Part 1 of a series on MCP security. This piece is the problem. The next one is what I built.
Here is how MCP adoption actually happens inside a company.
The data team connects their AI assistant to the internal data warehouse on a Tuesday afternoon. They wire up the Brave Search MCP server they found on GitHub, a Postgres MCP for the warehouse, and a homegrown one someone wrote last sprint for the ticketing system. By Friday there are twelve tools in the catalog and the demo is genuinely impressive: the assistant queries production, pulls in a web result, and files the Jira ticket itself. Nobody filed a security review request, because from where the data team sits nothing security-relevant happened — they connected some tools.
The security team finds out three weeks later, when an engineer mentions it in passing during a Slack standup.
This is not a story about negligent engineers. The data team made a reasonable decision — the tools are useful, the protocol is designed to enable this exact kind of connection, and moving fast is the job. The problem is structural: MCP makes connecting AI to tools so easy that the governance question — who should be able to call what, and how do we know it’s them? — arrives after the connections are already live. Usually well after.
The fragmentation nobody planned for
MCP is platform-agnostic by design. You can drive it from Claude Desktop, from Cursor, from a custom agent you wrote yourself, from a Jupyter notebook, from a CI pipeline. That’s the point. But every client has a different relationship with auth, and the transports underneath them have different relationships with identity — some have none at all.
Start with stdio, the default local transport. A stdio MCP server has no network listener and no auth handshake; it talks over the stdin/stdout pipes of the single parent process that spawned it. Identity is implied by the parent — the server trusts whoever launched it, full stop. That sounds contained, and the process model genuinely is. The exposure is the configuration that drives it. The client config that says “launch this binary with these arguments” also carries, in plaintext, the API keys handed to the server’s environment block. It’s a file, not an OS keychain entry, so any code running as your user can read it — and Trail of Bits found this isn’t a corner case but the norm across the ecosystem, from official GitLab/Postgres/Google Maps servers to third-party connectors (Insecure credential storage plagues MCP, April 2025). An arbitrary-file-read bug in any unrelated app on the box becomes a direct path to every downstream credential the agent can reach.
Move to a shared HTTP transport — which every team eventually does when they want a server to live somewhere other than one laptop — and the auth story does not automatically improve. The MCP spec added OAuth 2.1, but spec support is not implementation. Knostic mapped the public internet and found 1,862 exposed MCP servers; when they sampled 119 of them with a bare tools/list request, all 119 answered — enumerating their executable functions to an anonymous caller, no auth at all (Mapping MCP Servers Across the Internet). The “it’s on the internal network” argument carries the rest, and it works right up until someone connects a laptop from a coffee shop, or an SSRF bug inside the perimeter starts speaking MCP.
So you get a landscape that, in the orgs I’ve watched do this, looks roughly like: a handful of teams, several different MCP clients, a couple dozen servers, and as many distinct auth schemes as there are servers — including the scheme of “none, but we added it to the VPN allowlist.” Each server trusts a different set of credentials. Some trust nothing. The AI clients hold all of it simultaneously.
Even where there is auth, the token is the wrong shape
Suppose a team does the work: OAuth 2.1, PKCE, real tokens. You’ve closed the “no auth” hole. You have not closed the interesting one.
OAuth was designed to delegate a user’s access to an application. An AI agent is neither a user nor a conventional app — it’s a non-deterministic process holding delegated authority across many resources at once. Two things break at that seam:
- Audience binding is mandated on paper and missing in practice. RFC 8707 resource indicators let a token be minted for a specific resource server, so a token good for server A is rejected at server B. The MCP authorization spec went further than most and made this mandatory — as of the 2025-06-18 revision, clients MUST send the
resourceparameter and servers MUST validate the audience. But spec text is not deployed reality: most servers in the wild predate that revision or simply don’t enforce it. The spec calls this an audience-validation failure, and the result is a bearer token scoped to “the things this agent does” that five servers will all accept — one credential, five doors. Worse, the spec’s named confused-deputy problem sits one step downstream: a server that receives a token and forwards it on (token passthrough) lets that over-scoped credential keep travelling. Until servers actually validate audience, the token is the agent’s to misuse, and nothing along the chain checks it’s being used at the server it was minted for. - Scope is coarse and static; tool calls are fine-grained and contextual. Even with audience binding enforced, a token grants something like
warehouse:read. It cannot express “read aggregate metrics, but not thecustomerstable, and never as a write.” The authority is decided once, at issuance, for the whole session — but the risk of any individual call depends on its arguments, which the token never sees.
So “add auth” gets you a verified caller holding a credential that is broader than any single call needs and accepted in more places than any single call should reach. That is progress over tools/list answering strangers. It is not a control over what the agent actually does.
One secret, N stores, N ways to lose it
There’s a quieter cost to the fragmentation. When every server manages its own credentials and every client holds its own copy, a single downstream secret — the warehouse password, the GitHub token — ends up replicated across N differently-secured stores: a plaintext config here, an env var in a CI runner there, a token pasted into a second team’s client six months ago.
That has two consequences security carries forever. Every one of the N copies is an independent chance to leak the secret, secured to the standard of whichever team holds it — i.e. to the weakest of them. And revocation, the one move you have when something goes wrong, now has to reach all N stores to be real; miss one and the rotated-out credential is still live somewhere. You cannot rotate what you cannot enumerate, and nobody is enumerating.
The people problem
The fragmentation isn’t only technical. The humans behind these agents have radically different risk profiles, and the access model was never designed for that.
A senior engineer who stood up the Postgres MCP server knows it has read/write to production and would never hand that connection string to an intern’s laptop. But the intern’s AI assistant, running the same shared tool config that got pushed to the team channel and added by a dozen people, calls the same server with the same authority. The config flattened twelve different risk profiles into one. And when security is finally called in, the ask is always some version of “lock this down without breaking what people are using” — the business has already moved, the tooling is live, “block it” is off the table. That’s not a complaint; it’s the real constraint. But it means a blanket allowlist can’t be the answer, because the thing you need to distinguish — the analyst pulling aggregate metrics versus the same agent, mid-session, trying to write back to the warehouse — isn’t visible at the level a list operates on.
Why “just add auth” doesn’t solve it
Authentication tells you who called. Authorization can go further and tell you whether that identity is allowed to call this tool with these arguments — RBAC, ABAC, an OPA policy over identity+tool+args. Those are real controls and they matter. But there’s a fact that none of their standard inputs carry.
The standard inputs to an authorization decision — identity, tool, arguments — say nothing about what happened to the session between the token being issued and this specific call: whether the agent has since ingested untrusted content and is now acting on injected instructions. A policy engine like OPA is happy to decide over any attribute you feed it, so this isn’t a hard limit of authorization — it’s that the taint signal isn’t in the input document, and producing it requires something watching the content that flowed through the session, not just the identity and the call. The token is identical before and after. The arguments can be identical. The only thing that changed is what the session ingested, and nothing in the conventional authn/authz path is looking at that. (This is the prompt-injection problem viewed from the auth layer — the same lethal-trifecta risk everyone now knows, seen from the one place that’s supposed to be the gate. Part 3 is entirely about producing that taint signal; here it matters only as the reason the gate has to sit where it could see session history at all.)
The real constraint: there is no one to ask
Put the pieces together — multiple clients, multiple servers, inconsistent auth, broadly-scoped tokens, replicated secrets, diverse human risk, and an expectation that security enables rather than blocks — and the actual problem is the absence of a decision point. There is no single place where the question “should this specific call happen, right now?” is asked, because the calls originate everywhere, authenticated by different mechanisms, landing on servers with no shared policy.
None of this is a new shape of problem. Access control has had a name for the answer for twenty years: a Policy Decision Point separated from the Policy Enforcement Points — XACML, OPA, service-mesh authz, the AuthZEN work all describe one brain making decisions that many hands enforce. The pattern is well understood, and the right instinct here is to reach for it. What’s new isn’t the architecture — authorizing over history isn’t novel either; Chinese-Wall models and XACML environment attributes have done it for decades. What’s new is the kind of history that now decides the call: whether the agent ingested untrusted content earlier in the session, possibly through a different server entirely, and is now acting on injected instructions. That taint isn’t an attribute the request carries; it’s a fact about what flowed through the session, and a classical PDP was never handed it.
Which is why “standardize, don’t centralize” — put the same policy agent on every server, sidecar or mesh — gets you most of the way and then stalls. A distributed mesh can share state; you could ship every server’s session-taint signal to a common store and let each sidecar decide against it. But notice what you’ve done: you’ve centralized the decision data into one logical plane, which is the actual claim. Distributing the enforcement is fine — distributing the decision data is what fails, because the one fact that forces centralization lives across all the servers at once. Whether you express that as a single gateway or as a mesh reading a shared decision plane, you are building one logical place where the question is answered. The fragmentation problem is that today there’s no such place at all.
Be precise about which problem forces this. Most of what came earlier — missing auth, over-scoped tokens, sprawled secrets, flattened human risk — is individually fixable per server: validate audience, scope the token, broker the credential, gate the role. Each has a per-server home. The one that doesn’t is session taint: whether the agent, right now, is acting on something it ingested earlier this session. That fact lives across the whole session, not on any one server, which is why it’s the load-bearing reason the decision has to converge somewhere. Without that one place, everything else is defense-in-depth over a hole — you can add auth to every server, rotate every credential, scan every server for malware, and still have no point at which you can say, for this call, “this agent has been tainted and should not be allowed to drive this action.” There is no one to ask. That’s the whole problem.
What comes next
The direction I reached for isn’t a better classifier and it isn’t a smarter allowlist. It’s to give the question one place to be asked — a single decision plane that sits in the path of tool calls, where the arguments, the caller’s identity, the session’s history, and a policy can meet, so the call is mediated rather than waved through on a credential. Whether that holds up — whether you can actually put it in front of every call without the agent routing around it, and what it costs to make one decision point that isn’t itself the weakest link — is the work of the next piece, not a claim I’m making here.
That’s where this goes: mediate, don’t classify. One decision point, every call, with everything that decides it in front of you.
Part 2 → Mediate, don’t classify: the design of a runtime MCP security gateway
I write about cyber security — purple teaming, detection, secure architecture, and the open-source tools I build. Views my own.