MCP Governance Proxy
The @aaf/mcp-proxy package provides a governance layer for Model Context Protocol (MCP) tool calls. It intercepts tools/call JSON-RPC messages, evaluates them against AAF policies, and blocks or allows tool usage based on your rules.
Architecture
@aaf/mcp-proxy sits between the MCP client (your AI agent or Claude Desktop) and the MCP server process. It operates at the JSON-RPC message level: every tools/call message is intercepted, evaluated against AAF policies, and either forwarded to the server or blocked — before the server ever sees it. All other MCP messages (initialize, tools/list, notifications, etc.) pass through unchanged.
Evaluation pipeline
When a tools/call message arrives the proxy runs two sequential checks:
- Local governance pre-check — Instantly evaluated in-process using config-based rules: governance profile (
unrestricted,read_only,locked), server-level tool allowlist/denylist, and per-agent tool allowlist/denylist. No network call required. Short-circuits todenyif a local rule matches. - AAF policy evaluation — If the local check passes, the proxy submits the tool name and arguments to
POST /v1/actionson the AAF API. The three-tier AAF cascade (OPA/Rego → DB policy rules → heuristic fallback) returnsallow,deny, orrequire_approval.
For require_approval decisions the proxy polls GET /v1/approvals/:id every 2 seconds (configurable) until a human approves or denies, or the timeout elapses (default 30 seconds). On timeout or denial the proxy returns a JSON-RPC error to the client.
If the AAF API is unreachable the proxy defaults to fail closed (deny all) — configurable to fail open via failClosedOnError: false.
Sequence diagram
sequenceDiagram
participant Agent as AI Agent / MCP Client
participant Proxy as @aaf/mcp-proxy
participant AAF as AAF /v1/actions
participant Approver as Human Approver
participant Server as MCP Server
Agent->>Proxy: tools/call { name, arguments }
Note over Proxy: Local governance pre-check<br/>(profile, allowlist, denylist)
alt Local deny
Proxy-->>Agent: JSON-RPC error -32000 (blocked)
else Local pass
Proxy->>AAF: POST /v1/actions { tool, operation, parameters }
AAF-->>Proxy: decision: allow | deny | require_approval
alt allow
Proxy->>Server: tools/call (forwarded)
Server-->>Proxy: tools/call result
Proxy-->>Agent: tools/call result
else deny
Proxy-->>Agent: JSON-RPC error -32000 (blocked by policy)
else require_approval
loop Poll every 2s (up to approvalTimeoutMs)
Proxy->>AAF: GET /v1/approvals/:id
AAF-->>Proxy: status: pending | approved | denied
end
alt approved
Proxy->>Server: tools/call (forwarded)
Server-->>Proxy: tools/call result
Proxy-->>Agent: tools/call result
else denied / timeout
Proxy-->>Agent: JSON-RPC error -32001 (approval timeout/denied)
end
end
end
Transport modes
The proxy supports two transport modes that differ only in how messages are delivered — the policy evaluation pipeline is identical in both cases.
Stdio transport — The proxy spawns the MCP server as a child process (stdio: pipe). Client stdin is read line-by-line (newline-delimited JSON-RPC). tools/call lines are intercepted; all other lines are forwarded directly to the server's stdin. Server stdout is piped directly back to the client. This is the mode used by the CLI and proxy.wrapStdio().
HTTP/SSE transport — The proxy runs as an Express/Fastify middleware on your POST /mcp route. Non-tools/call requests fall through via next(). Blocked tool calls are returned as SSE error events (text/event-stream) before the request reaches your MCP server handler. This is the mode used by proxy.createSSEMiddleware().
Environment variables
| Variable | Default | Description |
|---|---|---|
AAF_API_KEY | — | Agent API key (alternative to --api-key flag) |
MCP_PROXY_REQUEST_TIMEOUT_MS | 5000 | Timeout for individual AAF API HTTP requests (policy evaluation call). Distinct from approvalTimeoutMs which governs the human approval polling window. |
AAF_API_URL | https://api.agentactionfirewall.com | AAF API base URL (self-hosted deployments) |
Installation
npm install @aaf/mcp-proxy
Quick Start: CLI
Wrap any MCP stdio server with governance in a single command:
npx @aaf/mcp-proxy \
--api-key YOUR_AAF_API_KEY \
-- uvx mcp-server-filesystem /tmp
This starts the MCP filesystem server as a child process, with all tools/call messages routed through AAF for policy evaluation before reaching the server.
CLI Options
| Flag | Description | Default |
|---|---|---|
--api-key | AAF API key (or AAF_API_KEY env var) | Required |
--api-url | AAF API endpoint | https://api.agentactionfirewall.com |
--agent-id | Agent identifier | mcp-proxy |
--verbose | Enable debug logging | false |
Programmatic Usage
Stdio Transport
Wrap a child process MCP server:
import { MCPProxy } from '@aaf/mcp-proxy';
const proxy = new MCPProxy({
apiKey: process.env.AAF_API_KEY!,
apiUrl: 'https://api.agentactionfirewall.com',
agentId: 'my-mcp-proxy',
});
// Wrap any stdio-based MCP server
proxy.wrapStdio('uvx', ['mcp-server-filesystem', '/tmp']);
HTTP/SSE Middleware
Use as Express/Fastify middleware for HTTP-based MCP servers:
import express from 'express';
import { MCPProxy } from '@aaf/mcp-proxy';
const app = express();
const proxy = new MCPProxy({
apiKey: process.env.AAF_API_KEY!,
});
// Intercept MCP requests before your handler
app.post('/mcp', proxy.createSSEMiddleware(), yourMCPHandler);
Programmatic Interception
Check individual tool calls without wrapping a server:
const proxy = new MCPProxy({
apiKey: process.env.AAF_API_KEY!,
});
const result = await proxy.interceptToolCall('file_write', {
path: '/etc/passwd',
content: 'malicious content',
});
if (!result.allowed) {
console.error(`Blocked: ${result.reason}`);
// result.reason: "SSRF: access to system files denied"
}
Policy Examples
Block Dangerous File Operations
package aaf.policy
# Deny writes to system directories
decision = "deny" {
input.action.tool == "file_write"
path := input.action.params.path
startswith(path, "/etc/")
}
decision = "deny" {
input.action.tool == "file_write"
path := input.action.params.path
startswith(path, "/usr/")
}
Require Approval for Shell Commands
package aaf.policy
# All shell commands require human approval
decision = "require_approval" {
input.action.tool == "run_command"
}
Allow Only Specific MCP Tools
package aaf.policy
# Allowlist specific tools
allowed_tools := {"file_read", "web_search", "calculator"}
decision = "deny" {
not input.action.tool in allowed_tools
}
Supported MCP Servers
The proxy works with any MCP-compliant server. Commonly used with:
| Server | Package | Description |
|---|---|---|
| Filesystem | mcp-server-filesystem | File read/write operations |
| GitHub | mcp-server-github | Repository operations |
| PostgreSQL | mcp-server-postgres | Database queries |
| Brave Search | mcp-server-brave-search | Web search |
| Puppeteer | mcp-server-puppeteer | Browser automation |
Monitoring
All intercepted tool calls appear in the AAF dashboard:
- Action feed: Each MCP tool call shows as an action with tool name, parameters, and decision
- Audit trail: Full hash-chained audit of every tool call, approval, and result
- Analytics: Tool usage patterns, denial rates, and latency metrics
Best Practices
Start in monitor mode. Use AAF's dry-run mode initially to observe tool call patterns before enforcing policies.
Use tool allowlists. Rather than blocking specific dangerous tools, define an allowlist of tools your agent should use.
Combine with guardrails. MCP tool parameters are scanned by the guardrails engine for prompt injection before policy evaluation.
Next Steps
- Policy Engine — Write policies for MCP tool governance
- TypeScript SDK — Integrate AAF directly in your agent code