02 — Open source
Toolgate — MCP Security Gateway
Closing a gap the AI agent protocol names but does not solve
- 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
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:
- 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.
- 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"and1cannot collide. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
Digital Wallet & Payment Platform→
An MFS and payment-service platform decomposed into ~32 services