What the wire format doesn’t promise

The Model Context Protocol standardizes a great deal: how a client and server agree on a protocol version, how a tool is described, how a call is invoked, how an error is shaped. What it standardizes is a wire format and a set of message types. What it does not standardize — and says so about itself — is what a deployment must do to keep a long-running, retried, multi-tenant session correct once that wire format is in production. The specification’s own security principles page states plainly that “MCP itself cannot enforce these security principles at the protocol level” [3], and the pattern repeats well beyond security: large parts of the operational contract are written as “SHOULD,” not “MUST,” which is a polite way of saying the protocol leaves the hard part to the implementer.

This piece catalogues ten failure modes that live in exactly that gap, drawn from the specification’s own text, from vulnerability disclosures, and from issue trackers where real deployments hit the gap first. It is deliberately narrow. It is not about a model choosing the wrong tool, an agent’s architecture losing context across a handoff, or an evaluation harness mismeasuring success — those are different failure surfaces, covered elsewhere. This is about what happens to a correctly chosen tool call once it crosses a network boundary between a client and a server that do not fully trust, fully agree with, or fully see each other. The organization OWASP has built specifically to track this class of problem gives a sense of scale: its MCP Top 10 project catalogues ten categories of risk in production MCP deployments, from token mismanagement through to what it names outright as “Lack of Audit and Telemetry” [16] — an admission, from a security project, that most of this is currently invisible until it is someone’s incident report.

1. Schema drift breaks a deployed agent without a single error

A tool’s inputSchema and outputSchema are declared once, at the point a client calls tools/list, and are assumed stable for the life of that list. Nothing in the specification requires a server to version its schemas, and a server operator can redeploy a tool with a renamed parameter with no more ceremony than an ordinary code push. The specification’s own tool-result guidance underlines how thin the safety net is: where an output schema exists, servers “MUST provide structured results that conform to this schema,” but clients merely “SHOULD validate structured results against this schema” [4] — a check on the calling side that is recommended, not required, and therefore frequently absent.

ADVERTISEMENT

A documented account from a developer running MCP tools in production shows what that gap looks like from the inside. A search tool’s parameter was renamed from query to search_query; the server did not reject the old name, it “silently ignored” the unrecognized field and substituted an empty-string default, returning a well-formed, error-free response with no results — and the model, seeing a clean response, “confidently explained the empty results as expected,” reporting back that the query was “too specific” [5]. No exception fired anywhere in the stack. The author’s proposed fixes — schema snapshots with diff detection between connections, input validation against the current schema before a call is issued, scheduled canary queries against known-good inputs, and semantic versioning for tool schemas [5] — are all client-side and server-side discipline invented to cover a gap the protocol leaves open by design.

Close view of a schema-diff comparator bench: two outwardly identical interface cards held in a mechanical gauge, a probe arm caught mid-swing as its tip meets a contact pad present on one card and not the other
Figure 1. A renamed field is a schema that changed without telling anyone who depended on the old one; the comparator catches the gap before a caller does, which is exactly the check most tool servers skip.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

2. Timeout budgets nobody actually agreed on

The lifecycle specification is honest about the tension: implementations “SHOULD” set timeouts on every request, “MAY” reset the timeout clock on progress notifications, but “SHOULD” always enforce a hard maximum regardless of that progress, specifically “to limit the impact of a misbehaving client or server” [1]. That is a reasonable rule read in isolation. It becomes a production incident when the client’s hard maximum and the server’s expected task duration were never negotiated against each other, because the specification gives no mechanism for negotiating them.

Two independent SDK issue trackers show the same mismatch from opposite directions. Against the official Python SDK, a reporter found that a tool doing nothing more than await asyncio.sleep(10) was unconditionally cancelled at ten seconds, the server “generates unhandled exceptions upon timeout” and the client connection was “terminated unexpectedly” — a hard, undocumented ceiling baked into the transport layer itself [8]. Against the official TypeScript SDK, a separate reporter described the mirror problem: legitimate multi-source lookups “may legitimately take more than a minute,” but “the MCP client times out before the response is returned” at a fixed 60-second ceiling with no documented way to raise it for that one call [7]. Neither ceiling was wrong on its own terms — a server author picked ten seconds to fail fast, a client author picked sixty to avoid hanging forever — but nothing in the protocol makes those two numbers visible to each other before a call is made.

