Mohd Zamin Quadri

GitHubLinkedIn

All work

Runtime policy enforcement for MCP tool calls

The boundary a scanner cannot stand at

An MCP client trusts three things at three different times: the tool declarations a server publishes, the arguments a model builds, and the content a tool returns. The security tooling that exists reads declarations before use, which covers the first and cannot reach the third — a poisoned search result does not exist until the search runs. This puts an enforcement point at the protocol boundary, where all three are visible, and then measures what each control is worth against a corpus that includes the legitimate traffic a careless rule would break.

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.
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, response154
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 44 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.

38.9%
38.5%

Policy gatewayNine controls across three stages.

11.1%
92.3%

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 38.9% 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 d5bd208, generated by evaluation/make_evidence.py.

What does not holdFour of 44 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 92.3% is an upper bound against the attack forms represented, not a general claim.
  • No decoding before matching, so encoded payloads pass.
  • 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 92.3% 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 18 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.

01Problem

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.

02Contribution

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.

03System

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

04Evidence

What is actually versioned

Attacks caught92.3%

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

Legitimate traffic refused11.1%

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

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 decision99.6 µs

Roughly 0.3 ms of policy per tool call across three stages, against a call that touches a disk or a network.

05Inspection points

Go directly to the evidence

06Quality controls

How the work is checked

  • 98 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 four known failures are named exactly so a new one fails the build

07Limitations

Where the evidence stops

  • Paraphrase defeats pattern matching, so 92.3% is an upper bound against the attack forms represented
  • Encoded payloads are not decoded before matching, and one corpus case is kept as a scored miss because of it
  • 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

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.