The MCP Tool List Grew Mid-Session and Your Agent Called a Tool It Had Never Been Told About
A security incident review opens with a question the team cannot answer: how did the agent learn the name of the tool it just called? The audit trail shows a tools/call for a tool whose name does not appear in any tools/list response the harness logged. The MCP server cheerfully accepted the call and executed it. The model, asked in a postmortem to explain where the tool name came from, offers no answer because there is none — it guessed, and the guess landed on a real action.
This is the failure mode at the seam between two assumptions that look compatible on paper. The client treats the tool list as a contract that names the surface area of authority it has been granted. The server treats the tool list as a snapshot of what is currently available, free to grow when the world grows. Between those two views, the LLM is a bridge that does not know the difference.
The List-Once Habit That MCP Encouraged
Most agent harnesses call tools/list exactly once per session. The reasons are pragmatic. Tool descriptions are large; serializing them into every prompt is expensive in tokens. The list rarely changes within a single conversation, so caching it is the obvious optimization. Several popular client SDKs ship with cache_tools_list=True as the default, and most production agents leave it there.
The MCP spec acknowledges that lists can change. Servers that declare the listChanged capability are expected to emit notifications/tools/list_changed when the available set shifts, and clients that receive the notification are supposed to refetch. The protocol diagram looks tidy: discover, invoke, notify on change, rediscover.
What the diagram does not show is what happens when the server adds a tool and the client does not refetch. Maybe the client is between turns and the notification arrived during a model call. Maybe the harness is a stateless wrapper that ignores notifications because it treats tools/list as cacheable on a TTL. Maybe the server is one of the many implementations that has the listChanged capability flag set to true but never actually fires the notification because the underlying integration was bolted on later and nobody wired the event. The client has, in all three cases, the same outcome: it is operating on a stale view of the server's authority.
Why the LLM's Confabulation Hits Real Tools
The naive picture of an LLM hallucinating a tool name imagines the model inventing a string that does not exist. The model is then corrected by a protocol error, the agent retries with a tool that does exist, and the audit trail is clean.
The dangerous picture is the one where the model invents a string that does exist. Tool names converge on a small surface. A workspace integration that connects to a project tracker is overwhelmingly likely to expose a tool called something like create_issue, update_issue, list_issues. If the model has been trained on enough public MCP servers — and it has — it will guess one of those names when the context implies an issue tracker is connected. If the server just added that integration five seconds ago and the client has not seen the new tool list, the model's guess lands.
The server has no reason to refuse. From its point of view, the tool exists, the caller is authenticated, the call shape is valid. There is no field in the tools/call request that asserts "I was told this tool existed." The server cannot verify that claim if there were one, because the protocol carries no signed receipt for past tools/list responses.
What looks in the trace like the agent calling a tool is actually the agent calling a name. The name happened to resolve to a tool. The audit trail loses the distinction.
The Notification Channel That Does Not Always Work
The spec's answer to mid-session mutation is notifications/tools/list_changed. In a well-behaved system this should close the gap: the server tells the client when the surface changes; the client refetches; the agent's next turn sees the new list and the LLM's selection is grounded in something real.
In practice, the notification channel is the weakest part of the design. A few reasons:
- Transport ambiguity. The 2025-06-18 spec assumes a persistent connection (stdio, SSE) where notifications can be pushed. The 2026-07-28 release candidate moves toward a stateless HTTP core that scales on ordinary infrastructure. In stateless mode the server has nowhere to push notifications, so the client must poll or rely on cache headers. Many real deployments mix transports — a stateless gateway in front of a stateful server — and the notification gets dropped at the seam.
- Capability advertising as theater. Servers declare
listChanged: truebecause the SDK template has that field set. Nothing tests whether the notification actually fires when the list mutates. The capability bit becomes documentation of an intention, not a fact about behavior. - Client-side dispatch races. The harness receives the notification mid-turn, while a tool call is already in flight or while the model is generating. The naive implementation queues the refetch for "after the current turn." The current turn finishes by calling a tool, and the tool name was selected against the pre-notification list, but resolved against the post-notification surface.
The 2026 release candidate's introduction of ttlMs and cacheScope fields on list responses helps with the polling case but does nothing for the dispatch race. A list with a one-second TTL still has a one-second window where the client's understanding lags the server's truth.
Where the Authority Question Lives
The architectural mistake worth naming is treating the tool list as an inventory rather than a grant. An inventory is "here is what exists." A grant is "here is what I have authorized you to call." Those two ideas are not the same, and the protocol conflates them.
When the LLM picks a tool, the decision should be bounded by the grant, not the inventory. The grant is what the user or operator agreed to when they connected the server, plus whatever they were told about in the discovery step. The inventory is what the server happens to be exposing right now. A tool that joined the inventory after the grant was issued is not part of the grant. A model that calls it is, from a security review's perspective, taking an unauthorized action — even if the server accepts the call without complaint.
The protocol has no built-in way to encode this distinction. The tools/call request carries a name, not a reference to a specific tools/list response. The server does not know which version of the list the client was looking at. The client does not know which version of the list the server is now serving.
Closing the gap requires explicit work at both ends.
Patterns That Close the Gap
A handful of patterns address different parts of the failure, and most production deployments need more than one.
- List-version pinning in tool calls. Extend
tools/callto carry the version (or hash) of thetools/listresponse the caller is operating against. The server rejects calls that reference a stale or unknown version. This is the cleanest fix — it makes the grant explicit in every invocation — but it requires both sides to implement the extension, and the spec does not yet mandate it. - Refetch on every turn for high-mutation servers. Tag servers in the harness configuration as "high-mutation" and refetch their tool list at the start of every agent turn. The latency cost is real (an extra round trip per server per turn), but for servers like project trackers or codebase tools where the underlying surface genuinely shifts, the cost is the price of a faithful tool list.
- Server-side grant enforcement. Have the server track, per session, which tools have been disclosed to the caller. A call to a tool that was added after the most recent
tools/listfor that session gets a soft-fail response that prompts the client to re-list before retrying. This is server-side enforcement of the grant model; it does not require client cooperation. - Reject-on-novel in the harness. Before the harness forwards a
tools/callto the server, check the requested tool name against the cached list. If it is not present, refuse the call before it leaves the harness — even if the LLM is convinced the tool exists. The model's belief that a tool exists is not evidence that it does, and the harness has the cached list as authoritative. - Notification-driven cache invalidation with synchronous flush. When a
notifications/tools/list_changedarrives, do not queue the refetch — block the next outbound call until the new list is in hand. Trading a small latency hit for surface coherence is the right side of the tradeoff. - Per-tool auditability with disclosure receipts. Log, for every tool call, the timestamp of the
tools/listresponse that disclosed the tool to the caller. If no such response exists, the call is a security event by definition. The audit trail then answers the security team's question — "how did the agent learn about this tool" — even when the answer is "it didn't."
The patterns trade latency against coherence. Refetching every turn is slower but truer. Pinning list versions is more cooperative but requires bilateral implementation. Reject-on-novel is unilateral but punishes valid use cases where the model usefully recovers from a partial list.
What This Says About Tool Surfaces as Contracts
The mental model worth installing is that the tool surface a server exposes is not a static catalog but a continuously revised contract. Every change to the surface is a change to what the agent is authorized to do. Treating the surface as cacheable indefinitely is treating an active contract as a museum exhibit.
This is not unique to MCP. Any system where the set of available actions can change mid-session and the calling layer caches the set faces the same gap — REST API gateways, GraphQL schemas with dynamic field exposure, RPC services with feature flags on individual methods. MCP makes the gap worse only because the caller is an LLM whose hallucinations are unusually good at producing names that happen to be real.
The teams that ship MCP integrations safely will be the ones that stop thinking of tools/list as a discovery step and start thinking of it as an authorization step that needs to be repeated whenever the underlying authorization could have changed. The harness that refetches the list before a sensitive call has not added latency — it has added a contract check. The audit log that captures which list version authorized each call is not extra paperwork — it is the only way to distinguish "the agent acted within its grant" from "the agent guessed a real name."
The tool list is not what the server can do. It is what the agent is allowed to ask the server to do. Treat it that way, and the gap closes. Treat it as inventory, and you ship an agent whose hallucinations can buy real things.
- https://modelcontextprotocol.io/specification/2025-06-18/server/tools
- https://www.speakeasy.com/mcp/tool-design/dynamic-tool-discovery
- https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/
- https://chatforest.com/guides/mcp-caching-strategies/
- https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks
- https://owasp.org/www-project-mcp-top-10/
- https://www.practical-devsecops.com/mcp-security-vulnerabilities/
- https://vulnerablemcp.info/security.html