3. A retry that becomes a second charge, ticket, or message

A timeout tells a caller only that it did not hear back in time. It does not tell the caller whether the request never arrived, arrived and executed, or executed and lost its response on the way out. The Model Context Protocol carries no idempotency-key mechanism and no exactly-once delivery guarantee; behaviour hints on a tool are explicitly advisory rather than enforced. Combine an ambiguous timeout with an agent loop biased toward trying again, and the failure is not theoretical.

A fully reproducible, still-open report against Atlassian’s own remote MCP server shows the mechanism cleanly: every call to a createJiraIssue tool created two identical Jira tickets, “regardless of whether the call is part of a batch or a standalone single-ticket request,” with the two issues logged four to twenty milliseconds apart — the reporter’s evidence table shows one pair, ticket PS-4544 and PS-4545, created 4 milliseconds apart, each with its own distinct internal Jira ID, ruling out a display artefact [6]. The reporter’s own diagnosis, drawn from ruling out a doubled client call and a batching bug, points at “a retry-on- timeout, connection handling issue, or request duplication” somewhere in the path between the MCP server and the underlying REST API [6] — precisely the ambiguous-loss scenario the protocol leaves unaddressed.

ADVERTISEMENT

The shape of the exposure is simple enough to write down. Let nn be the number of side-effecting tool calls in a session, pp the probability that any one call’s response is lost after the server has already executed it, and ii the fraction of those calls actually covered by a working, executor-enforced idempotency key. Then the expected count of uncontrolled duplicate side effects across the session is approximately

E[duplicates]    np(1i). \mathbb{E}[\text{duplicates}] \;\approx\; n \cdot p \cdot (1 - i).

The only term a protocol upgrade could move is ii; nn and pp are properties of the workload and the network. Today, for most deployments, ii is close to zero, because nothing in the base protocol gives a server a caller-supplied key to deduplicate against.

A bank of round timer dials feeding a ticket printer, one dial's hand crossing into its red cancel zone as the printer emits a second paper chit curling out a beat behind an identical first one
Figure 2. The timeout fires before the server's own answer gets back, the caller retries, and the retry is not a second attempt at the same outcome — it is a second outcome, timestamped milliseconds after the first.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

4. A capability list that stops matching what the server offers

A server that adds, removes or renames a tool after initialization is expected to send notifications/tools/list_changed, and a client that receives it is expected to re-issue tools/list — but both halves of that exchange are written as “SHOULD,” not “MUST[4], and the gap between the recommendation and the implementation shows up across unrelated clients rather than in just one.

Against Claude Desktop, a reverse-engineered defect report — verified across, in the reporter’s words, “336 version bumps” of the application — found that the client “receives the notification, parses it, and discards it,” because the internal handler that would refresh the tool list is gated on a configuration callback the application never actually wires up; the practical effect is that “any MCP server that adds, removes, or renames a tool after initialize is invisible to Claude Desktop until the user fully restarts the app” [9]. Against OpenAI’s Codex, a separate reporter found the identical symptom by a different mechanism: a server exposing one lightweight bootstrap tool that later unlocks dozens more sends the change notification correctly, but Codex’s deferred tool-search cache is never invalidated, so “none of the newly available tools can be discovered or called,” and the same defect “affects MCP servers with plugin systems,” where enabling or disabling a plugin silently fails to update what the agent can see [10]. A third, independent analysis frames why this keeps recurring rather than being a one-off bug: because clients are not required to declare which optional protocol features they actually implement, “the MCP clients have no incentive to adopt all the new MCP protocol features,” and server authors are pushed toward “a generic tool… less precise, [that] lacks schema validation” rather than trusting dynamic discovery at all [11].

5. Two protocol revisions in the same fleet

