MZQMohd Zamin Quadri
All work

Runtime policy enforcement for MCP tool calls

MCP Policy Gateway

A security checkpoint for AI tool calls.

Role
Sole author of the gateway, controls, corpus, benchmark and trace format
Classification
Engineering prototype
Inspect repository
Three lanes of traffic reach a boundary and get three different answers: pass, stop, or strip and continue.A boundary standing between a client and a downstream server. Three lanes of traffic reach it: a declaration passes, an argument set is stopped at the line, a returned document is stripped and continues thinner. Nine control plates stack along the boundary, and the one that has to make a judgement steps off it. Three bars compare no gateway, a keyword filter and the gateway on attacks caught against legitimate traffic refused.
  1. Problem

    A model cannot reliably tell data from instructions.

  2. How it was solved

    The gateway checks tool text at three moments.

    1. Discovery
    2. Request
    3. Response
    4. Trace
  3. Result

    96.2%

    of 26 attacks handled

    • Keyword filter38.5%
    • Gateway96.2%
  4. Built with

    • Python
    • Model Context Protocol
    • pytest
    See the evidence on GitHub
A boundary standing between a client and a downstream server. Three lanes of traffic reach it: a declaration passes, an argument set is stopped at the line, a returned document is stripped and continues thinner. Nine control plates stack along the boundary, and the one that has to make a judgement steps off it. Three bars compare no gateway, a keyword filter and the gateway on attacks caught against legitimate traffic refused.

Current engineering / Agent security

A tool description can be scanned before anyone calls it. The document that tool returns cannot; it does not exist yet. So what is worth putting at the boundary itself, and what does it cost in refused legitimate calls?

Attacks caught96.2%

How it works

Three moments a client has to trust

An MCP client trusts the tool declarations a server publishes, the arguments a model builds and the content a tool returns, each at a different time.

One engine, consulted at three boundaries

lists toolscalls toolallow / sanitiseblock / holdreturns contentrecordsModel / agentSpeaks to the gateway, neverto the server directly.DiscoveryThe tool list, before anythingis called.RequestArguments, before the call isforwarded.ResponseReturned content, before itre-enters context.Surface controlsToolAllowlist, ToolShadowing,DestructiveAction.Argument controlsSchemaConformance,PathSandbox, BudgetControl.Content controlsSecretDisclosure,EgressControl…Severity mergeMost severe action wins. Everyfinding kept, none weighted.Downstream serverReached only by calls theengine allowed.Refused or heldblock, or require_approvalwhere the effect cannot be…Decision traceWhich rules fired, and howlong they took. Never the…
The same nine controls and the same severity merge run at all three stages. What differs is which controls declare an interest - and that the response stage inspects a document that did not exist when the call was allowed.
  1. Problem

    A tool description and a tool result are both prose that ends up in front of the model, which has no reliable way to tell a sentence describing data from one instructing it.

  2. Relay

    The downstream tool list is relayed with declarations that carry instructions withheld or stripped.

  3. Check

    Arguments are checked against the schema the server itself published, then the call is forwarded, refused, or held for a human if its effects cannot be undone.

  4. Inspect

    Returned content is inspected before it is read back into the model's context.

  5. Record

    Each decision is recorded with the rules that fired and the timing, without copying the payload.

  6. What this does not show

    Paraphrase defeats pattern matching, so the headline is an upper bound against the attack forms represented. The corpus is mine, which is not the same as an independent evaluation.

    Inspect repository

This diagram in words

  1. Model / agent source — Speaks to the gateway, never to the server directly.
    asyncio over stdio transport
  2. Discovery gate — The tool list, before anything is called.
  3. Request gate — Arguments, before the call is forwarded.
  4. Response gate — Returned content, before it re-enters context.
  5. Surface controls process — ToolAllowlist, ToolShadowing, DestructiveAction.
  6. Argument controls process — SchemaConformance, PathSandbox, BudgetControl.
  7. Content controls process — SecretDisclosure, EgressControl, InstructionInjection.
  8. Severity merge gate — Most severe action wins. Every finding kept, none weighted.
  9. Downstream server sink — Reached only by calls the engine allowed.
    Model Context Protocol SDK 2.x (dependency, MIT, Anthropic PBC)
  10. Refused or held sink — block, or require_approval where the effect cannot be undone.
  11. Decision trace artifact — Which rules fired, and how long they took. Never the payload.

01 / 11Three moments, not oneContent crosses into a model's context three times, and they are not the same event.

Moments
3
Scannable ahead
1 of 3
A boundary standing between a client and a downstream server. Three lanes of traffic reach it: a declaration passes, an argument set is stopped at the line, a returned document is stripped and continues thinner. Nine control plates stack along the boundary, and the one that has to make a judgement steps off it. Three bars compare no gateway, a keyword filter and the gateway on attacks caught against legitimate traffic refused.
Technical questionAn MCP client trusts three things at three different times. Which of them can be checked before anyone runs anything?
The third row is the one this project exists for. A document carrying an instruction did not exist when the server was scanned.
MomentWho wrote itCheckable in advanceWhat can go wrong
DeclarationsThe server authorYes, a scanner reads thisInstructions hidden in a description the client pastes into the prompt.
ArgumentsThe model, from the schemaNoPaths that escape a sandbox, types the schema never declared.
ResultsWhoever wrote the underlying dataNoAn instruction inside a document that did not exist when the server was scanned.

