⟨ INCOMING TRANSMISSION ⟩ 200,000 MCP instances exposed by April 2026 security disclosure (OX Security) · 97M monthly MCP SDK downloads, up from ~2M at launch (Anthropic, Mar 2026) · RSAC 2026: $392M raised in agentic security in one week · EU AI Act fully applicable August 2026 · Microsoft (Apr 2026): MCP tool execution needs a control plane · sources: sentnelops.com/research/mcp-landscape · ⟨ INCOMING TRANSMISSION ⟩ 200,000 MCP instances exposed by April 2026 security disclosure (OX Security) · 97M monthly MCP SDK downloads, up from ~2M at launch (Anthropic, Mar 2026) · RSAC 2026: $392M raised in agentic security in one week · EU AI Act fully applicable August 2026 · Microsoft (Apr 2026): MCP tool execution needs a control plane · sources: sentnelops.com/research/mcp-landscape ·

[ LEARN // IMPLEMENTATION ]

Add policy enforcement to an MCP server

Adding policy enforcement to an MCP server means every tool call gets evaluated against explicit rules — permit, block, or require human approval — before the tool executes, with the decision logged. This guide covers the two placements that work (middleware inside the server, or a proxy in front of it) and the policy, logging, and CI practices that make enforcement operable.

[ TWO PLACEMENTS THAT WORK ]

Enforcement has to sit in the request path — between the agent's decision to call a tool and the tool executing. There are exactly two places to put it, and the trade-offs are real in both directions.

In-server middleware

Wrap every tool handler with a policy check inside the MCP server process itself. The check runs before the handler, with the full parameter context already parsed, and there is no extra network hop.

  • +Full parameter context — the check sees exactly what the handler sees
  • +No added network hop
  • +Fine for servers you wrote and operate
  • Policy fragments across every server and every language you run
  • Enforcement lives inside the same process an injected agent is driving
  • Useless for third-party servers you cannot modify

Proxy in front (MCP firewall)

The client connects to a proxy instead of the server. The proxy evaluates each tool call against policy, then forwards permitted calls and drops or holds the rest. The server never sees a blocked call.

  • +One policy file governs every server behind the proxy
  • +Enforcement sits outside the blast radius of a compromised server or agent
  • +Works with servers you don't control — no code changes
  • Adds a network hop (SentnelOps publishes under 15ms p99 for its proxy)
  • One more component to deploy and operate

The practical recommendation: proxy for coverage, middleware for defense-in-depth. A proxy gives you one policy and one log across every server, including the ones you didn't write. Middleware inside the servers you own adds a second, independent check that survives even if traffic somehow reaches the server directly. This placement question is the core implementation decision of AI agent runtime governance — everything after it is the same four steps regardless of where the check runs.

[ STEP 1 — WRITE THE POLICY ]

Default-deny, explicit grants, named rules

Start from a rule that blocks everything, then grant narrowly. Each rule matches on agent, server, tool, and optional call conditions, and produces one of three actions: permit, block, or require_approval. Name every rule — the name is what shows up in logs and test failures.

# policy.yaml — default posture: nothing is granted until it is granted.
- name: default-deny
  agent: "*"
  server: "*"
  tool: "*"
  action: block

# The review bot may read code and comment — nothing else.
- name: review-bot-read-and-comment
  agent: review-bot
  server: github
  tool: [read_file, list_pull_requests, create_comment]
  action: permit

# Merges to main pause for a human, with full context.
- name: gate-merges-to-main
  agent: deploy-bot
  server: github
  tool: merge_pull_request
  when: { branch: main }
  action: require_approval

# Destructive infrastructure tools are never automated.
- name: no-destructive-infra
  agent: "*"
  server: aws
  tool: [ec2_terminate_instance, s3_delete_bucket, rds_delete_db_instance]
  action: block

One policy file per environment — staging and production diverge on purpose.

This is policy as code: it lives in a repository, changes arrive as pull requests, and a reviewer can see exactly which agent gains which capability in the diff. Keep one file per environment rather than one file with environment switches — the production policy should be readable on its own, without mentally evaluating conditionals.

[ STEP 2 — ENFORCE IT ]

The middleware shape

For the in-server placement, the enforcement point is a wrapper that runs before every tool handler. The shape is the same in any language — this is illustrative pseudocode, not a specific SDK's API:

# Illustrative pseudocode — the shape, not a library.
def before_tool_call(agent, tool, params):
    decision = evaluate(policy, agent, tool, params)

    if decision.action == "block":
        log(agent, tool, params, decision)
        # Structured denial: name the rule and the reason.
        return tool_error(
            code="policy_blocked",
            message=f"Blocked by rule '{decision.rule}': {decision.reason}",
        )

    if decision.action == "require_approval":
        log(agent, tool, params, decision)
        notify_approvers(agent, tool, params)
        wait_for_approval()   # hold the call; resume only on explicit approval

    result = execute(tool, params)   # reached only on permit or approval
    log(agent, tool, params, decision, result.status)
    return result

Illustrative middleware wrapper — every tool handler runs behind this check.

The structured denial matters more than it looks. The error goes back to the model, and a model that reads "blocked by rule no-destructive-infra" can re-plan — choose a different tool, ask its operator, or stop. A generic failure looks transient, and agents retry transient failures, so a silent block becomes a retry loop.

For the proxy placement, the server code doesn't change at all. The client's MCP configuration swaps the server URL for the proxy URL:

// Before: the client talks straight to the MCP server.
{
  "mcpServers": {
    "github": { "url": "https://github-mcp.internal.example.com/mcp" }
  }
}

// After: same client, same server — the proxy now sits in the path.
{
  "mcpServers": {
    "github": { "url": "https://mcp-proxy.internal.example.com/github" }
  }
}

Generic MCP client configuration — enforcement added with a one-line URL swap.

[ STEP 3 — LOG THE DECISIONS ]

One record per decision, including the blocks

Every evaluation produces a record — permitted calls, blocked calls, and approvals alike. Blocked calls are often the most valuable entries in the log: they are the attempts, and attempts are what an incident review or an auditor asks about first.

{
  "timestamp": "2026-09-07T14:32:08Z",
  "agent": "deploy-bot",
  "server": "github",
  "tool": "merge_pull_request",
  "params": { "repo": "acme/api", "pull_number": 4312, "branch": "main" },
  "decision": "require_approval",
  "rule": "gate-merges-to-main"
}

One decision record: who, what, with which parameters, what was decided, and why.

Two properties are non-negotiable. The log is append-only — an agent (or anyone driving it) must not be able to edit history. And it lives in your own database, complete enough to answer "which agent did what, and was it allowed?" without reconstruction from application logs.

[ STEP 4 — TEST IN CI ]

Policy is production code — test it like production code

A policy change that over-permits is a security bug; one that over-blocks is an outage for every agent behind it. Both are catchable before merge with unit-style cases: sample calls, each asserting the decision the policy must produce.

# policy-tests.yaml — evaluated against policy.yaml on every policy PR.
- name: review bot may comment
  call: { agent: review-bot, server: github, tool: create_comment }
  expect: permit

- name: review bot may not merge
  call: { agent: review-bot, server: github, tool: merge_pull_request }
  expect: block

- name: merges to main pause for a human
  call:
    agent: deploy-bot
    server: github
    tool: merge_pull_request
    params: { branch: main }
  expect: require_approval

- name: nothing terminates production instances
  call: { agent: deploy-bot, server: aws, tool: ec2_terminate_instance }
  expect: block

- name: unknown agents get nothing
  call: { agent: some-new-agent, server: github, tool: read_file }
  expect: block

Case → expected decision. A failing case blocks the merge.

Run the suite on every pull request that touches policy; a failing case blocks the merge. When a rule changes, the diff shows the intent and the test run proves the effect. This is what makes governance changes safe to ship on a Friday — the same reason any other production change is.

[ ROLLOUT PRACTICES ]

Observe first, enforce second

  • Start in log-only mode: evaluate every call against policy and record what would have been blocked, without blocking anything. This surfaces the calls your agents actually make — usually a longer list than anyone expected.
  • Flip to enforce only after reviewing at least a week of decisions. The log-only period is where you discover the legitimate call your default-deny rule would have broken on day one.
  • Add require_approval gates before broad blocks. A held call that a human can release in a minute keeps a team unblocked; a hard block on a tool they need generates a ticket and an exemption request.
  • Review blocked-call reports weekly. A spike in blocks on one rule means either an agent is misbehaving or the policy no longer matches how the team works — both are worth knowing.

[ HOW SENTNELOPS IMPLEMENTS THIS ]

SentnelOps implements the proxy placement as an MCP firewall deployed in your own VPC: agents point at the proxy instead of the MCP server, every tool call is evaluated against YAML runtime policy in under 15ms p99, require_approval rules arrive as Slack DMs with full context, and every decision is logged to your own database with the rule that produced it. The quickstart gets the first call logged in under 10 minutes.

[ FREQUENTLY ASKED QUESTIONS ]

Should policy enforcement live inside the MCP server or in front of it?

A proxy in front is the better default: one policy covers every server including third-party ones you cannot modify, and enforcement sits outside the process an injected agent is driving. In-server middleware is worth adding as defense-in-depth on servers you own, where the check gets full parameter context with no network hop. The honest weakness of middleware alone is fragmentation — policy scattered across every server and language you run.

What should happen when a tool call is blocked?

Return a structured denial the model can read: an error stating the call was blocked by policy, which rule matched, and why. A clear denial lets the agent re-plan — pick another tool, ask its operator, or stop. A silent failure or generic error looks like a transient fault, and agents respond to transient faults by retrying, so an unexplained block turns into a retry loop.

How do I test policy changes before they reach production?

Treat the policy file like production code. Keep a suite of test cases — sample tool calls with the decision each should produce — and run the policy engine against them in CI on every pull request that touches policy. A failing case blocks the merge. This catches both accidental over-permitting and the rule that would have blocked a call your agents legitimately make.

Does putting a proxy in front of my MCP servers add meaningful latency?

A policy evaluation is a rule lookup, so a well-built proxy adds single-digit to low-double-digit milliseconds — small next to the seconds a model spends deciding to make the call and the time the tool itself takes. For reference, SentnelOps publishes under 15ms p99 overhead for its proxy. If a governance layer adds more latency than the tool call it governs, that is an implementation problem, not an architectural one.

Can I roll this out without breaking my team's agents?

Yes — start in log-only mode, where every call is evaluated and the would-be decision is recorded but nothing is blocked. Review a week of decisions, fix the rules that would have broken legitimate work, then flip to enforce. Using require_approval instead of block for borderline operations also keeps teams moving: a held call a human approves in a minute beats a hard block and an exemption ticket.

← All learn articles