From 3075357f17b5feda52d9814b7e898c6eab46b942 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:52:59 +0000 Subject: [PATCH 1/4] Stop later-revision result fields from failing pre-2026 sessions A client that negotiated a pre-2026 protocol version failed `list_tools()` (and every other cacheable list/read) with a ValidationError whenever the server put a `cacheScope` value on the result that the 2026-07-28 enum doesn't allow. Those cache-hint fields aren't part of a 2025 session's schema at all, so the call should never have been able to fail on them. Inbound results are validated against the negotiated version's schema-exact surface and then parsed into the version-free result models, which carry every revision's fields. Because that second step was fed the raw wire dict, a field belonging to a newer revision than the one negotiated (`ttlMs`, `cacheScope`, `resultType`) still reached the version-free model, and that revision's constraints fired on a session that never negotiated the field. Add `strip_era_foreign_fields` to `mcp_types.methods` and apply it at every point that parses a result into the version-free models. It removes exactly the keys a newer revision's surface declares for the method but the negotiated version's does not, derived from the surfaces themselves. 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 stripped. A legacy server that put valid hint keys on the wire previously leaked those values onto the model even though the cache never consulted them there; they now show the documented defaults consistently, so the caching page and its wire-presence tip are updated to match. --- docs/client/caching.md | 2 +- src/mcp-types/mcp_types/methods.py | 76 +++++++++++++++++++++++++++++- src/mcp/client/session.py | 8 +++- src/mcp/server/connection.py | 10 +++- src/mcp/server/session.py | 10 +++- tests/client/test_session.py | 31 ++++++++++++ tests/docs_src/test_caching.py | 8 ---- tests/types/test_methods.py | 40 ++++++++++++++-- 8 files changed, 165 insertions(+), 20 deletions(-) diff --git a/docs/client/caching.md b/docs/client/caching.md index 5d83cfa92a..fdd46f804f 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -101,7 +101,7 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with The hints are also plain fields on every cacheable result (`result.ttl_ms` and `result.cache_scope`, already parsed), in case you want to layer your own bookkeeping on top of (or instead of) the built-in cache. -Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. +Against an **older server** (pre-2026 protocol), the fields are outside that session's schema, so the models always show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. This holds even if a legacy server puts `ttlMs`/`cacheScope` keys on the wire anyway: those fields belong to a later protocol revision than the one negotiated, so the client drops them before parsing, and a smuggled hint (even a value the 2026-07-28 enum would reject) never reaches the model. The cache treats a legacy session the same way: hints are never consulted, only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. ## Older clients diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 41959c56d7..6433812ca1 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -45,6 +45,7 @@ "parse_server_request", "parse_server_result", "serialize_server_result", + "strip_era_foreign_fields", "validate_client_notification", "validate_client_request", "validate_client_result", @@ -631,6 +632,67 @@ def parse_server_notification( return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) +@cache +def _wire_aliases(target: type[BaseModel] | UnionType) -> frozenset[str]: + """The top-level wire keys `target` declares (its members', for a union).""" + models: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,) + return frozenset( + field.alias or name + for model in models + if isinstance(model, type) and issubclass(model, BaseModel) + for name, field in model.model_fields.items() + ) + + +def _later_revision_fields( + method: str, version: str, surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] +) -> frozenset[str]: + """Wire keys a newer revision's surface declares for `method` but `version`'s does not. + + Such a key is a later revision's field arriving on an earlier session + (e.g. 2026-07-28 `ttlMs`/`cacheScope` at 2025-11-25): outside that + session's contract, so it must not reach the version-free model, which + would otherwise apply the later revision's constraints to it. Only fields + a newer surface actually declares qualify - keys no surface knows + (extension payloads) and legal fields a strict surface merely omits + (e.g. `_meta` on an empty result) are never in this set. + """ + later = KNOWN_PROTOCOL_VERSIONS[KNOWN_PROTOCOL_VERSIONS.index(version) + 1 :] + later_fields: set[str] = set() + for later_version in later: + row = surface.get((method, later_version)) + if row is not None: + later_fields |= _wire_aliases(row) + return frozenset(later_fields) - _wire_aliases(surface[(method, version)]) + + +def strip_era_foreign_fields( + method: str, + version: str, + data: Mapping[str, Any], + *, + surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS, +) -> Mapping[str, Any]: + """Drop a later revision's fields from a `version`-scoped result. + + The version-free `mcp_types` models carry every revision's fields, so + parsing raw wire data into them lets a field of a revision newer than + `version` (and that revision's constraints) act on a session that never + negotiated it. This removes exactly those keys: the ones a newer surface + for `method` declares but `version`'s surface does not. Every other key + passes through untouched, so payloads the surface only gates (extension + result claims, open `_meta`) are unaffected, and on a session speaking the + newest revision the set is empty. Returns `data` itself when nothing is + stripped; an unknown `(method, version)` also returns `data` unchanged. + """ + if version not in KNOWN_PROTOCOL_VERSIONS or (method, version) not in surface: + return data + foreign = _later_revision_fields(method, version, surface) + if foreign.isdisjoint(data): + return data + return {key: value for key, value in data.items() if key not in foreign} + + def serialize_server_result( method: str, version: str, @@ -682,6 +744,10 @@ def parse_server_result( ) -> types.Result: """Validate a server result against `surface`, then parse and return its `monolith` model. + Cross-era fields (see `strip_era_foreign_fields`) are removed before the + monolith parse, so a later revision's field cannot reach the version-free + model on a session that did not negotiate it. + Args: surface: `(method, version)` to wire-type map; the version-gate lookup and (per-schema-era) shape check run against this. Pass an extended @@ -696,7 +762,8 @@ def parse_server_result( RuntimeError: surface matched but `method` has no monolith row. """ validate_server_result(method, version, data, surface=surface) - result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) + payload = strip_era_foreign_fields(method, version, data, surface=surface) + result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(payload, by_name=False) return result @@ -728,6 +795,10 @@ def parse_client_result( ) -> types.Result: """Validate a client result against `surface`, then parse and return its `monolith` model. + Cross-era fields (see `strip_era_foreign_fields`) are removed before the + monolith parse, so a later revision's field cannot reach the version-free + model on a session that did not negotiate it. + Args: surface: `(method, version)` to wire-type map; the version-gate lookup and (per-schema-era) shape check run against this. Pass an extended @@ -742,5 +813,6 @@ def parse_client_result( RuntimeError: surface matched but `method` has no monolith row. """ validate_client_result(method, version, data, surface=surface) - result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) + payload = strip_era_foreign_fields(method, version, data, surface=surface) + result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(payload, by_name=False) return result diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 895339ca18..d8e51f11b2 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -558,9 +558,13 @@ async def send_request( _methods.validate_server_result(method, version, raw) except KeyError: pass + # A later revision's field (e.g. 2026-07-28 cache hints on a pre-2026 + # session) is outside the negotiated contract and must not reach the + # version-free result type, which would apply that revision's constraints. + payload = _methods.strip_era_foreign_fields(method, version, raw) if isinstance(result_type, TypeAdapter): - return result_type.validate_python(raw, by_name=False) - return result_type.model_validate(raw, by_name=False) + return result_type.validate_python(payload, by_name=False) + return result_type.model_validate(payload, by_name=False) async def send_notification(self, notification: types.ClientNotification) -> None: """Send a one-way notification. Usable before entering the context manager. diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 99ba2da481..0dc76c56cb 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -395,8 +395,16 @@ async def send_request( _methods.validate_client_result(req.method, self.protocol_version, raw) except KeyError: pass + # A later revision's field is outside the negotiated contract and must + # not reach the version-free result type. + payload = _methods.strip_era_foreign_fields( + req.method, + self.protocol_version, + raw, + surface=_methods.CLIENT_RESULTS, + ) cls = result_type if result_type is not None else _RESULT_FOR[type(req)] - return cls.model_validate(raw, by_name=False) + return cls.model_validate(payload, by_name=False) async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: """Send a best-effort notification on the standalone stream. diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index bb446415e8..9751d146aa 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -119,7 +119,15 @@ async def send_request( _methods.validate_client_result(request.method, self.protocol_version, result) except KeyError: pass - return result_type.model_validate(result, by_name=False) + # A later revision's field is outside the negotiated contract and must + # not reach the version-free result type. + payload = _methods.strip_era_foreign_fields( + request.method, + self.protocol_version, + result, + surface=_methods.CLIENT_RESULTS, + ) + return result_type.model_validate(payload, by_name=False) async def send_notification( self, diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 6e5e0a14d8..994ccb1e28 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1759,6 +1759,37 @@ async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(w assert result.ttl_ms == int(wire_ttl) +_LEGACY_HINTED_RESULTS: list[tuple[str, dict[str, Any]]] = [ + ("list_tools", {"tools": []}), + ("list_prompts", {"prompts": []}), + ("list_resources", {"resources": []}), + ("list_resource_templates", {"resourceTemplates": []}), + ("read_resource", {"contents": []}), +] + + +@pytest.mark.anyio +@pytest.mark.parametrize(("verb", "body"), _LEGACY_HINTED_RESULTS) +async def test_cache_hints_from_a_legacy_server_never_reach_the_result(verb: str, body: dict[str, Any]) -> None: + """SDK-defined: on a pre-2026 session the caching fields are outside the negotiated + schema, so whatever a server puts in them - even values the 2026-07-28 enum + rejects - is dropped and the model shows its conservative defaults.""" + init_result = InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True) + hinted = {**body, "ttlMs": -1, "cacheScope": "session"} + dispatcher = _ScriptedDispatcher(init_result, hinted) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.initialize() + call = getattr(session, verb) + result = await (call("mem://x") if verb == "read_resource" else call()) + assert (result.ttl_ms, result.cache_scope) == (0, "private") + assert not {"ttl_ms", "cache_scope"} & result.model_fields_set + + @pytest.mark.anyio async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None: """`ClientSession.call_tool(..., allow_input_required=True)` surfaces the diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 751ca86121..4e551b5994 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -199,11 +199,3 @@ async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> "and Transport instances get a random per-client identity, so their entries in a shared store could " "never be served to another client" ) - - -async def test_the_wire_presence_check_the_page_recommends_works() -> None: - """The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a - server that sent the field from one that said nothing (model defaults).""" - async with Client(tutorial001.mcp) as client: - tools = await client.list_tools() - assert "ttl_ms" in tools.model_fields_set diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 3e25bba23d..f16f9a6780 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -682,15 +682,45 @@ def test_empty_result_body_parses_at_versions_that_define_it(): assert isinstance(parsed, types.EmptyResult) -def test_2026_07_28_shaped_result_extras_pass_at_earlier_versions(): - """The earlier surface ignores unknown keys; the monolith preserves them on fields it declares.""" +def test_2026_07_28_result_fields_never_reach_the_model_at_earlier_versions(): + """The negotiated surface is the version's contract: 2026-07-28 keys sent at an + earlier version are dropped there, so the model shows defaults, not the wire values.""" parsed = methods.parse_server_result( "tools/list", "2025-11-25", {"tools": [], "resultType": "complete", "ttlMs": 5, "cacheScope": "public"} ) assert isinstance(parsed, types.ListToolsResult) - assert parsed.result_type == "complete" - assert parsed.ttl_ms == 5 - assert parsed.cache_scope == "public" + assert (parsed.result_type, parsed.ttl_ms, parsed.cache_scope) == ("complete", 0, "private") + assert not {"ttl_ms", "cache_scope"} & parsed.model_fields_set + + +def test_stripping_removes_only_later_revision_fields_and_nothing_at_the_newest_revision(): + """Extension payload keys neither the model nor the schema declares pass through; + only the model's own fields from a later revision are stripped, and the newest + revision's surface declares them all, so it strips nothing.""" + body: dict[str, Any] = {"tools": [], "ttlMs": 5, "cacheScope": "public", "vendor/x": 1} + assert methods.strip_era_foreign_fields("tools/list", "2025-11-25", body) == {"tools": [], "vendor/x": 1} + assert methods.strip_era_foreign_fields("tools/list", "2026-07-28", body) is body + + +def test_a_legal_field_a_strict_surface_omits_is_not_a_later_revision_field(): + """`ping` has no 2026-07-28 surface, so nothing is foreign at 2025-11-25: the + `_meta` its strict (empty-dumping) surface merely omits is a legal field, not a + later revision's, and it survives to the model.""" + body: dict[str, Any] = {"_meta": {"trace": "t-1"}} + assert methods.strip_era_foreign_fields("ping", "2025-11-25", body) is body + parsed = methods.parse_server_result("ping", "2025-11-25", body) + assert isinstance(parsed, types.EmptyResult) + assert parsed.meta == {"trace": "t-1"} + + +def test_a_2026_07_28_value_invalid_at_that_version_still_parses_at_earlier_versions(): + """A pre-2026 result is judged only by its own schema, so a cache-hint value the + 2026-07-28 enum rejects (a future or non-conformant server) cannot fail the parse.""" + body: dict[str, Any] = {"tools": [], "resultType": "complete", "ttlMs": -1, "cacheScope": "session"} + parsed = methods.parse_server_result("tools/list", "2025-11-25", body) + assert isinstance(parsed, types.ListToolsResult) + with pytest.raises(pydantic.ValidationError): + methods.parse_server_result("tools/list", "2026-07-28", body) def test_embedded_input_request_entries_without_method_reject_at_the_surface_step(): From 009d0bb518c80de4055f4ae18347bd49c8bd022c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:15:52 +0000 Subject: [PATCH 2/4] Keep the later-revision field drop private to the client session The reported failure is on the client's inbound path, so the fix now lives there as private helpers next to the existing inbound-ttl clamp rather than as a new public function in mcp_types.methods. Drop the exported strip_era_foreign_fields helper and the calls threaded through the standalone parse functions and the server-side result parsing (the server-side calls stripped nothing, since no client-result method has a later-revision field). Restore the caching docs paragraph and its docs test, which the client-local change does not contradict. --- docs/client/caching.md | 2 +- src/mcp-types/mcp_types/methods.py | 76 +----------------------------- src/mcp/client/session.py | 53 +++++++++++++++++---- src/mcp/server/connection.py | 10 +--- src/mcp/server/session.py | 10 +--- tests/docs_src/test_caching.py | 8 ++++ tests/types/test_methods.py | 40 ++-------------- 7 files changed, 62 insertions(+), 137 deletions(-) diff --git a/docs/client/caching.md b/docs/client/caching.md index fdd46f804f..5d83cfa92a 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -101,7 +101,7 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with The hints are also plain fields on every cacheable result (`result.ttl_ms` and `result.cache_scope`, already parsed), in case you want to layer your own bookkeeping on top of (or instead of) the built-in cache. -Against an **older server** (pre-2026 protocol), the fields are outside that session's schema, so the models always show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. This holds even if a legacy server puts `ttlMs`/`cacheScope` keys on the wire anyway: those fields belong to a later protocol revision than the one negotiated, so the client drops them before parsing, and a smuggled hint (even a value the 2026-07-28 enum would reject) never reaches the model. The cache treats a legacy session the same way: hints are never consulted, only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. +Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. ## Older clients diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 6433812ca1..41959c56d7 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -45,7 +45,6 @@ "parse_server_request", "parse_server_result", "serialize_server_result", - "strip_era_foreign_fields", "validate_client_notification", "validate_client_request", "validate_client_result", @@ -632,67 +631,6 @@ def parse_server_notification( return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False) -@cache -def _wire_aliases(target: type[BaseModel] | UnionType) -> frozenset[str]: - """The top-level wire keys `target` declares (its members', for a union).""" - models: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,) - return frozenset( - field.alias or name - for model in models - if isinstance(model, type) and issubclass(model, BaseModel) - for name, field in model.model_fields.items() - ) - - -def _later_revision_fields( - method: str, version: str, surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] -) -> frozenset[str]: - """Wire keys a newer revision's surface declares for `method` but `version`'s does not. - - Such a key is a later revision's field arriving on an earlier session - (e.g. 2026-07-28 `ttlMs`/`cacheScope` at 2025-11-25): outside that - session's contract, so it must not reach the version-free model, which - would otherwise apply the later revision's constraints to it. Only fields - a newer surface actually declares qualify - keys no surface knows - (extension payloads) and legal fields a strict surface merely omits - (e.g. `_meta` on an empty result) are never in this set. - """ - later = KNOWN_PROTOCOL_VERSIONS[KNOWN_PROTOCOL_VERSIONS.index(version) + 1 :] - later_fields: set[str] = set() - for later_version in later: - row = surface.get((method, later_version)) - if row is not None: - later_fields |= _wire_aliases(row) - return frozenset(later_fields) - _wire_aliases(surface[(method, version)]) - - -def strip_era_foreign_fields( - method: str, - version: str, - data: Mapping[str, Any], - *, - surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS, -) -> Mapping[str, Any]: - """Drop a later revision's fields from a `version`-scoped result. - - The version-free `mcp_types` models carry every revision's fields, so - parsing raw wire data into them lets a field of a revision newer than - `version` (and that revision's constraints) act on a session that never - negotiated it. This removes exactly those keys: the ones a newer surface - for `method` declares but `version`'s surface does not. Every other key - passes through untouched, so payloads the surface only gates (extension - result claims, open `_meta`) are unaffected, and on a session speaking the - newest revision the set is empty. Returns `data` itself when nothing is - stripped; an unknown `(method, version)` also returns `data` unchanged. - """ - if version not in KNOWN_PROTOCOL_VERSIONS or (method, version) not in surface: - return data - foreign = _later_revision_fields(method, version, surface) - if foreign.isdisjoint(data): - return data - return {key: value for key, value in data.items() if key not in foreign} - - def serialize_server_result( method: str, version: str, @@ -744,10 +682,6 @@ def parse_server_result( ) -> types.Result: """Validate a server result against `surface`, then parse and return its `monolith` model. - Cross-era fields (see `strip_era_foreign_fields`) are removed before the - monolith parse, so a later revision's field cannot reach the version-free - model on a session that did not negotiate it. - Args: surface: `(method, version)` to wire-type map; the version-gate lookup and (per-schema-era) shape check run against this. Pass an extended @@ -762,8 +696,7 @@ def parse_server_result( RuntimeError: surface matched but `method` has no monolith row. """ validate_server_result(method, version, data, surface=surface) - payload = strip_era_foreign_fields(method, version, data, surface=surface) - result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(payload, by_name=False) + result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) return result @@ -795,10 +728,6 @@ def parse_client_result( ) -> types.Result: """Validate a client result against `surface`, then parse and return its `monolith` model. - Cross-era fields (see `strip_era_foreign_fields`) are removed before the - monolith parse, so a later revision's field cannot reach the version-free - model on a session that did not negotiate it. - Args: surface: `(method, version)` to wire-type map; the version-gate lookup and (per-schema-era) shape check run against this. Pass an extended @@ -813,6 +742,5 @@ def parse_client_result( RuntimeError: surface matched but `method` has no monolith row. """ validate_client_result(method, version, data, surface=surface) - payload = strip_era_foreign_fields(method, version, data, surface=surface) - result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(payload, by_name=False) + result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False) return result diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index d8e51f11b2..00bf1a84dc 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -4,10 +4,10 @@ import logging from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass -from functools import reduce +from functools import cache, reduce from operator import or_ -from types import TracebackType -from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload +from types import TracebackType, UnionType +from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, get_args, overload import anyio import anyio.abc @@ -30,6 +30,7 @@ from mcp_types import methods as _methods from mcp_types.version import ( HANDSHAKE_PROTOCOL_VERSIONS, + KNOWN_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS, @@ -77,6 +78,39 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None: raw["ttlMs"] = 0 +@cache +def _wire_fields(target: type[BaseModel] | UnionType) -> frozenset[str]: + """Top-level wire keys `target` declares (its members', for a union).""" + models: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,) + return frozenset( + field.alias or name + for model in models + if isinstance(model, type) and issubclass(model, BaseModel) + for name, field in model.model_fields.items() + ) + + +@cache +def _later_revision_fields(method: str, version: str) -> frozenset[str]: + """Result keys a revision newer than `version` declares for `method` but `version` doesn't. + + The version-free result types carry every revision's fields, so such a key + (e.g. 2026-07-28 `ttlMs`/`cacheScope` on a pre-2026 session) is outside the + negotiated contract yet would still parse into the model and trip that later + revision's constraints. Empty at the newest known revision. + """ + current = _methods.SERVER_RESULTS.get((method, version)) + if current is None or version not in KNOWN_PROTOCOL_VERSIONS: + return frozenset() + newer = KNOWN_PROTOCOL_VERSIONS[KNOWN_PROTOCOL_VERSIONS.index(version) + 1 :] + later: set[str] = set() + for revision in newer: + row = _methods.SERVER_RESULTS.get((method, revision)) + if row is not None: + later |= _wire_fields(row) + return frozenset(later) - _wire_fields(current) + + def _same_schema(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: """JSON equality for two output schemas. @@ -558,13 +592,14 @@ async def send_request( _methods.validate_server_result(method, version, raw) except KeyError: pass - # A later revision's field (e.g. 2026-07-28 cache hints on a pre-2026 - # session) is outside the negotiated contract and must not reach the - # version-free result type, which would apply that revision's constraints. - payload = _methods.strip_era_foreign_fields(method, version, raw) + # Drop a later revision's fields (e.g. 2026-07-28 cache hints on a pre-2026 + # session): they are outside the negotiated contract, and the version-free + # result type would otherwise apply that revision's constraints to them. + if not (foreign := _later_revision_fields(method, version)).isdisjoint(raw): + raw = {key: value for key, value in raw.items() if key not in foreign} if isinstance(result_type, TypeAdapter): - return result_type.validate_python(payload, by_name=False) - return result_type.model_validate(payload, by_name=False) + return result_type.validate_python(raw, by_name=False) + return result_type.model_validate(raw, by_name=False) async def send_notification(self, notification: types.ClientNotification) -> None: """Send a one-way notification. Usable before entering the context manager. diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 0dc76c56cb..99ba2da481 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -395,16 +395,8 @@ async def send_request( _methods.validate_client_result(req.method, self.protocol_version, raw) except KeyError: pass - # A later revision's field is outside the negotiated contract and must - # not reach the version-free result type. - payload = _methods.strip_era_foreign_fields( - req.method, - self.protocol_version, - raw, - surface=_methods.CLIENT_RESULTS, - ) cls = result_type if result_type is not None else _RESULT_FOR[type(req)] - return cls.model_validate(payload, by_name=False) + return cls.model_validate(raw, by_name=False) async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: """Send a best-effort notification on the standalone stream. diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 9751d146aa..bb446415e8 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -119,15 +119,7 @@ async def send_request( _methods.validate_client_result(request.method, self.protocol_version, result) except KeyError: pass - # A later revision's field is outside the negotiated contract and must - # not reach the version-free result type. - payload = _methods.strip_era_foreign_fields( - request.method, - self.protocol_version, - result, - surface=_methods.CLIENT_RESULTS, - ) - return result_type.model_validate(payload, by_name=False) + return result_type.model_validate(result, by_name=False) async def send_notification( self, diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 4e551b5994..751ca86121 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -199,3 +199,11 @@ async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> "and Transport instances get a random per-client identity, so their entries in a shared store could " "never be served to another client" ) + + +async def test_the_wire_presence_check_the_page_recommends_works() -> None: + """The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a + server that sent the field from one that said nothing (model defaults).""" + async with Client(tutorial001.mcp) as client: + tools = await client.list_tools() + assert "ttl_ms" in tools.model_fields_set diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index f16f9a6780..3e25bba23d 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -682,45 +682,15 @@ def test_empty_result_body_parses_at_versions_that_define_it(): assert isinstance(parsed, types.EmptyResult) -def test_2026_07_28_result_fields_never_reach_the_model_at_earlier_versions(): - """The negotiated surface is the version's contract: 2026-07-28 keys sent at an - earlier version are dropped there, so the model shows defaults, not the wire values.""" +def test_2026_07_28_shaped_result_extras_pass_at_earlier_versions(): + """The earlier surface ignores unknown keys; the monolith preserves them on fields it declares.""" parsed = methods.parse_server_result( "tools/list", "2025-11-25", {"tools": [], "resultType": "complete", "ttlMs": 5, "cacheScope": "public"} ) assert isinstance(parsed, types.ListToolsResult) - assert (parsed.result_type, parsed.ttl_ms, parsed.cache_scope) == ("complete", 0, "private") - assert not {"ttl_ms", "cache_scope"} & parsed.model_fields_set - - -def test_stripping_removes_only_later_revision_fields_and_nothing_at_the_newest_revision(): - """Extension payload keys neither the model nor the schema declares pass through; - only the model's own fields from a later revision are stripped, and the newest - revision's surface declares them all, so it strips nothing.""" - body: dict[str, Any] = {"tools": [], "ttlMs": 5, "cacheScope": "public", "vendor/x": 1} - assert methods.strip_era_foreign_fields("tools/list", "2025-11-25", body) == {"tools": [], "vendor/x": 1} - assert methods.strip_era_foreign_fields("tools/list", "2026-07-28", body) is body - - -def test_a_legal_field_a_strict_surface_omits_is_not_a_later_revision_field(): - """`ping` has no 2026-07-28 surface, so nothing is foreign at 2025-11-25: the - `_meta` its strict (empty-dumping) surface merely omits is a legal field, not a - later revision's, and it survives to the model.""" - body: dict[str, Any] = {"_meta": {"trace": "t-1"}} - assert methods.strip_era_foreign_fields("ping", "2025-11-25", body) is body - parsed = methods.parse_server_result("ping", "2025-11-25", body) - assert isinstance(parsed, types.EmptyResult) - assert parsed.meta == {"trace": "t-1"} - - -def test_a_2026_07_28_value_invalid_at_that_version_still_parses_at_earlier_versions(): - """A pre-2026 result is judged only by its own schema, so a cache-hint value the - 2026-07-28 enum rejects (a future or non-conformant server) cannot fail the parse.""" - body: dict[str, Any] = {"tools": [], "resultType": "complete", "ttlMs": -1, "cacheScope": "session"} - parsed = methods.parse_server_result("tools/list", "2025-11-25", body) - assert isinstance(parsed, types.ListToolsResult) - with pytest.raises(pydantic.ValidationError): - methods.parse_server_result("tools/list", "2026-07-28", body) + assert parsed.result_type == "complete" + assert parsed.ttl_ms == 5 + assert parsed.cache_scope == "public" def test_embedded_input_request_entries_without_method_reject_at_the_surface_step(): From 411c21f16d904609b6db40ff3dbd8867f72b00e0 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:53:11 +0000 Subject: [PATCH 3/4] Harden the client's later-revision drop and widen its coverage A `RootModel` wrapper row (the empty result served at pre-2026 versions) reported the pydantic-internal `root` key rather than the keys of the model it wraps; unwrap it so a legal field a strict surface merely omits, such as `_meta`, is never mistaken for a later revision's field. This is latent against today's tables (none of the wrapper rows has a newer-revision counterpart) but keeps the field-set logic correct as revisions are added. Extend the legacy-session test over every handshake protocol version, cover the `resultType` tag on `tools/call` and `prompts/get` results, and register the inbound tolerance in the interaction requirements manifest alongside its existing outbound counterpart. --- src/mcp/client/session.py | 22 +++++++---- tests/client/test_session.py | 60 ++++++++++++++++++++++++------ tests/interaction/_requirements.py | 13 +++++++ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 00bf1a84dc..f18cc0ef10 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -80,14 +80,20 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None: @cache def _wire_fields(target: type[BaseModel] | UnionType) -> frozenset[str]: - """Top-level wire keys `target` declares (its members', for a union).""" - models: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,) - return frozenset( - field.alias or name - for model in models - if isinstance(model, type) and issubclass(model, BaseModel) - for name, field in model.model_fields.items() - ) + """Top-level wire keys `target` declares (its members', for a union). + + A `RootModel` row (e.g. an empty result carried as `RootModel[Result]`) + reports its wrapped type's keys, not the pydantic-internal `root`. + """ + members: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,) + models = [m for m in members if isinstance(m, type) and issubclass(m, BaseModel)] + fields: set[str] = set() + for model in models: + if getattr(model, "__pydantic_root_model__", False): # a RootModel wrapper row + fields |= _wire_fields(model.model_fields["root"].annotation) + else: + fields.update(field.alias or name for name, field in model.model_fields.items()) + return frozenset(fields) @cache diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 994ccb1e28..6663fb47a2 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1767,29 +1767,67 @@ async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(w ("read_resource", {"contents": []}), ] +_LEGACY_TAGGED_RESULTS: list[tuple[str, dict[str, Any]]] = [ + ("call_tool", {"content": []}), + ("get_prompt", {"messages": []}), +] + + +def _legacy_init(version: str) -> dict[str, Any]: + return InitializeResult( + protocol_version=version, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True) + + +async def _call_legacy(session: ClientSession, verb: str) -> Any: + if verb == "read_resource": + return await session.read_resource("mem://x") + if verb == "call_tool": + return await session.call_tool("t", {}) + if verb == "get_prompt": + return await session.get_prompt("p") + return await getattr(session, verb)() + @pytest.mark.anyio +@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS) @pytest.mark.parametrize(("verb", "body"), _LEGACY_HINTED_RESULTS) -async def test_cache_hints_from_a_legacy_server_never_reach_the_result(verb: str, body: dict[str, Any]) -> None: - """SDK-defined: on a pre-2026 session the caching fields are outside the negotiated +async def test_cache_hints_from_a_legacy_server_never_reach_the_result( + version: str, verb: str, body: dict[str, Any] +) -> None: + """SDK-defined: on any pre-2026 session the caching fields are outside the negotiated schema, so whatever a server puts in them - even values the 2026-07-28 enum rejects - is dropped and the model shows its conservative defaults.""" - init_result = InitializeResult( - protocol_version=LATEST_HANDSHAKE_VERSION, - capabilities=ServerCapabilities(), - server_info=Implementation(name="mock-server", version="0.1.0"), - ).model_dump(by_alias=True, mode="json", exclude_none=True) - hinted = {**body, "ttlMs": -1, "cacheScope": "session"} - dispatcher = _ScriptedDispatcher(init_result, hinted) + dispatcher = _ScriptedDispatcher(_legacy_init(version), {**body, "ttlMs": -1, "cacheScope": "session"}) with anyio.fail_after(5): async with ClientSession(dispatcher=dispatcher) as session: await session.initialize() - call = getattr(session, verb) - result = await (call("mem://x") if verb == "read_resource" else call()) + result = await _call_legacy(session, verb) assert (result.ttl_ms, result.cache_scope) == (0, "private") assert not {"ttl_ms", "cache_scope"} & result.model_fields_set +@pytest.mark.anyio +@pytest.mark.parametrize(("verb", "body"), _LEGACY_TAGGED_RESULTS) +async def test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_result( + verb: str, body: dict[str, Any] +) -> None: + """SDK-defined: `resultType` is 2026-07-28 vocabulary that also feeds result-union + routing, so a tag on a pre-2026 wire (even one no union arm claims) is dropped and + the plain result is returned rather than mis-routing or failing.""" + # `call_tool` re-lists tools to validate structured content; that answer trails harmlessly for `get_prompt`. + dispatcher = _ScriptedDispatcher( + _legacy_init(LATEST_HANDSHAKE_VERSION), {**body, "resultType": "task"}, {"tools": []} + ) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.initialize() + result = await _call_legacy(session, verb) + assert result.result_type == "complete" + + @pytest.mark.anyio async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None: """`ClientSession.call_tool(..., allow_input_required=True)` surfaces the diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 964a1829d2..59d96d276b 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3278,6 +3278,19 @@ def __post_init__(self) -> None: "at the transport seam." ), ), + "hosting:client:legacy-inbound-modern-vocabulary-dropped": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning", + behavior=( + "A client on a pre-2026 session that receives 2026-07-28 result vocabulary " + "(resultType, ttlMs, cacheScope) drops those fields before parsing, so a value " + "the 2026-07-28 types reject cannot fail the call and the result shows its defaults." + ), + deferred=( + "Client-inbound seam, covered by tests/client/test_session.py::" + "test_cache_hints_from_a_legacy_server_never_reach_the_result and " + "test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_result" + ), + ), "hosting:http:modern:tools-call-stateless": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http", behavior=( From 32bbb0510f2976f54e3e7466c118222ae3213f72 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:44:21 +0000 Subject: [PATCH 4/4] Drop the interaction-requirements manifest entry The manifest's ID vocabulary is aligned entry-by-entry with the TypeScript SDK's requirements suite, so a new ID is a maintainer decision rather than something to mint alongside a bug fix. The client tests stand on their own without it. No-Verification-Needed: manifest-only revert with no runtime surface --- tests/interaction/_requirements.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 59d96d276b..964a1829d2 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3278,19 +3278,6 @@ def __post_init__(self) -> None: "at the transport seam." ), ), - "hosting:client:legacy-inbound-modern-vocabulary-dropped": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/versioning", - behavior=( - "A client on a pre-2026 session that receives 2026-07-28 result vocabulary " - "(resultType, ttlMs, cacheScope) drops those fields before parsing, so a value " - "the 2026-07-28 types reject cannot fail the call and the result shows its defaults." - ), - deferred=( - "Client-inbound seam, covered by tests/client/test_session.py::" - "test_cache_hints_from_a_legacy_server_never_reach_the_result and " - "test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_result" - ), - ), "hosting:http:modern:tools-call-stateless": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http", behavior=(