Integration guide

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.

  • 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.

yaml
# 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.

yaml
# 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.

yaml
# 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.

json
# 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

Drop a policy gateway in front of your MCP servers.