Skip to content
← All case studies

02 — Open source

Toolgate — MCP Security Gateway

Closing a gap the AI agent protocol names but does not solve

2026Sole authorsource on GitHub ↗
Protocol
MCP 2026-07-28
Runtime
Java 21 · Spring WebFlux
Tests
79, adversarial
Code
Public · runnable

The problem

The Model Context Protocol is how AI agents reach external tools. Its specification is unusually direct about the risk it carries:

Clients MUST consider tool annotations to be untrusted unless they come from trusted servers.

and then, in the same document:

While MCP itself cannot enforce these security principles at the protocol level, implementors SHOULD implement appropriate access controls.

So the spec names the threat and correctly admits it has no mechanism for it. Every implementor is told to solve the same problem alone.

In 2026 that stopped being theoretical. Researchers disclosed a systemic vulnerability affecting an estimated 200,000 MCP instances, and separately hijacked several mainstream coding agents by planting instructions in content those agents treated as trusted context.

The attack requires no code execution. A tool’s description is read by the model as operational instruction. Change the description and you change the agent’s behaviour. Nothing in the protocol notices.

Shape of the system

toolgate / mcp policy gateway
AI Agenttools/listtools/callTOOLGATESpring WebFlux · deny by default1 · allowlistnot a heuristic2 · fingerprint pinSHA-256, canonical3 · injection scanweighted, defence in depth4 · human gatesingle-use, expiringaudit — every decisionincluding what was refused, and whyallowed → namespaceddenied — never enters contextMCP SERVERSfilesread_file, write_filedatabasequerycompromiseddescription rewrittenOperator APIapprove · pins · auditseparate path — the agent cannot reach ittrust boundary
Policy runs at tools/list, not at tools/call. A poisoned description blocked at call time has already been read by the model — by then it may be acting on those instructions through a different tool entirely.

The agent points at the gateway instead of at its tool servers. Every tool the agent can see has survived four checks; every decision, including the refusals, is on the record.

Why the ordering is the design

The controls run in a deliberate sequence, and the order carries more weight than any individual check:

  1. Allowlist — the only control here that is not a heuristic. A tool nobody authorised is refused before anything else is considered, and the answer to “what can this agent reach?” lives in one reviewable file.
  2. Fingerprint — a canonical SHA-256 over every field the model reads: name, title, description, input and output schema, annotations. Keys are sorted so reordering is not mistaken for tampering; values are type-tagged so "1" and 1 cannot collide.
  3. Header confinement — the one field in a definition that instructs the transport rather than the model, checked separately because it is the only way a hostile server can reach past the message body into an HTTP header.
  4. Content scan — weighted heuristics for imperative phrasing, credential paths, exfiltration shapes, and invisible unicode used to hide text from human reviewers while leaving it legible to a model.
  5. Human gate — destructive tools require a person, with grants that are single-use and expire.

The critical property is when this runs. Filtering happens at tools/list, so a poisoned definition never enters the model’s context at all. A gateway that waits until the tool is invoked has already lost: the instructions were read the moment the tool list was rendered into the prompt.

Proving it, adversarially

A security control without an attack suite is an assertion. The tests run against a real MCP server that turns hostile on command — an actual HTTP server, not a mocked client, because the bugs live in the transport, the serialisation and the wiring, which is exactly what a mock assumes away.

The main scenario walks a full compromise: the agent discovers tools, an unlisted exec_shell never reaches it, a clean call succeeds, a destructive tool demands human approval — and then the upstream server is compromised mid-session and rewrites a tool’s description to read a private key and post it to an external host. The drifted tool disappears from the agent’s view, an unchanged tool alongside it is unaffected, and the refusal is in the audit log with its evidence.

Two bugs surfaced while writing those tests. One was a genuine test-isolation defect — shared state between scenarios meant a green suite depended on method order, which makes it worthless. The other was more interesting: an assertion failed because a tool was allowlisted in configuration but never actually advertised by the server, so the gateway refused it as “never advertised through this gateway.” The gateway was right and the test was wrong — a defence-in-depth check catching something its own author had not thought through.

Running it against a server that fights back

The repository ships a hostile MCP server and a script that walks through what happens.

docker compose up --build -d
./demo/walkthrough.sh

The server advertises four tools — one per control — and exposes an endpoint that rewrites a tool’s description after it has been approved. Nothing is mocked; every line is the gateway’s real answer:

2. What the agent is actually shown
  - demo__read_file
  - demo__send_email

10. The record
  DENIED             demo/fetch_url    tool declares an unacceptable x-mcp-header mirror
  DENIED             demo/search_docs  tool metadata contains adversarial content (score 70)
  DENIED             demo/read_file    tool definition changed since it was pinned
  APPROVAL_REQUIRED  demo/send_email   tool is marked as requiring human approval
  ALLOWED            demo/read_file    allowlisted and pinned

