The Advisory List: Why MCP Governance Lives at the Call Path
You withdraw a tool. You have decided — for a security reason, a compliance reason, a 3 a.m. reason — that a given agent should no longer be able to call it. In a direct provider-to-agent MCP connection, what actually happens?
Nothing. Not until the agent decides to ask again.
The agent is holding a list of your tools that it fetched at some point and cached. You can emit a notifications/tools/list_changed to suggest it refresh, but that is a courtesy, not a control: the client re-lists when it feels like it — in a second, at the next cold start, or never. You changed the menu. The diner is still ordering off the copy it photographed last week.
This is not a bug in anyone’s implementation. It is the shape of the protocol. And once you see it clearly, it tells you exactly one thing about where governance in MCP can — and cannot — live.
The list is a hint. The call path is the contract.
MCP gives a provider two surfaces that matter here. tools/list advertises what exists. tools/call executes. The first is advisory and client-cached by design — the entire point of a tool list is that the client holds it locally and reasons over it without round-tripping to you. The second is the one thing you mediate synchronously: every invocation comes back through you before anything runs.
So there is an asymmetry baked into the protocol, and it is the most important fact about governing MCP: what the agent believes it can do is a snapshot it owns; what actually executes is a decision you make at call time. Those two are allowed to disagree, and in any system that outlives a cache TTL, they will.
Most people read that asymmetry as a problem — drift between the advertised surface and the real one. It is the opposite. It is the only leverage you have. The list you do not control. The call you do. Anything you want to enforce — revocation, scoping, versioning, audit — rides the surface you control, or it does not get enforced at all.
Where governance attaches
Six months ago I called MCP’s governance story a vacuum — the specific gaps named and unaddressed. Two months later, I showed a protocol shipping faster than the controls around it. Those pieces described what was missing. They left the harder question for later, and this is later: in MCP specifically, where does a governance control physically attach?
Not to the list. The list is advisory, it lives in the client’s cache, and you do not own the refresh. A control that depends on the agent seeing an updated list depends on the thing you are trying to govern cooperating with you. That is not a control. It is a request.
It attaches to the call path, or it attaches to nothing.
State it that plainly and the rest stops being aspirational and turns mechanical. Put a gateway on every tools/call and you have decoupled what the agent believes from what executes — and that one decoupling is the whole governance story, because every primitive you actually want is a decision made at that seam.
Withdrawal becomes synchronous. Revoke a tool and the next call is rejected — not eventually, once the client re-lists, but now, against whatever stale list the agent holds. The cached list stops being a liability and becomes irrelevant, because the call is mediated regardless of what the agent thinks it knows.
Per-tenant projection becomes real. The same backend can present a different executable surface to different callers — tenant A may call a tool tenant B may not — enforced at the call, where you know who is asking, not at the list, where you are publishing a brochure. You can project the list per tenant too, handing each caller a view of only the tools it may call; a smaller advertised surface is a smaller attack surface and a cleaner contract. But know what that buys: it makes the agent’s initial snapshot correct and does nothing about it going stale. Projecting the list narrows the gap between believed and executable. The call path is what closes it.
Versioning and canary become routing. Pin tenant A to v2 and tenant B to v1; move a cohort onto a new implementation and watch it before the rest follow. None of it touches the advertised list. It is a decision at the call.
Audit becomes a byproduct. If every invocation passes through one mediated surface, every invocation is logged with the identity that made it — not because you bolted on a logger, but because the topology leaves no other path. The audit trail is what the architecture emits when it runs, not a feature you remembered to add.
Four primitives, one seam. That is the argument: in MCP, governance is not a layer on top of the tool list. It is a property of mediating the call.
The honest version
Call-path authority is necessary. It is not automatically sufficient, and the way it fails is more telling than the way it works — because each failure is a place where a gateway looks like it governs and doesn’t.
Synchronous is only as synchronous as your state is shared. Inside one process, withdrawal is robust where it counts: it is an overlay on the call decision, not a property of the discovered list, so a tool stays withdrawn even if a backend blips out of discovery, and a withdrawal applied at runtime survives a config reload without opening a window. What it does not survive is a second replica that never heard about it. If enforcement state lives in process memory and you run more than one node, withdrawal propagates only as fast as each node learns — the tool you killed on one box is still callable on the others. Fleet-wide synchronous withdrawal is a real claim, and it costs real engineering: shared state, not a local registry. Short of that, say so plainly — the guarantee is per-process. A kill switch whose latency you can’t bound is not a kill switch.
Unknown has to fail closed, or you have only moved the vacuum. A gateway in front of untrusted external agents has to decide what happens when a caller presents no identity. “Fall back to the broadest default” is a fine answer for an internal aggregator talking to your own trusted client — and a hole the moment you point that same code at the open internet and inherit a fail-open default nobody chose. The two topologies want opposite defaults. The dangerous case is the one where you got yours by inheritance instead of decision. Make the mode explicit; make external exposure deny on unknown regardless.
A claim is not an authentication. This is the one that hides. It is tempting to treat “the call carries a tenant identifier” as “the caller is that tenant.” It is not. A token that was decoded but never verified — signature unchecked, expiry ignored — carries a tenant claim exactly as convincingly as a real one. The call path can only be authoritative over an identity it actually verified; a tenant value riding an unverified token has to resolve to unknown, which — see above — had better fail closed. And the layer that establishes identity is not the layer that enforces policy: advertising a resource-metadata endpoint and challenging for a real token is the handshake; checking that token’s signature and audience is the validation; deciding what the verified caller may do is the policy. Your policy is only as honest as the validation beneath it. A gateway that mistakes presence of identity for proof of it is enforcing rules against an attacker’s self-description.
None of these are footnotes. They are the difference between a gateway that governs and one that performs governance. The call path being the right place for control does not excuse you from getting the control right.
Three questions for your next architecture review
-
When you withdraw a tool, what is the worst-case window before a call to it is actually rejected — and is that window bounded, or does it wait on a client deciding to re-list?
-
If a caller presents no identity, or an unverified one, does your gateway fail open or fail closed — and did you choose that, or inherit it from a different topology?
-
Can you name the single surface every tool call in your deployment passes through? If you can’t, then whatever you believe is enforcing your policy isn’t.
The list is what the agent thinks it can do. The call path is what you let it. Govern the second; the first will always be out of date.
Disclosure: I build MCP Hangar in this space — an MIT-licensed gateway that sits on the MCP call path, where the per-tenant projection in this post is implemented: synchronous withdrawal, member-scoped policy projected onto both the list and the call, a fail-closed front-door mode. The entire project is at github.com/mcp-hangar/mcp-hangar. I’m not pitching it here — but it shapes what I notice, and you should know that.