Static scanners read declarations before use. That covers the first row well and cannot reach the third at all, not because the tools are weak, but because of when they run. The gateway sits at the protocol boundary, where all three are visible.

Where the uncertainty lives9 controls across three stages. 8 of them answer questions that have a decidable answer; one has to make a judgement.
Counts sum to more than 26 because several cases are caught by more than one control. Every control runs on every event even after another has blocked, so these numbers are not an artefact of ordering.
ControlStageAttacks caughtBenign touched
instruction_injectionInstructions addressed to the model, inside text the model is about to read.discovery, response165
egress_controlHosts outside the allowlist, including suffix-confusion lookalikes.request, response40
path_sandboxPaths that resolve outside the root, in either path grammar, symlinks included.request40
secret_disclosureCredential-shaped values, redacted rather than refused.request, response30
schema_conformanceArguments checked against the schema the server itself published.request20
tool_allowlistExact-match permit list. Empty means deny everything.request20
tool_shadowingA server claiming a tool name another server already owns.discovery10
destructive_actionIrreversible calls held for a human instead of refused.request10
budgetCall counts, output bytes, and a per-tool circuit breaker.request, response10

8 of 9 controls have no false positives on this corpus. Every benign case that gets touched is touched by instruction_injection, the one control permitted to be uncertain. That is the argument for keeping judgement in a single place rather than spreading a little of it through all nine, and a test enforces it.

ResultAttacks caught against legitimate traffic refused, on 47 deterministic cases. No model call, no network, the same table on any machine.

No gatewayWhat an MCP client does today.

0.0%
0.0%

Keyword filterSubstring matching on the obvious phrases. What gets built first.

33.3%
38.5%

Policy gatewayNine controls across three stages.

9.5%
96.2%

The keyword filter is in the table because it is the real alternative, substring matching on the obvious phrases is what gets written when a team decides to do something about prompt injection. It catches 38.5% of the attacks and refuses 33.3% of legitimate calls. That second number is the profile of a control an operator switches off, at which point its real effectiveness is zero. Comparing only against no gateway at all would have hidden that.

Source: assets/results.json at commit 4a14af8, generated by evaluation/make_evidence.py.

What does not holdFour of 47 cases are not handled. All four were written before the controls were, and a test names them exactly, so a new failure breaks the build rather than quietly lowering the headline.

Missed attacks 2

inject-006 Base64 of an override instruction, with no plaintext directive around it.

Matching runs on raw text. Decoding every base64-looking span would flag every legitimate encoded attachment, and a second encoding layer defeats it anyway.

secret-004 A bare 64-character hex credential.

Catching it means flagging every SHA-256 digest in every document. The control keys on prefixes and assignment shape instead.

False positives 2

fp-known-001 A runbook saying "ignore the previous instructions in section 3".

Genuine operator prose with the exact shape of an attack. Separating them needs to know that 'section 3' makes it self-referential.

fp-known-002 Onboarding text asking someone to "show your system prompt".

Internal documentation discusses prompts now, and the rule cannot tell who is being addressed.

Both false positives are the same problem: a sentence is an attack or not depending on who is meant to read it, and that is not recoverable from the text. Any rule strong enough to catch a real injection catches a runbook that means it. This is the limit of the approach rather than a gap in the implementation.

A bug the corpus could not findA withheld tool that could still be called
  1. What happenedThe gateway hid a poisoned tool at discovery, then executed a direct call to it.
  2. WhyHiding a name from a listing is not the same as making it uncallable. A client holding the name from an earlier session can still ask, and the request-stage controls never learn that the declaration was rejected; they only see the call.
  3. FixThe gateway records withheld tools and refuses calls to them.

The corpus tests decisions. This was a bug in wiring, and only a test that spoke the protocol could reach it. It was found by integration tests, over a real mcp subprocess, the tests that launch a real MCP server as a subprocess and talk to it over stdio, rather than exercising the policy engine as a library.

What the deterministic design costsEvery decision is a function of the text, which is what makes the benchmark reproduce exactly and CI need no API key. It is also why these five things are true.
  • Paraphrase defeats it. Every rule keys on a shape, so the 96.2% is an upper bound against the attack forms represented, not a general claim.
  • Base64 is decoded only where it returns prose, so attachments and digests are never rescanned. Nested or multi-layer encodings still pass.
  • The false-refusal advantage over a keyword filter is not established: 9.5% and 33.3% carry 95% intervals of [2.7%, 28.9%] and [17.2%, 54.6%], which overlap. The recall gap does not.
  • One event at a time. An instruction assembled across several tool results is invisible.
  • English only. Images, PDFs and binary content are not inspected at all.
  • The corpus is mine. Ground truth was written before the controls and documented misses were kept, but that is not the same as an independent evaluation.