The specification’s version-negotiation rule is unambiguous on paper: a client sends the protocol version it supports, and “if the client does not support the version in the server’s response, it SHOULD disconnect” [1]. Over HTTP, a client that omits the negotiated-version header entirely causes the server to fall back to an assumption — the specification directs it to “assume protocol version 2025-03-26” for backward compatibility [2] — which means a fleet’s actual behaviour under a missing header depends on which historical default each server implementation chose to honour.

What that looks like from inside a maintainer’s own backlog is visible in a still-open issue against a popular Go server library, filed to track adoption of the newer 2025-11-25 revision alongside the existing 2025-06-18, 2025-03-26 and 2024-11-05 versions the library already supports. The issue walks through, line by line, what has to change: the negotiation function that already falls back to the library’s own latest version when a client’s requested version is unrecognized, and — the harder question — “should this default be updated to 2025-11-25, or should it remain at an older version for backward compatibility?” with the maintainer’s own tentative answer being to keep the older default and “document this behaviour” rather than force every existing client through an upgrade [12]. That is a maintainer solving, by hand, exactly the compatibility matrix the protocol’s own “SHOULD disconnect” rule assumes will rarely matter in practice.

ADVERTISEMENT
A capability-registry card-index drawer with a freshly updated card held half-inserted and not yet filed, and behind it an interoperability test panel racking an older and a newer keyed connector generation side by side
Figure 3. A server that changed its tool list already sent the notice; the drawer still shows yesterday's roster because updating it was optional, and the two connector generations behind it are the same story told in hardware.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

6. A token that expires mid-task, not between tasks

Authorization in MCP is deliberately stateless at the transport level: every request carries its own bearer token, invalid or expired tokens “MUST receive a HTTP 401 response,” and authorization servers “SHOULD issue short-lived access tokens to reduce the impact of leaked tokens” [3]. Short-lived tokens are good security practice. They are also a timer running underneath every long tool call, and nothing in the base exchange guarantees that timer will not expire in the middle of one.

A report filed against OpenAI’s Codex App documents the failure directly: a remote MCP server using OAuth worked immediately after login, then broke when “the 15-minute access token expires,” because the authorization server required the RFC 8707 resource parameter — mandatory under the protocol’s own newer authorization revision — on every token refresh, not only on the initial grant, and the client’s refresh request omitted it; the resulting failure was not a graceful re-authentication prompt but an outright block: “required MCP server can prevent creation of a new task” [13]. The reporter had already configured the correct resource value for login; it simply was not carried through to the silent background refresh a session depends on to survive past the token’s own short lifetime.

A token cabinet of hanging access badges, one badge's small mechanical expiry flag caught mid-flip from valid to expired while the task it authorizes is still visibly in progress on a console behind it
Figure 4. The token was good when the task started and is not good now; nothing about the task changed, only the clock, and the session has no way to notice until the next call fails.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

7. A result that is well-formed and wrong

A tool call can fail two structurally different ways, and the specification is explicit that only one of them is visible as an error: protocol errors are “issues with the request structure itself,” while a tool that runs and produces bad output returns a normal, successful-looking JSON-RPC response, distinguished only by an isError flag the tool author chooses to set — or does not [4]. A response that is silently incomplete rather than explicitly failed passes every check a client bothers to run, because nothing marks it as anything other than success.

A defect against GitHub’s own Copilot CLI shows this happening by pure accident rather than malice. The CLI already had a safety mechanism: any tool output larger than 30KB gets written to a temporary file, with a small pointer message returned to the model instead of the raw content. The bug was an ordering error — MCP response text was truncated to 10KB by an internal formatting step before that 30KB safety check ever ran, so the 30KB mechanism never triggered on a response that was, by then, already cut down to 10KB. The reporter’s own summary is unambiguous: “any MCP tool response larger than ~10KB is silently corrupted… with no indication that data was lost” [20]. The model received a truncated file listing, a truncated search result, a truncated log — and, having no signal that anything was missing, reasoned over it as though it were complete.

8. A scope broad enough that one injected instruction becomes a breach

