Skip to content

Commit 3075357

Browse files
committed
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.
1 parent a4f4ccd commit 3075357

8 files changed

Lines changed: 165 additions & 20 deletions

File tree

docs/client/caching.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with
101101

102102
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.
103103

104-
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.
104+
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.
105105

106106
## Older clients
107107

src/mcp-types/mcp_types/methods.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"parse_server_request",
4646
"parse_server_result",
4747
"serialize_server_result",
48+
"strip_era_foreign_fields",
4849
"validate_client_notification",
4950
"validate_client_request",
5051
"validate_client_result",
@@ -631,6 +632,67 @@ def parse_server_notification(
631632
return _monolith_row(monolith, method).model_validate(_body(method, params), by_name=False)
632633

633634

635+
@cache
636+
def _wire_aliases(target: type[BaseModel] | UnionType) -> frozenset[str]:
637+
"""The top-level wire keys `target` declares (its members', for a union)."""
638+
models: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,)
639+
return frozenset(
640+
field.alias or name
641+
for model in models
642+
if isinstance(model, type) and issubclass(model, BaseModel)
643+
for name, field in model.model_fields.items()
644+
)
645+
646+
647+
def _later_revision_fields(
648+
method: str, version: str, surface: Mapping[tuple[str, str], type[BaseModel] | UnionType]
649+
) -> frozenset[str]:
650+
"""Wire keys a newer revision's surface declares for `method` but `version`'s does not.
651+
652+
Such a key is a later revision's field arriving on an earlier session
653+
(e.g. 2026-07-28 `ttlMs`/`cacheScope` at 2025-11-25): outside that
654+
session's contract, so it must not reach the version-free model, which
655+
would otherwise apply the later revision's constraints to it. Only fields
656+
a newer surface actually declares qualify - keys no surface knows
657+
(extension payloads) and legal fields a strict surface merely omits
658+
(e.g. `_meta` on an empty result) are never in this set.
659+
"""
660+
later = KNOWN_PROTOCOL_VERSIONS[KNOWN_PROTOCOL_VERSIONS.index(version) + 1 :]
661+
later_fields: set[str] = set()
662+
for later_version in later:
663+
row = surface.get((method, later_version))
664+
if row is not None:
665+
later_fields |= _wire_aliases(row)
666+
return frozenset(later_fields) - _wire_aliases(surface[(method, version)])
667+
668+
669+
def strip_era_foreign_fields(
670+
method: str,
671+
version: str,
672+
data: Mapping[str, Any],
673+
*,
674+
surface: Mapping[tuple[str, str], type[BaseModel] | UnionType] = SERVER_RESULTS,
675+
) -> Mapping[str, Any]:
676+
"""Drop a later revision's fields from a `version`-scoped result.
677+
678+
The version-free `mcp_types` models carry every revision's fields, so
679+
parsing raw wire data into them lets a field of a revision newer than
680+
`version` (and that revision's constraints) act on a session that never
681+
negotiated it. This removes exactly those keys: the ones a newer surface
682+
for `method` declares but `version`'s surface does not. Every other key
683+
passes through untouched, so payloads the surface only gates (extension
684+
result claims, open `_meta`) are unaffected, and on a session speaking the
685+
newest revision the set is empty. Returns `data` itself when nothing is
686+
stripped; an unknown `(method, version)` also returns `data` unchanged.
687+
"""
688+
if version not in KNOWN_PROTOCOL_VERSIONS or (method, version) not in surface:
689+
return data
690+
foreign = _later_revision_fields(method, version, surface)
691+
if foreign.isdisjoint(data):
692+
return data
693+
return {key: value for key, value in data.items() if key not in foreign}
694+
695+
634696
def serialize_server_result(
635697
method: str,
636698
version: str,
@@ -682,6 +744,10 @@ def parse_server_result(
682744
) -> types.Result:
683745
"""Validate a server result against `surface`, then parse and return its `monolith` model.
684746
747+
Cross-era fields (see `strip_era_foreign_fields`) are removed before the
748+
monolith parse, so a later revision's field cannot reach the version-free
749+
model on a session that did not negotiate it.
750+
685751
Args:
686752
surface: `(method, version)` to wire-type map; the version-gate lookup
687753
and (per-schema-era) shape check run against this. Pass an extended
@@ -696,7 +762,8 @@ def parse_server_result(
696762
RuntimeError: surface matched but `method` has no monolith row.
697763
"""
698764
validate_server_result(method, version, data, surface=surface)
699-
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
765+
payload = strip_era_foreign_fields(method, version, data, surface=surface)
766+
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(payload, by_name=False)
700767
return result
701768

702769

@@ -728,6 +795,10 @@ def parse_client_result(
728795
) -> types.Result:
729796
"""Validate a client result against `surface`, then parse and return its `monolith` model.
730797
798+
Cross-era fields (see `strip_era_foreign_fields`) are removed before the
799+
monolith parse, so a later revision's field cannot reach the version-free
800+
model on a session that did not negotiate it.
801+
731802
Args:
732803
surface: `(method, version)` to wire-type map; the version-gate lookup
733804
and (per-schema-era) shape check run against this. Pass an extended
@@ -742,5 +813,6 @@ def parse_client_result(
742813
RuntimeError: surface matched but `method` has no monolith row.
743814
"""
744815
validate_client_result(method, version, data, surface=surface)
745-
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
816+
payload = strip_era_foreign_fields(method, version, data, surface=surface)
817+
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(payload, by_name=False)
746818
return result

src/mcp/client/session.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -558,9 +558,13 @@ async def send_request(
558558
_methods.validate_server_result(method, version, raw)
559559
except KeyError:
560560
pass
561+
# A later revision's field (e.g. 2026-07-28 cache hints on a pre-2026
562+
# session) is outside the negotiated contract and must not reach the
563+
# version-free result type, which would apply that revision's constraints.
564+
payload = _methods.strip_era_foreign_fields(method, version, raw)
561565
if isinstance(result_type, TypeAdapter):
562-
return result_type.validate_python(raw, by_name=False)
563-
return result_type.model_validate(raw, by_name=False)
566+
return result_type.validate_python(payload, by_name=False)
567+
return result_type.model_validate(payload, by_name=False)
564568

565569
async def send_notification(self, notification: types.ClientNotification) -> None:
566570
"""Send a one-way notification. Usable before entering the context manager.

src/mcp/server/connection.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,16 @@ async def send_request(
395395
_methods.validate_client_result(req.method, self.protocol_version, raw)
396396
except KeyError:
397397
pass
398+
# A later revision's field is outside the negotiated contract and must
399+
# not reach the version-free result type.
400+
payload = _methods.strip_era_foreign_fields(
401+
req.method,
402+
self.protocol_version,
403+
raw,
404+
surface=_methods.CLIENT_RESULTS,
405+
)
398406
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
399-
return cls.model_validate(raw, by_name=False)
407+
return cls.model_validate(payload, by_name=False)
400408

401409
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
402410
"""Send a best-effort notification on the standalone stream.

src/mcp/server/session.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,15 @@ async def send_request(
119119
_methods.validate_client_result(request.method, self.protocol_version, result)
120120
except KeyError:
121121
pass
122-
return result_type.model_validate(result, by_name=False)
122+
# A later revision's field is outside the negotiated contract and must
123+
# not reach the version-free result type.
124+
payload = _methods.strip_era_foreign_fields(
125+
request.method,
126+
self.protocol_version,
127+
result,
128+
surface=_methods.CLIENT_RESULTS,
129+
)
130+
return result_type.model_validate(payload, by_name=False)
123131

124132
async def send_notification(
125133
self,

tests/client/test_session.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,37 @@ async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(w
17591759
assert result.ttl_ms == int(wire_ttl)
17601760

17611761

1762+
_LEGACY_HINTED_RESULTS: list[tuple[str, dict[str, Any]]] = [
1763+
("list_tools", {"tools": []}),
1764+
("list_prompts", {"prompts": []}),
1765+
("list_resources", {"resources": []}),
1766+
("list_resource_templates", {"resourceTemplates": []}),
1767+
("read_resource", {"contents": []}),
1768+
]
1769+
1770+
1771+
@pytest.mark.anyio
1772+
@pytest.mark.parametrize(("verb", "body"), _LEGACY_HINTED_RESULTS)
1773+
async def test_cache_hints_from_a_legacy_server_never_reach_the_result(verb: str, body: dict[str, Any]) -> None:
1774+
"""SDK-defined: on a pre-2026 session the caching fields are outside the negotiated
1775+
schema, so whatever a server puts in them - even values the 2026-07-28 enum
1776+
rejects - is dropped and the model shows its conservative defaults."""
1777+
init_result = InitializeResult(
1778+
protocol_version=LATEST_HANDSHAKE_VERSION,
1779+
capabilities=ServerCapabilities(),
1780+
server_info=Implementation(name="mock-server", version="0.1.0"),
1781+
).model_dump(by_alias=True, mode="json", exclude_none=True)
1782+
hinted = {**body, "ttlMs": -1, "cacheScope": "session"}
1783+
dispatcher = _ScriptedDispatcher(init_result, hinted)
1784+
with anyio.fail_after(5):
1785+
async with ClientSession(dispatcher=dispatcher) as session:
1786+
await session.initialize()
1787+
call = getattr(session, verb)
1788+
result = await (call("mem://x") if verb == "read_resource" else call())
1789+
assert (result.ttl_ms, result.cache_scope) == (0, "private")
1790+
assert not {"ttl_ms", "cache_scope"} & result.model_fields_set
1791+
1792+
17621793
@pytest.mark.anyio
17631794
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
17641795
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the

tests/docs_src/test_caching.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -199,11 +199,3 @@ async def test_a_custom_store_with_an_in_process_server_requires_target_id() ->
199199
"and Transport instances get a random per-client identity, so their entries in a shared store could "
200200
"never be served to another client"
201201
)
202-
203-
204-
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
205-
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
206-
server that sent the field from one that said nothing (model defaults)."""
207-
async with Client(tutorial001.mcp) as client:
208-
tools = await client.list_tools()
209-
assert "ttl_ms" in tools.model_fields_set

tests/types/test_methods.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -682,15 +682,45 @@ def test_empty_result_body_parses_at_versions_that_define_it():
682682
assert isinstance(parsed, types.EmptyResult)
683683

684684

685-
def test_2026_07_28_shaped_result_extras_pass_at_earlier_versions():
686-
"""The earlier surface ignores unknown keys; the monolith preserves them on fields it declares."""
685+
def test_2026_07_28_result_fields_never_reach_the_model_at_earlier_versions():
686+
"""The negotiated surface is the version's contract: 2026-07-28 keys sent at an
687+
earlier version are dropped there, so the model shows defaults, not the wire values."""
687688
parsed = methods.parse_server_result(
688689
"tools/list", "2025-11-25", {"tools": [], "resultType": "complete", "ttlMs": 5, "cacheScope": "public"}
689690
)
690691
assert isinstance(parsed, types.ListToolsResult)
691-
assert parsed.result_type == "complete"
692-
assert parsed.ttl_ms == 5
693-
assert parsed.cache_scope == "public"
692+
assert (parsed.result_type, parsed.ttl_ms, parsed.cache_scope) == ("complete", 0, "private")
693+
assert not {"ttl_ms", "cache_scope"} & parsed.model_fields_set
694+
695+
696+
def test_stripping_removes_only_later_revision_fields_and_nothing_at_the_newest_revision():
697+
"""Extension payload keys neither the model nor the schema declares pass through;
698+
only the model's own fields from a later revision are stripped, and the newest
699+
revision's surface declares them all, so it strips nothing."""
700+
body: dict[str, Any] = {"tools": [], "ttlMs": 5, "cacheScope": "public", "vendor/x": 1}
701+
assert methods.strip_era_foreign_fields("tools/list", "2025-11-25", body) == {"tools": [], "vendor/x": 1}
702+
assert methods.strip_era_foreign_fields("tools/list", "2026-07-28", body) is body
703+
704+
705+
def test_a_legal_field_a_strict_surface_omits_is_not_a_later_revision_field():
706+
"""`ping` has no 2026-07-28 surface, so nothing is foreign at 2025-11-25: the
707+
`_meta` its strict (empty-dumping) surface merely omits is a legal field, not a
708+
later revision's, and it survives to the model."""
709+
body: dict[str, Any] = {"_meta": {"trace": "t-1"}}
710+
assert methods.strip_era_foreign_fields("ping", "2025-11-25", body) is body
711+
parsed = methods.parse_server_result("ping", "2025-11-25", body)
712+
assert isinstance(parsed, types.EmptyResult)
713+
assert parsed.meta == {"trace": "t-1"}
714+
715+
716+
def test_a_2026_07_28_value_invalid_at_that_version_still_parses_at_earlier_versions():
717+
"""A pre-2026 result is judged only by its own schema, so a cache-hint value the
718+
2026-07-28 enum rejects (a future or non-conformant server) cannot fail the parse."""
719+
body: dict[str, Any] = {"tools": [], "resultType": "complete", "ttlMs": -1, "cacheScope": "session"}
720+
parsed = methods.parse_server_result("tools/list", "2025-11-25", body)
721+
assert isinstance(parsed, types.ListToolsResult)
722+
with pytest.raises(pydantic.ValidationError):
723+
methods.parse_server_result("tools/list", "2026-07-28", body)
694724

695725

696726
def test_embedded_input_request_entries_without_method_reject_at_the_surface_step():

0 commit comments

Comments
 (0)