The first is the important one. A paraphrased attack that avoids every pattern gets through, so 96.2% is an upper bound against the attack forms represented rather than a general claim. A production deployment would sensibly run patterns at the boundary for the cheap certain cases and a model only for the ambiguous remainder.

What is mine, and what is notThe protocol is somebody else’s work. Everything at the boundary is mine.

Dependency — not my work

Model Context Protocol Python SDK

Package
mcp
Licence
MIT
Copyright
Anthropic, PBC and contributors

Protocol implementation. Installed as a dependency; no source vendored.

Targets the 2.x API, where FastMCP was renamed MCPServer.

Written for this project

  • The proxying gateway, fail-closed, over real stdio MCP
  • All 9 controls and the engine that combines their findings
  • The corpus: 26 attacks across 10 classes and 21 benign near-misses, with ground truth per case
  • The three-configuration benchmark, including the keyword baseline
  • The JSONL trace format that records digests rather than payloads
  • 98 tests, and CI running the benchmark and the end-to-end demo on 3.11, 3.12, 3.13

Prior art read but not used as code: OWASP, MCP Tool Poisoning, Snyk agent-scan. The attack-class names in the corpus come from the OWASP taxonomy so that the results are comparable to categories other people already recognise. Full detail in NOTICE.

Read the full background

Problem

Why this work exists

A tool description is prose the client concatenates into a prompt, and a tool result is prose appended to the conversation. A model has no reliable way to tell a sentence that describes data from a sentence that instructs it, and there is no parameterised query for a prompt. So the question is not how to make the model resistant to persuasion; it is how much of the deputy's authority can be taken away at the boundary before the cost in refused legitimate calls makes an operator switch the control off.

Contribution

What I can claim

Everything except the protocol itself. The gateway is an MCP server to the client and an MCP client to the downstream server, so it needs no cooperation from either and works against a server whose source I do not have. Nine controls run across three stages; eight of them answer decidable questions and one has to make a judgement, which is deliberate; it means the false-positive story has exactly one owner. The Model Context Protocol SDK is a dependency and is not my work.

How it was built

System

Workflow and decisions

Client speaks MCP to the gateway; the gateway speaks MCP to the real server. Policy runs when declarations are relayed, before arguments are forwarded, and before results re-enter context. Every decision is written to a JSON Lines trace that stores digests and lengths rather than payloads.

  1. 1Relay the downstream tool list, withholding or stripping declarations that carry instructions
  2. 2Check arguments against the schema the server itself published, and against the sandbox
  3. 3Forward the call, or refuse it, or hold it for a human if its effects cannot be undone
  4. 4Inspect the returned content before it is read back into the model's context
  5. 5Record the decision, the rules that fired and the timing, without copying the payload
  • Python 3.11 to 3.13
  • Model Context Protocol SDK 2.x (dependency, MIT, Anthropic PBC)
  • asyncio over stdio transport
  • pytest
  • ruff
  • mypy
  • GitHub Actions

Evidence

What is actually versioned

Attacks caught96.2%

25 of 26 adversarial cases handled at or above the response each case requires. Deterministic: no model call, no network.

Legitimate traffic refused9.5%

2 of 21 benign near-misses blocked. The keyword filter it is compared against refuses 33.3%.

Controls with no false positives8 of 9

Every benign case touched is touched by the one control permitted to be uncertain. A test enforces this.

Median decision191 µs

A property of the machine it ran on rather than of the system: eight runs on one machine ranged from 191 to 710 µs. Read the order of magnitude, which is well under a millisecond of policy per call, against a call that touches a disk or a network.

Inspection points

Go directly to the evidence

How the work is checked

Quality controls

How the work is checked

  • 234 tests: unit, engine properties, corpus hygiene, and integration over a real MCP subprocess
  • CI runs lint, format, mypy, the tests, the benchmark and the end-to-end demo on 3.11, 3.12 and 3.13
  • The benchmark reproduces exactly from a clean clone, with no API key, network or GPU
  • Headline numbers are pinned by tests, and the three known failures are named exactly so a new one fails the build

Limitations

Where the evidence stops

  • Paraphrase defeats pattern matching, so 96.2% is an upper bound against the attack forms represented
  • Base64 is decoded only when it returns prose, so attachments and keys are never re-scanned; nested or multi-layer encodings are still invisible
  • One event at a time: an instruction assembled across several tool results is invisible
  • English only; images, PDFs and binary content are not inspected
  • The corpus is mine, which is not the same as an independent evaluation
What this changed in my practice

What this changed in my practice

The benign half is the hard half. Writing attacks took an afternoon; the near-misses forced every interesting decision, matching on imperative shape rather than vocabulary, four actions instead of allow and deny, demoting a finding when the text is quoting rather than demanding.

Next evidence milestone: A paraphrase axis, to put a number on how much the headline over-states things, and attacks that deliberately exploit the reporting-context demotion.