The over-broad-permissions failure does not require a compromised tool. Invariant Labs’s disclosure against the official GitHub MCP server demonstrated an attack that worked against fully trusted, unmodified tooling: an attacker plants a prompt injection inside an ordinary public GitHub issue, and when a user’s agent — authorized with a token broad enough to read that user’s private repositories — processes the issue, it “can be coerced into pulling private repository data into context, and leaking it in an autonomously-created PR” [14]. The researchers’ stated mitigation is scope reduction: limit an agent’s access “to only the repositories it needs to interact with — following the principle of least privilege” [14], which is another way of saying the exploit only works because the token was broader than any single task required.

A user-filed reproduction against the GitHub MCP server issue tracker confirms the mechanism and adds a detail about how blunt the available controls were at the time: testing a broad-scope token against a public-repository-only token, the reporter found the narrower token still “did read the private repositories but not write information from them,” and noted plainly that “OAuth does not specify to what I am allowing access” [15] — the authorization surface offered no finer grain than the token’s overall scope. OWASP’s own taxonomy gives this failure a name rather than treating it as one server’s bug: “Privilege Escalation via Scope Creep” is its own numbered category, distinct from outright credential theft [16].

An output-integrity bench where a spool of paper tape trails off short of a printed target mark, and beside it an oversized unclipped access badge carrying a broad all-scopes tag sits loose in its tray
Figure 5. The tape stopped short of the mark and nothing on the bench says so; the loose badge beside it was never fitted to one task, so a single bad instruction can reach everything the badge can reach.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

9. A transport that assumes the session survives

Streamable HTTP, the transport that replaced the original SSE design specifically to fix this class of problem, still documents its own limits candidly: an open stream’s “disconnection MAY occur at any time (e.g., due to network conditions),” resumption via a Last-Event-ID header is something a server “MAY” support rather than must, and a server “MAY terminate the session at any time,” after which every further request against that session ID gets a flat 404 [2]. Every one of those is a MAY, which means a client that assumed otherwise finds out the hard way.

Two independent client projects logged the same assumption failing in production. Against Claude Code, a report describes MCP tools that “appear in the tool list at session start, then silently stop working mid-session” with “no way to reconnect without restarting the entire… session,” losing all conversation context in the process [18]. Against the open-source agent OpenCode, a parallel report on remote servers describes “no automatic reconnection” and “reactive failure handling only,” where a dropped idle connection is discovered only when the next tool call fails outright, rather than through any proactive health check [19]. The more severe version of the same trust assumption was CVE-2025- 49596, a critical vulnerability (CVSS 9.4) in Anthropic’s own MCP Inspector tool: the local proxy between the Inspector’s browser interface and the servers it drives accepted commands from “unauthenticated requests,” so a malicious webpage using DNS rebinding to reach the local proxy port could execute arbitrary commands on a developer’s machine with no further access required [17]. A transport that assumes whoever can reach it is meant to be there is the same failure as a session that assumes whoever holds the connection is still authorized to use it.

10. Nobody can say which tools actually ran

The specification’s advice to clients is, again, advisory rather than enforced: “Log tool usage for audit purposes” is one bullet point among several security considerations, not a requirement [4]. OWASP’s own top-ten list gives this the bluntest name in the whole taxonomy — “Lack of Audit and Telemetry” is its own numbered category, sitting alongside injection and credential theft as a first-class production risk rather than a nice-to-have [16].

The infrastructure to fix this is arriving from outside MCP itself, and its own documentation concedes the problem is structural, not incidental: OpenTelemetry’s semantic-conventions project now defines dedicated span types for agent systems — an execute_tool span alongside chat and invoke_agent spans — and explicitly scopes its work to cover “GenAI clients, MCP (Model Context Protocol), and provider-specific conventions” as separate, previously disconnected signal sources [21]. That a standards body felt the need to name MCP specifically as a gap in existing observability tooling is itself evidence of the tenth failure mode: without instrumentation bolted on from outside the protocol, the honest answer to “which tools did the agent actually call, with what arguments, and what came back” is frequently: check the model’s own summary of what it did, which is exactly the account failure mode seven shows cannot be trusted.

