My agents have a tool problem, and it is the good kind of problem, the kind you earn. Most new capabilities I add ship as MCP servers, and the more comfortable I get with the protocol, the more often I reach for it. MCP is the protocol that lets an AI agent call external tools: one server exposes search over my research library, another sends messages between agents, another fetches web pages, and so on. Each one is useful. But each one is also its own entrance: every server an agent connects to is a separate door with its own lock, or worse, no lock at all.
What I wanted was a shed: one structure to keep all the tools in, with a single front door. Concretely, that means one MCP server my agents actually talk to, which then talks to every other server behind it. Every tool call passes through that door, gets checked against who is asking, and gets written down. This post is the story of building that door, and of the bug I found in the middle of it: a gateway that carefully verified who was calling, and then forgot the answer at the exact moment it mattered.
The problem I thought I had
If you read about MCP gateways, the pitch is usually about tokens. Every tool you connect to an agent costs context: the tool’s name, its description, its parameter schema, all of it loaded into the model’s window before the conversation even starts. Connect enough servers and you have spent a meaningful slice of your context on tool catalogues the agent will mostly never touch.
So before building anything, I measured what my own catalogue was actually costing. The answer: almost nothing, because Claude Code, the harness my agents run in, had already fixed this, and the fix lives in the harness, not in the tool servers. The servers still describe every tool in full when asked; what changed is what the harness loads into the model’s context. Tool definitions now load on demand: the model sees a list of names, and a tool’s full schema only enters the context when the agent actually reaches for it. When I measured it earlier this year, the full schemas across all my servers came to about 16,000 tokens, and the names-only list a session actually loads came to about 500. That is the 97 percent saving in practice: a cost worth engineering around became a cost small enough to ignore, and I did not have to build anything to get it. The token problem, the one the gateway pitches lead with, was already solved.
Before committing to a build, I ran a proper triage: around twenty gateway candidates, sorted into what each one actually solves. Tokens: solved, free. Tool discovery at large scale: a real gap, but only past several hundred tools, which I am nowhere near, so instrument it and move on. That left the third problem, and the third problem was real.
The problem I actually had
None of my tool servers knew who was calling. Any agent that could reach a server could use everything the server exposed. The research agent could send messages. Anything that could read my library could also fetch arbitrary web pages.
Why that combination matters has a name. Simon Willison calls it the lethal trifecta: an agent with access to private data, exposure to untrusted content, and a channel to communicate out. (Willison coined the term, and when he is not naming failure modes he benchmarks new models by making them draw a pelican riding a bicycle. Please check out his blog.) Any two of those are manageable. All three in one session means a malicious web page can instruct the agent to read something private and send it somewhere. The defense is to make sure no single identity ever holds all three at once.
And the defense has to be structural, not behavioural. You can write “do not use the messaging tool” into an agent’s instructions, and it will comply until the day some clever text in a fetched page convinces it otherwise. That maneuver has names, jailbreaking and prompt injection, and enough of a track record that OWASP ranks prompt injection as the number one security risk for LLM applications. Instructions are behaviour, and behaviour bends. The door I wanted filters the tool list itself: when a research identity connects, the messaging tools are not refused, they are absent. The model cannot be talked into calling a tool it has never seen.
The verdict from the triage was to assemble, not build: an open-source gateway called agentgateway as the door, with per-identity rules deciding which tools each caller sees, plus a tamper-evident audit log and a small secrets vault I already had. I stood it up in front of real servers, defined a research identity and a comms identity, and proved the containment: same session, same servers, and the research identity is structurally unable to reach the tools that communicate out.
Then I looked at the audit log.
The bug
Every entry said the same thing. Not the agent that made the call. The gateway.
Here is what was happening. The gateway checks identity properly at the front door: each agent presents a signed identity token, the gateway verifies it, and the per-identity tool filtering all works. But a gateway is a middleman. After it accepts your call, it turns around and makes the call to the actual tool server itself.
And those tool servers come in two kinds. Some are web services: the gateway calls them over HTTP like any other API, and HTTP requests have headers, which gives the gateway an obvious place to attach the caller’s identity on the way through. The rest are not services at all. They are small local programs the gateway launches itself and keeps running in the background, and the two sides talk the way command-line programs have talked since forever: the gateway writes a request into the program’s input, the program writes a response back out. No network, no headers, just two processes sharing a pipe. MCP calls this transport stdio, for standard input and output, and it is how most local tool servers run. All three of my servers are the second kind. And on that path, the verified identity made it almost all the way through the plumbing and was dropped at the final step, the line of code that actually writes the message into the pipe.
So the door knew who you were. The room behind the door did not. Every message posted through the gateway was attributed to the gateway’s own ambient identity. My messaging server keeps an author on every message, and under the gateway, every author was the same agent, no matter who had actually called.
Security people have an old name for this shape: the confused deputy. A deputy with a badge does things on your behalf, and the systems downstream see only the badge, never you. It is one of the oldest failure patterns in computer security, and I had reproduced it in my own tool shed. What makes it worth writing about is that nothing was misconfigured. Identity verification: working. Tool filtering: working. Audit log: working. Every action in it was real, and every attribution was wrong.
The fix
The fix rides the identity inside the message itself. Tool calls in MCP are JSON messages, and the protocol reserves a metadata field on every call. My patch takes the identity the gateway already verified at the door and injects it into that field on the way out, so the identity travels with the call all the way to the tool server, across the exact boundary where it used to vanish.
The tool server then decides what to trust, in a strict order: an explicitly provided author always wins, the gateway’s injected identity comes second, and the server’s own ambient default is last. The trust argument is about topology rather than faith: the gateway is the only thing holding the pipe to that child process, so a downstream server can treat an identity arriving on that pipe as one the gateway put there. That was the reasoning, and I believed it.
The real check was live: a token for one specific agent, a message posted through the full stack with no author supplied at all, and then a read of the message store to see what actually landed. The store attributed it to the right agent, on a path where the old behaviour would have attributed it to the gateway. I have re-run that drive a few times since, and it lands the same way every time.
Here is that check in full, three layers of it from one of those runs.
First, the gateway’s own log at the front door. The identity token it verified belongs to roxas, one of my agents, and the call is send_message on the messenger server (trimmed for width; the gateway logs in UTC):
2026-07-10T01:02:43.553784Z info request ... jwt.sub=roxas
mcp.method.name=tools/call mcp.target=messenger
gen_ai.tool.name=send_message ... http.status=200 duration=5ms
Second, the messenger server itself, booting as a child process a moment earlier (this one logs local time, same moment). Look at who it thinks it is. The ambient identity is MINERVA, because that is the environment the gateway spawned it into, and under the old behaviour this is the name every message would have carried:
2026-07-09 21:02:43,507 [INFO] forge-messenger: Identity: agent=MINERVA, project=cortex
And third, the message store, read straight from the database afterwards, no gateway in the loop:
$ sqlite3 messenger.db "SELECT id, sender, summary FROM messages
WHERE summary LIKE 'OBO drive 2026-07-09T21:02:43%';"
1927|ROXAS|OBO drive 2026-07-09T21:02:43 t=1783645363
A server that believes it is MINERVA wrote a row that says ROXAS, because the identity rode in on the message and won over the ambient default. That row is the fix, observable from outside the whole stack.
The hole in the fix
Working tests tell you the fix does what you meant. They do not tell you it holds up against someone trying to abuse it, and that is a different question. So before I took the patch anywhere, I handed it to one of my own agents, running under a different provider’s model, and gave it one job: not to review the patch, not to admire it, but to break it.
It found the hole in about a minute. My patch took the verified identity and wrote it into the message. What it never did was clear out any identity that was already there. On the normal path that does not matter, because the gateway overwrites the field with the real caller. But the gateway only overwrites when there is a real caller to write, and identity checking is something you can turn off, or make optional, or simply not configure. Run the gateway that way, send a call with no token, and my injection step never fires. On that path, whatever the client wrote into the identity field rides straight through, untouched, onto the one pipe every downstream server had just been told to trust.
So an anonymous caller, one who had proved nothing, could type “I am the admin” into the exact field I had spent the whole patch teaching every server to believe. I had trusted the wrong thing. My argument had been that the gateway is the only program allowed to write to that pipe, so anything arriving on it must have come from the gateway. That part was true. What I missed is simpler: the gateway builds its outgoing message from what the caller sent, and carries along whatever extra fields are riding inside it. So if the caller writes a fake identity into the request, the gateway passes it straight through. Being the only writer does not help you if you are faithfully writing down someone else’s lie.
The correction is small. Before the gateway forwards a call, it now strips that identity field out, every time, no exceptions. Then, only if it has a verified caller, it writes the real one back. A client can still put anything it likes in the field; the gateway erases it before a single downstream server ever sees it. I wrote one more failing test, the mirror of the first: a call with no identity and a forged field, checking that the forgery does not survive the trip. Red, then green. Then I re-ran the full live drive to confirm the fix had not broken the working path. It had not.
The takeaway I am keeping: the person who writes a fix is the wrong person to decide it is safe. Point something adversarial at it before you trust it.
The part that surprised me
After the patch, I went looking for how other people had handled this. Middlemen losing track of who called is not a new problem, so I assumed there was a standard answer somewhere that I had simply missed.
Nobody has. The MCP specification covers how a client authenticates to a server, and stops there: there is no standard for carrying the original caller’s identity through a middleman to the tool on the other side. IBM runs an open-source MCP gateway of their own, and the identical ask has been sitting open in their tracker since March, with a proposed fix based on forwarding an HTTP header. Which works for the web-service kind of server, and fails on the stdio kind, the kind mine broke on: a child process whose entire conversation is JSON written to its standard input and read back from its standard output. There is no header to forward, because there is no HTTP anywhere on that path. The identity has to ride in the message, because for those servers the message is the only thing that arrives.
The larger lesson I am taking is about where the real problems live. The token problem was the one with products and pitches around it, and it turned out to be already solved before I started. The identity problem had no products at all, an open issue at IBM scale, and a fix that fit in one carefully placed injection point. And now when I read the audit log, every entry names the agent that actually made the call. Which is all I wanted from the door in the first place.