How to enforce MCP tool policies
Model Context Protocol servers expose the tools they decide to offer. When you point an agent at one, you accept everything it offers. The MCP Gateway pattern flips that: a thin proxy sits between the agent and the upstream servers, evaluates a YAML policy on each governed tool call, and blocks the ones that should not run. This is also how you defend against tool poisoning: a compromised upstream tries to slip a changed or malicious tool definition past the agent. Four steps: drop the gateway in, name the upstreams, write the rules, point the client at it.
What you'll build
- An MCP Gateway container running between your agent and the upstream MCP servers.
- Upstream definitions for the postgres, filesystem, and github servers (or yours).
- Per-tool YAML rules including a tool-definition signature check for poisoning or mid-session drift.
- Agent config that points at the gateway endpoint with identity in the headers.
Step 1: Run Veto as the MCP Gateway
The gateway is a single container. Run it next to your agent with the policy directory and the upstreams file mounted in. The image speaks MCP on the north side and stdio or SSE on the south side, so the agent and the upstream servers do not need to know it is there.
# docker-compose.yaml
services:
veto-gateway:
image: ghcr.io/plawio/veto-mcp-gateway:<pinned-release>
ports:
- "8080:8080"
environment:
VETO_API_KEY: ${VETO_API_KEY}
VETO_MODE: enforce
VETO_POLICY_PATH: /etc/veto/policies
volumes:
- ./policies:/etc/veto/policies:ro
- ./upstreams.yaml:/etc/veto/upstreams.yaml:ro
The VETO_MODE environment variable controls enforcement. Start in shadow for the first week. Flip to enforce once theshadow-test signal is clean.
Step 2: Declare upstream MCP servers
The gateway needs to know which upstreams it proxies. Each entry names a transport (stdio for local processes, sse for hosted endpoints), a command or URL, and any credentials the upstream needed when the agent talked to it directly. The gateway becomes the single chokepoint where those credentials live.
# upstreams.yaml
upstreams:
- name: postgres
transport: stdio
command: ["npx", "-y", "@modelcontextprotocol/server-postgres", "postgresql://user:pass@localhost:5432/app"]
- name: filesystem
transport: stdio
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"]
- name: github
transport: sse
url: "https://mcp.github.com"
headers:
Authorization: "Bearer ${GITHUB_TOKEN}"
Notice the postgres connection string and the GitHub bearer token both live on the gateway, not in the agent config. Rotating either no longer requires touching every agent.
Step 3: Write per-tool YAML rules
The policy lives in YAML alongside the rest of your code. Each rule matches on upstream and tool, then narrows further with arguments. The interesting block at the bottom is theblock_unsigned_tool_definitions rule. That rule checks the signature of every tool definition served by an upstream and refuses anything that changed mid-session.
# policies/mcp.yaml
- name: filesystem_read_only_outside_workspace
match:
upstream: filesystem
rules:
- if: tool == "write_file" and not args.path.startsWith("/data/workspace/")
then: deny
- if: tool == "delete_file"
then: require_approval
- if: tool == "read_file" and args.path.contains("..")
then: deny
- name: postgres_no_destructive_statements
match:
upstream: postgres
rules:
- if: tool == "query" and args.sql matches "^(?i)(DROP|TRUNCATE|DELETE).*"
then: deny
- if: tool == "query" and args.sql matches "^(?i)UPDATE.*"
then: require_approval
- name: block_unsigned_tool_definitions
match:
upstream: "*"
rules:
- if: tool_definition.signature != context.expected_signature
then: deny
- if: tool_definition.last_changed_at > context.session_start
then: deny
Read the YAML format reference for the full grammar, including the tool-definition signature scheme and the in-process expression operators.
Step 4: Point the client at the gateway
Replace the MCP URL or command in the agent config with the gateway endpoint. Add a couple of headers so the gateway knows which agent is calling. The agent does not change otherwise; all tool calls now travel through Veto on the way to the upstream.
# In your agent or Claude Code config:
# Point at the gateway instead of the raw MCP servers.
{
"mcpServers": {
"veto-gateway": {
"url": "http://localhost:8080/mcp",
"headers": {
"X-Veto-Agent-Id": "ag_main_001",
"X-Veto-Agent-Role": "developer"
}
}
}
}See /integrations/mcp for client-specific guidance on Claude Desktop, Claude Code, and the OpenAI Assistants API.
Failure modes to catch
Leaving upstreams directly reachable
If the postgres or filesystem MCP server is still reachable on its original port, the gateway is a recommendation, not a control. Bind upstreams to localhost or to the gateway-only network so the only path through is the proxy.
No tool-definition signature check
Skipping the signature rule leaves you open to tool poisoning. A malicious upstream can change a tool definition between session start and a sensitive call. The signature check is the bit that blocks the drift.
Forgetting agent identity in headers
Without X-Veto-Agent-Id and X-Veto-Agent-Role, the gateway cannot tell which agent called. Per-agent rules fall back to a default and the decision record loses the identity. Set both headers on every client.
Production checklist
- Gateway is the only network path from the agent to the upstream MCP servers.
- Tool-definition signature rule is in policies and tested against a known-good fixture.
- Upstream credentials live on the gateway, not on the agent.
- Every client config sets X-Veto-Agent-Id and X-Veto-Agent-Role.
- VETO_MODE ran as shadow for at least seven days before flipping to enforce.
FAQ
How does this help with MCP tool poisoning?⌄
A compromised or malicious upstream MCP server can return a tool definition that includes hidden instructions or changes mid-session. The gateway pattern checks tool-definition signatures against an allow-list and refuses definitions that no longer match. See the block_unsigned_tool_definitions rule in step 3.
Does the gateway add latency?⌄
The gateway is a thin proxy that runs the same in-process YAML evaluator the Python preview uses. The bottleneck for an MCP call is usually the upstream server, not the policy decision.
Can I run the gateway in shadow mode?⌄
Yes. Set VETO_MODE=shadow on the gateway. Each governed decision is recorded but no action is blocked. Run for a week, tune the policy in the workspace, then flip to enforce. The shadow-test guide walks through the rollout.
Related guides
Use the same YAML policy with the Anthropic SDK without a gateway.
Shadow-test AI agent policiesRun the gateway in observe-only mode for a week before enforcement.
Contain prompt injectionCombine input filtering, output validation, and gateway policy in defense in depth.
MCP integrationClient-specific wiring for Claude Desktop, Claude Code, and others.
MCP GatewayGlossary entry on the gateway pattern and why it beats per-server policy.
Drop a policy gateway in front of your MCP servers.