A session-transport monitoring console with a row of status lamps, one going dark mid-row, beside a telemetry log printer whose pen rests lifted off the paper leaving a blank gap in the printed trace
Figure 6. The connection dropped between two calls that both needed to be part of the same record, and the log shows a blank instead of the drop, because logging the drop was optional and nobody had wired it up.Image prompt and art direction by Brecht Corbeel; image generated to that direction.

Where the fault line actually runs

These ten do not have one root cause, and treating them as one problem invites one wrong fix. Three are things the protocol simply has no answer for at all — non-idempotent retries, schema versioning, and the absence of a caller-supplied deduplication key are not oversights so much as scope the specification never took on. Four are things the protocol does specify but only as a recommendation, which implementations then skip under ordinary competitive pressure to ship: the list-changed notification, the enforcement of output-schema conformance, audit logging, and a hard timeout ceiling independent of progress. The remaining three sit downstream of the protocol entirely, in how a specific deployment wires identity, scope and transport trust around it — token refresh logic, OAuth scope grants, and a proxy’s assumption about who can reach it. Knowing which bucket a given production incident falls into changes the fix: the first bucket needs a new protocol primitive, the second needs a client or server that actually implements what already exists, and the third needs an operator decision that no specification revision will make on anyone’s behalf.

Predictions, and what would falsify them

These are forecasts, kept separate from the sourced incidents above. Horizon: 12 August 2028. Assumption throughout: MCP or a close successor remains the dominant open tool-calling protocol, and no single vendor’s proprietary interface displaces it entirely.

One. A caller-supplied idempotency identifier, deduplicated by the server rather than merely hinted at by an annotation, will appear in a future MCP revision, because the current gap has already produced reproducible, publicly filed duplication incidents rather than theoretical risk. Disconfirmed if the specification two revisions from 2025-06-18 still carries only advisory behaviour hints with no request-identity mechanism a server is obliged to honour.

Two. tools/list_changed handling will move from a recommendation to a requirement, or major clients will converge on implementing it correctly regardless of its normative strength, because three independent, unrelated clients have now shipped the identical defect. Disconfirmed if audits of widely used MCP clients in 2028 still show a majority silently discarding the notification.

Three. Tool-call observability will standardize around the OpenTelemetry GenAI conventions specifically, rather than each agent framework inventing its own tracing schema, because the alternative is what OWASP has already flagged as a named, numbered risk category rather than a nice-to-have. Disconfirmed if the leading agent frameworks in 2028 still ship incompatible, non-interoperable tracing formats for tool calls.

Four. Mid-session credential refresh will be tested as its own failure class in MCP client conformance suites, distinct from initial-login authentication, because the failure mode is already documented against a major client and traces to a specific, nameable omission rather than an exotic edge case. Disconfirmed if 2028-era MCP client test suites still validate only the initial OAuth handshake and not token refresh mid-task.

None of these requires a change in what language models can do. Each follows from gaps that are already visible in the specification’s own text and in the incidents this piece has traced back to it.

What to take away

A tool protocol standardizes the shape of a request and the shape of a response. It does not, by itself, guarantee that a schema stays stable, that a timeout means what both sides think it means, that a retried call has the effect of one call rather than two, that a capability list is current, that two protocol revisions in the same fleet interoperate, that a token survives the task it authorized, that a truncated result announces itself as truncated, that a broad scope stays broad only where it needs to, that a dropped connection is noticed, or that anyone can later say what actually happened. Every one of those ten properties is either explicitly left to the deployment, or specified as a recommendation that a specific, documented implementation has already skipped.

The practical response is not to distrust the protocol — MCP’s version negotiation, its structured- error channel, and its OAuth-based authorization model are each, on their own terms, reasonable engineering. It is to read the specification the way this piece has: noting every MUST as a guarantee and every SHOULD as a place a production system still has to supply its own answer, and building the schema diffing, the idempotency keys, the reconnection logic, the scope audits and the tool-call telemetry as first-class parts of the deployment rather than as an afterthought bolted on once the first incident report arrives.