Drop later-revision cache-hint fields on pre-2026 sessions

Merged
MA
3mo ago
+117-3

A client that negotiated a pre-2026 protocol version fails list_tools() (and every other cacheable list/read) with a ValidationError when the server puts a cacheScope value on the result that the 2026-07-28 enum doesn't recognise — e.g. a server that has moved to a newer scheme, or a non-conformant one. On a 2025 session that field isn't part of the negotiated schema at all, and the client already treats these hints as inert on legacy sessions, so the call shouldn't be able to fail on it.

Motivation and Context

ClientSession.send_request validates a result against the negotiated version's schema-exact surface (the version gate), then parses the same raw wire dict into the version-free result type, which carries every revision's fields. So a field belonging to a later revision than the one negotiated (ttlMs, cacheScope, resultType at 2025-11-25) still reached that type, and its 2026-07-28 constraints (closed Literal, ge=0) fired on a session that never negotiated the field — even though the surface had already ruled the key out-of-schema.

send_request now drops exactly the keys a newer revision's surface declares for the method but the negotiated version's does not, before parsing. The set is derived from the surfaces themselves, so keys no surface knows about (extension result-claim payloads) pass through untouched, legal fields a strict surface merely omits (_meta on an empty result) are never treated as foreign, and on a session speaking the newest revision nothing is dropped. The helpers are private and sit next to the existing _clamp_inbound_ttl, which handles the same class of concern.

Not changed here: on a modern (2026-07-28) session cacheScope is in-schema and the enum stays enforced, so an unrecognised value there still rejects the result. Whether an advisory hint should fail closed rather than fatally in-era is a separate policy question.

How Has This Been Tested?

  • New parametrized client test: a handshake (2025) session receives every legacy-reachable cacheable result (tools/list, prompts/list, resources/list, resources/templates/list, resources/read) carrying ttlMs: -1 and cacheScope: "session", and each call succeeds with the defaults and neither hint in model_fields_set.
  • Full suite green at 100% coverage with strict-no-cover clean.
  • End-to-end against a raw (non-SDK) stdio JSON-RPC server driven through Client(stdio_client(...)): a 2025-06-18 session with a bad cacheScope, garbage resultType, and a vendor key now completes list_tools()/call_tool(); the identical script against main reproduces the reported ValidationError. A 2026-07-28 session sending the same bad value still rejects, as expected.

Breaking Changes

None to public signatures. Observable behaviour change: on a pre-2026 session a result no longer carries ttl_ms/cache_scope/result_type values a server smuggled onto the wire — they were outside that session's schema and are now dropped, so the models show their documented defaults (which the response cache never consulted on legacy sessions anyway).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The fix is deliberately client-local. The public parse_server_result/parse_client_result helpers in mcp_types.methods share the same parse shape and still hit this on a legacy body with a bad hint; that's left for a follow-up rather than widened into this change.

AI Disclaimer