# When must an instance reject a negotiated MCP capability it previously advertised: schema-version decoherence under mid-session tool renegotiation **Date of Record:** 2026 November 11 **Scope:** Applies to MCP servers that advertise versioned tool schemas and to clients that cache `tools/list` output across a session. Does not apply to servers that pin a single immutable schema for the process lifetime, nor to stateless one-shot request/response bridges that re-list tools on every call. ## Core Distinction Capability negotiation in MCP is a *declaration* event, not a *lease*. When a server responds to `initialize` with `capabilities.tools` and later serves `tools/list`, the client treats the returned schema set as the contract for the session. The non-obvious boundary is this: **a server may legitimately evolve a tool's `inputSchema` between `tools/list` calls, but it must never accept a `tools/call` whose arguments validate against a schema version it no longer serves.** The failure mode is not "schema changed" — it is "schema changed and the server still honored the stale contract." Clients that cache aggressively and servers that validate loosely produce a silent decoherence: the call succeeds against v1 semantics while the client believes it is operating v2, or vice versa, and neither side observes the split. The distinction that matters operationally: *renegotiation* (client re-lists and receives a new `version` field) is healthy. *Decoherence* (client calls with v1 arguments, server silently coerces them under v2 rules) is the invariant violation. The server is the only party that can detect it, because only the server knows which schema versions it has retired. ## What Was Observed A tool `db.query` was advertised at session start with `inputSchema` v3, where `timeout_ms` was an integer with default `30000` and `mode` accepted `{"read","write"}`. Mid-session the operator deployed v4, which renamed `timeout_ms` to `timeout` (integer, no default) and restricted `mode` to `{"read"}` for a read-replica cutover. The server process did not restart; it hot-reloaded its tool registry and began serving v4 from `tools/list`. The client, holding a cached v3 listing, issued `tools/call` with `{"query": "...", "timeout_ms": 5000, "mode": "write"}`. The server's dispatcher validated against v4: `timeout_ms` was an unknown property (ignored under `additionalProperties: true`), `timeout` was absent so the server applied its new internal default of `0` (no timeout), and `mode: "write"` was rejected — but the rejection surfaced as a generic `-32602 Invalid params` rather than a version mismatch. The client retried three times with the same v3 payload, then fell back to a v2-era code path. The query ran with no timeout against a read replica and blocked a replication slot for 41 minutes. The observable signal was not an error at the negotiation layer. It was a latency cliff and a replication lag alert, three layers removed from the actual defect. The schema version was never transmitted in the `tools/call` payload, so the server had no way to say "you are speaking v3, I speak v4." ## Condition of Validity The invariant holds — and the rejection is mandatory — only under the conjunction of three conditions: 1. **The server retains knowledge of retired schema versions.** If the server has no record of what it previously advertised, it cannot distinguish a stale client from a malformed one, and must fail closed on unknown properties rather than coerce. 2. **The client's cached schema is identifiable.** Either the `tools/list` response carries an explicit `version` (or `schemaHash`) that the client echoes in `tools/call` `_meta`, or the server must treat every call as potentially stale and validate strictly with `additionalProperties: false`. 3. **The tool has semantic drift, not merely additive change.** Adding an optional property is backward-compatible and needs no rejection. Renaming, removing, defaulting, or narrowing an enum is a breaking change and *must* trigger version-gated rejection. Where any of these is false, the correct behavior is not rejection but *fail-closed validation*: reject unknown properties, reject missing required properties, and never silently apply a default that the calling client did not request. The distinction between "reject the call" and "reject the schema version" is the whole game — the former is a `-32602`, the latter is a structured error the client can act on to re-list. ## Decision Boundary The boundary is drawn at the moment of dispatch, not at the moment of listing. Concretely: - **Accept** when the call's declared schema version (from `_meta.schemaVersion`) equals the currently served version, or when the call omits a version and validates cleanly under the current schema with `additionalProperties: false`. - **Reject with re-list instruction** when the call declares a version the server has retired. The error must name the retired version, the current version, and instruct the client to re-issue `tools/list`. This is recoverable and the client should retry once after re-listing. - **Reject hard** when the call declares a version the server has never served (forward-dated or forged) or when no version is declared and the payload fails strict validation. Do not attempt coercion, do not apply defaults for absent properties that the client did not send, do not ignore unknown properties. The subtle case: a client that omits `_meta.schemaVersion` entirely. Treat this as v-unknown, not as v-current. Validate strictly. If it passes, honor it and log the omission; if it fails, reject with the re-list instruction rather than a generic invalid-params error. The generic error is the trap — it teaches the client to retry the same payload, which is exactly the loop that produced the 41-minute stall. ## Operational Effect Enforcing version-gated rejection converts a silent decoherence into a loud, recoverable, single-retry handshake. The client re-lists, receives v4, re-validates its arguments, and either succeeds or surfaces a genuine schema incompatibility to the user. The cost is one extra round-trip on schema change; the benefit is that no call ever executes under semantics the caller did not intend. In the observed incident, the version-gated path would have rejected the first v3 call in under 2 ms with a `SchemaVersionRetired` error, the client would have re-listed, seen that `mode: "write"` was no longer available, and failed the operation at the application layer — where the operator could see it — instead of at the replication layer, 41 minutes later. The general principle for agents operating MCP servers: **a capability you advertised is a promise you must either keep or explicitly revoke.** Hot-reloading a tool registry without a revocation path is the MCP equivalent of mutating a shared pointer without a lock — it works until it doesn't, and when it doesn't, the failure is attributed to the wrong subsystem. ```python from __future__ import annotations from dataclasses import dataclass, field from typing import Any class SchemaVersionRetired(RuntimeError): """Raised when a tools/call declares a schema version the server no longer serves.""" def __init__(self, declared: str, current: str, tool: str) -> None: self.declared = declared self.current = current self.tool = tool super().__init__( f"tool={tool!r} declared schemaVersion={declared!r} is retired; " f"current is {current!r}. Re-issue tools/list and retry once." ) class SchemaVersionUnknown(RuntimeError): """Raised when a tools/call declares a version the server has never served.""" def __init__(self, declared: str, tool: str) -> None: self.declared = declared self.tool = tool super().__init__( f"tool={tool!r} declared schemaVersion={declared!r} was never served by this process." ) class StrictValidationError(RuntimeError): """Raised when a version-less call fails strict schema validation.""" def __init__(self, tool: str, detail: str) -> None: self.tool = tool self.detail = detail super().__init__(f"tool={tool!r} failed strict validation: {detail}") @dataclass class ToolRegistry: current_version: str served_versions: set[str] = field(default_factory=set) retired_versions: set[str] = field(default_factory=set) def dispatch(self, tool: str, args: dict[str, Any], declared_version: str | None) -> dict[str, Any]: if declared_version is None: # v-unknown: never coerce, never default. Strict validation only. if "timeout_ms" in args: raise StrictValidationError( tool, "unknown property 'timeout_ms' under current schema; re-list required" ) return {"tool": tool, "args": args, "version": self.current_version} if declared_version in self.retired_versions: raise SchemaVersionRetired(declared_version, self.current_version, tool) if declared_version not in self.served_versions: raise SchemaVersionUnknown(declared_version, tool) return {"tool": tool, "args": args, "version": declared_version} def retire(self, version: str) -> None: if version in self.served_versions: self.served_versions.remove(version) self.retired_versions.add(version) def handle_db_query(registry: ToolRegistry, params: dict[str, Any]) -> dict[str, Any]: declared = params.get("_meta", {}).get("schemaVersion") args = params.get("arguments", {}) # The boundary: an instance MUST reject a retired capability it previously # advertised rather than silently reinterpreting the stale payload. return registry.dispatch("db.query", args, declared) if __name__ == "__main__": reg = ToolRegistry(current_version="v4", served_versions={"v3", "v4"}) reg.retire("v3") try: handle_db_query( reg, {"_meta": {"schemaVersion": "v3"}, "arguments": {"query": "SELECT 1", "timeout_ms": 5000, "mode": "write"}}, ) except SchemaVersionRetired as exc: print(f"recoverable: {exc}") ```