Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 10 min read

An MCP tool schema can change while an agent keeps the old one

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent. The original is at https://blog.worksi.ai/mcp-stale-tool-schema-after-server-update. A tool call that worked an hour ago now fail

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent. The original is at https://blog.worksi.ai/mcp-stale-tool-schema-after-server-update.

A tool call that worked an hour ago now fails validation, and the MCP server was redeployed in between. The obvious story is a stale tool schema after a server update: the agent still holds the old definition. That story can be right, but accepting it unchecked sends you to restart clients and clear caches while the cause may sit elsewhere: a different credential, a filtered list from a proxy, a client that read only the first page, or a model that had the new schema and sent the old field anyway.

Our position: before you blame a cache or retry the call, compare three things. The schema the server serves now, the catalog the client actually received, and the definition the model was actually given. A notifications/tools/list_changed message contains none of them.

The test: one read-only tool, one renamed parameter

The setup is hypothetical. Run it in a disposable environment with synthetic data. A read-only tool, count_events, counts events in a time window. Version 1 requires duration:

{
  "name": "count_events",
  "description": "Count synthetic events in a time window. Read-only.",
  "inputSchema": {
    "type": "object",
    "properties": { "duration": { "type": "string" } },
    "required": ["duration"],
    "additionalProperties": false
  }
}

Version 2 renames it to lookback and keeps everything else. With additionalProperties: false, a v1-shaped call is now invalid. The tool is read-only because you will call it several times, and none of those calls may change anything.

Deploy v2, then run three probes against the same endpoint with the same credential:

  1. The existing session. The long-lived agent conversation that last saw v1. Ask it to count events for the last hour. Record the tools/call arguments and the server's response.
  2. A fresh client. A new client process and conversation with the same configuration, so discovery runs from scratch (on a legacy revision, also a new protocol session). Same request, same records.
  3. Direct discovery. Send tools/list yourself through the same endpoint and principal, following nextCursor until a response has none.

For each probe, record:

  • the UTC timestamp and the server's deployment version or commit;
  • the protocol version: negotiated at initialize for legacy traffic (2025-11-25 and earlier), declared on each request for 2026-07-28 traffic;
  • the full endpoint path, including any proxy or gateway;
  • the principal (the identity, not the secret) and any policy that filters tools for it;
  • every page of tools/list, with its arrival time and any ttlMs and cacheScope;
  • whether a list_changed notification was sent, and whether the client logged receiving it.

Then reduce each schema to something you can compare. If you saved each page's JSON-RPC response as page-1.json, page-2.json and so on:

jq -s '[.[].result.tools[] | select(.name == "count_events")]' page-*.json > count_events.json
jq 'length' count_events.json                       # expect 1; 0 or 2 is a finding
jq -S '.[0].inputSchema' count_events.json | sha256sum

The direct list shows what this path serves to this principal now, not what the existing session holds. A fresh client that receives v2 and makes an accepted call shows that this client, endpoint and principal worked at that time. It says nothing about whether the existing session refreshed.

Seven layers, and which ones you can see

A tool definition passes through several hands between the server and the model, and each can hold a different version.

Layer What to capture Mark it unknown when
1. Server's served definition The server's log of its tools/list result, or the schema hash at the deployed version The server does not log responses
2. Proxy or gateway response What the intermediary returned, after any filtering The intermediary keeps no record of responses
3. Client-received catalog Client debug log or wire capture of every tools/list page The client logs no protocol traffic
4. Client cache or tool binding The client's internal tool registry The client offers no way to inspect it
5. Model-visible definition The tool definitions in the request the client sent to the model provider You cannot capture model requests
6. Emitted tools/call Tool name and arguments Rarely unknown: the server receives it
7. Server validation result JSON-RPC error or isError result, with its message Rarely unknown

Three rules keep the table honest. A fresh tools/list response at layer 2 or 3 proves nothing about layers 4 or 5. A tool listing in the client's interface shows what the client displays, not necessarily what the model received. And arguments at layer 6 alone do not prove a stale schema: a model can send the wrong field while holding the right schema, and an adapter between client and model can rename, drop or reshape fields.

Layer 5 is the one people skip because it is hardest to reach. In a disposable environment, if the agent lets you configure its model endpoint, point it at a logging or mock endpoint and read the request body. Where you cannot see layer 5, write "unknown" instead of inferring it from layer 6.

Read the error class before the error message

The 2025-11-25 tools specification defines two error mechanisms. Protocol errors are JSON-RPC errors for unknown tools, malformed requests and server errors. Tool execution errors come back as a result with isError: true, and the spec lists input validation errors, such as a date in the wrong format, among them. Servers draw the line in different places: the server in the Codex report below returned -32602 with "unexpected additional properties" for an unknown field. Record which class you got.

The class matters for what the model sees next. The same spec says clients SHOULD pass tool execution errors to the model and MAY pass protocol errors. An isError message that names lookback can let the model correct itself. If the next call succeeds, that is consistent with error-driven correction, but a refreshed definition or an adapter could produce the same result: inspect the model request and the tool binding between the two calls to tell them apart. A protocol error may not reach the model at all, so the same stale call can repeat.

Six patterns, six different next steps