Four tools go in, two come out, and the two that were refused never entered the model’s context. The poisoning stays blocked across a container restart, because the pins are on a volume — without one, a restart would treat the mutated definition as a first sighting and simply trust it.

Building the demo found a flaw the unit tests had not. The integrity check runs before the content checks and pins whatever it sees for the first time — so a tool refused for poisoning still became the trusted baseline, and when the upstream fixed the description, the repair arrived as drift and sat blocked waiting for a human to approve it. Remediation should not need permission. A refused definition is now forgotten, and the ordering of two checks turned out to have a consequence three steps away.

What it does not do

Stated here for the same reason it is stated in the README: a security tool that oversells itself is worse than none.

  • Trust on first use assumes the first sighting is clean. A server compromised before it is ever pinned becomes the trusted baseline.
  • Pattern matching loses alone. The scanner catches the unsophisticated majority and nothing more.
  • The audit trail is a local file. It is append-only from the gateway’s side, but anything with write access to the disk can edit it. A record you cannot rewrite has to live somewhere you do not control.
  • Credentials are static hashes in configuration. That suits a self-hosted gateway; a real deployment wants token expiry and an authorization server, which is why token validation is an interface rather than a function.

Why I built this one

Most of my work is under NDA, which makes for case studies without code. This is the exception, and it is deliberately close to work I have already done: a reactive Spring gateway enforcing a single trust boundary, with an explicit allowlist, an audit trail, and a bypass list that is configuration rather than scattered conditionals.

That is the same architecture I built for subscriber verification. The threat model is new; the engineering is not. Agent security in 2026 is mostly a rediscovery of problems that API gateways solved a decade ago — untrusted input, unclear trust boundaries, controls duplicated until the weakest copy defines the posture. What changed is that the untrusted input is now prose the model obeys.

Decisions & trade-offs

Every choice below had a credible alternative. These are the ones I turned down, and why.

  1. 01

    choseFilter tools at advertisement time, not only at call time

    notChecking policy when a tool is invoked

    By the time a poisoned tool is called, the model has already read its description — the tool list enters the context window as trusted instruction. It may now be acting on those instructions through some entirely different tool, and blocking the one call achieves nothing. Denied tools are removed before the model ever sees them.

  2. 02

    choseTrust on first use, with drift that never auto-heals

    notRe-pinning a definition when it changes

    Auto-healing defeats the control completely: an attacker mutates once to be refused, then mutates again to be re-trusted. TOFU has a real weakness — it assumes the first sighting is clean — and that is stated in the README rather than hidden. It detects change, not goodness.

  3. 03

    choseScore suspicious content rather than block on pattern matches alone

    notTreating the injection scanner as an authoritative filter

    Pattern matching against natural language loses to anyone who knows the rules. The scanner earns its place as defence in depth behind the allowlist and the pins, not as the thing standing between an agent and a compromised server. Presenting a heuristic as an oracle is how security tools get trusted past their competence.

  4. 04

    choseScreen tool output on the return path

    notValidating only what the server advertises

    A tool result reaches the model exactly as directly as a description does. A server that cannot get poisoned metadata past the fingerprint check will simply put the instructions in a response body instead.

  5. 05

    choseOperator API on a separate path, with its own credential, enforced by a filter

    notOne API with role checks; or separation alone; or per-handler guards

    Approving a call and making a call must not share a door, or a sufficiently capable agent approves itself. But separation alone is not access control — on a developer machine the agent shares a host with the gateway and can simply open a socket — so the operator API has its own token, and enabled-but-unconfigured means closed rather than open. It is a filter rather than a check inside each handler because with per-handler guards the next endpoint someone adds is unprotected until they remember to guard it, and 'remember to' is not an access control model.

  6. 06

    choseConfine x-mcp-header to the namespace the specification reserves for it

    notA block list of sensitive headers

    This is the one field in a tool definition that instructs the transport rather than the model, so a hostile server can reach past the message body and write an HTTP header. A definition naming Authorization takes over the credential the gateway authenticates with. A block list is a race between the list and the next header someone finds a use for; requiring the reserved prefix makes every interesting header unreachable by construction.

  7. 07

    chosePersist pending approvals, but never granted ones

    notMaking all state durable, for consistency

    A pending request is a decision a human still owes, and losing the queue mid-review during a deploy is a real cost. A grant is the opposite: permission for one call, in one moment, in a context a person had in their head at the time. Writing it to disk turns a momentary yes into a standing permission that outlives the situation justifying it — the exact failure the single-use rule exists to prevent. A restart revoking every grant is the behaviour, not a gap in it.

Stack

Java 21Spring BootSpring WebFluxProject ReactorModel Context ProtocolSHA-256JSON-RPC 2.0
Next case study

Digital Wallet & Payment Platform

An MFS and payment-service platform decomposed into ~32 services