Pattern What the evidence shows Look next at
No notification No notification reached the client: not declared, not sent, or not subscribed (2026-07-28). Existing session fails, fresh client works Server capability, subscription and notification logs; whether the client re-lists on its own
Notification received, no observed refresh Client logged the notification; no tools/list follows on the wire The client's refresh behavior and cache
Refreshed list, stale binding or model context v2 arrives at layer 3 after the notification; layer 5 shows v1 The client's tool binding and how it builds model requests
Refreshed model definition, invalid arguments Layer 5 shows v2; layer 6 sends duration or both fields The model, any adapter, and whether earlier duration calls in the conversation history steer it; this is not a cache
Different endpoint or principal Direct probe and agent disagree; the agent's configuration points through another proxy, workspace or credential Endpoint and identity; a filtered list can be correct behavior
Paginated discovery mismatch The v2 entry is missing or the name appears twice across pages Repeat discovery from the first page against an unchanged deployment, recording each page's time and cache hints, to rule out a change between fetches; then compare with the client's pages

Rows three and four look the same from the server. When layer 5 is unknown, label the case "suspected stale binding or invalid generation; not distinguished".

What the protocol revision in use specifies

Record which revision each side speaks and read that one. Four rules change the diagnosis.

A notification is optional and empty. In the legacy 2025-11-25 revision, negotiated once per session, a server that declared listChanged SHOULD send notifications/tools/list_changed: a SHOULD, not a MUST. The message has a method and nothing else: no tool names, no schema. The client has to call tools/list again, through every page, and the tools page does not say that it must, or that it must update what the model sees.

On 2026-07-28, only subscribers hear about changes. That revision has no handshake: every request declares its protocol version. Its tools page says the notification SHOULD go to clients that opened a subscriptions/listen stream with toolsListChanged: true, and that tools/list MAY vary by the authorization presented on the request.

A cached page can be legitimately stale. Under the 2026-07-28 caching rules, each tools/list page carries ttlMs and cacheScope, and a client MAY treat a page as fresh for ttlMs, keyed by method and parameters, cursor included. A relevant notification immediately makes the cached page stale, even before its TTL expires. The client should re-fetch on next access, although the spec permits serving stale responses if re-fetching fails. Private cached pages must not cross authorization contexts, including different access tokens; public cached pages may be shared across them. A client MAY also re-fetch early after a tool call fails with invalid parameters. So an old list served after a notification is a lead: check whether the client tried to re-fetch before you call it a bug.

Eras can mix on one endpoint. The versioning page lets a dual-era server serve legacy and modern clients side by side. Check which era each client uses before applying either page's rules. No revision makes a notification proof that the model's context changed.

Two public reports, read for what they show

Codex issue #19155, opened 23 April 2026 against Codex CLI 0.122.0, describes the exact rename used above. A tool count_detections required duration; the server was changed to require lookback, rebuilt and restarted, and, per the reporter, emitted notifications/tools/list_changed. The next call in the same session failed with -32602 and "unexpected additional properties ["duration"]". A fresh MCP client against the restarted server saw the new schema and called the tool correctly.

Claude MCP issue #45, opened 6 February 2026, is a different shape. With Claude Desktop, Streamable HTTP and protocol version 2025-11-25, the reporter's server log shows the client requesting tools/list after the notification and the server answering with one more tool, while Claude still reported the old count and could not call the new tool. It is labeled bug, known-issue and backlog.

Read both as single dated reports, not evidence about current releases. Each documents the server side; neither captured layer 5, and a model's account of its tool count is not a captured definition. The Codex report leaves the client's catalog unreported; the Claude report shows a mismatch between discovery and behavior, not a stale input schema. Both reporters fell back to the same control: a fresh client or a reconnect.

Roll out a schema change so stale clients fail safely

Stage an additive transition when the meaning is unchanged. If lookback means exactly what duration meant, ship an intermediate version that accepts either and requires exactly one:

{
  "type": "object",
  "properties": {
    "duration": { "type": "string", "description": "Deprecated. Use lookback." },
    "lookback": { "type": "string" }
  },
  "oneOf": [
    { "required": ["duration"] },
    { "required": ["lookback"] }
  ],
  "additionalProperties": false
}

oneOf rejects a call with both fields or neither. Enforce the same rule in server code, because a client or model provider that converts tool schemas may not pass oneOf through intact; test each client you support with v1 arguments, v2 arguments, both and neither. Log which field each call used, with the client and principal.

Use a versioned tool name when the meaning changes. If lookback is anchored differently, aliasing it to duration gives old clients a silently different answer. Ship count_events_v2 beside count_events, keep the old tool's semantics until it is retired, and let stale sessions keep working against the definition they hold.

Retire on observed migration, not on a date. Remove the old field or name when your server logs show no calls using it from the clients you support, over a window you choose. After removal, stale sessions fail with a validation error or an unknown tool: loud and side-effect free.

Count the cost of the window. Two code paths need maintenance. The old path can miss a check you added to v2, such as a maximum on lookback. Tool-level policies must cover both names: a rule written for count_events_v2 that forgets count_events leaves the old path governed by whatever applied before. Discovery and invocation can be filtered separately, so test both, as in our MCP authorization test matrix.

Do not reissue a real write on a hunch. The test tool is read-only for this reason. If a production tool that writes fails this way, a compatibility shim, an earlier retry or partial work before validation may already have had an effect. Check the target system's own record first. A tool span alone does not settle it, as we argued in a green tool span is not the outcome.

Originally published at blog.worksi.ai.

πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.