Skip to content

Commit 009d0bb

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

7 files changed

Lines changed: 62 additions & 137 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 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.
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.
105105

106106
## Older clients
107107

src/mcp-types/mcp_types/methods.py

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

634633

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-
696634
def serialize_server_result(
697635
method: str,
698636
version: str,
@@ -744,10 +682,6 @@ def parse_server_result(
744682
) -> types.Result:
745683
"""Validate a server result against `surface`, then parse and return its `monolith` model.
746684
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-
751685
Args:
752686
surface: `(method, version)` to wire-type map; the version-gate lookup
753687
and (per-schema-era) shape check run against this. Pass an extended
@@ -762,8 +696,7 @@ def parse_server_result(
762696
RuntimeError: surface matched but `method` has no monolith row.
763697
"""
764698
validate_server_result(method, version, data, surface=surface)
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)
699+
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
767700
return result
768701

769702

@@ -795,10 +728,6 @@ def parse_client_result(
795728
) -> types.Result:
796729
"""Validate a client result against `surface`, then parse and return its `monolith` model.
797730
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-
802731
Args:
803732
surface: `(method, version)` to wire-type map; the version-gate lookup
804733
and (per-schema-era) shape check run against this. Pass an extended
@@ -813,6 +742,5 @@ def parse_client_result(
813742
RuntimeError: surface matched but `method` has no monolith row.
814743
"""
815744
validate_client_result(method, version, data, surface=surface)
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)
745+
result: types.Result = _adapter(_monolith_row(monolith, method)).validate_python(data, by_name=False)
818746
return result

src/mcp/client/session.py

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
import logging
55
from collections.abc import Callable, Mapping, Sequence
66
from dataclasses import dataclass
7-
from functools import reduce
7+
from functools import cache, reduce
88
from operator import or_
9-
from types import TracebackType
10-
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, overload
9+
from types import TracebackType, UnionType
10+
from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, Protocol, TypeAlias, cast, get_args, overload
1111

1212
import anyio
1313
import anyio.abc
@@ -30,6 +30,7 @@
3030
from mcp_types import methods as _methods
3131
from mcp_types.version import (
3232
HANDSHAKE_PROTOCOL_VERSIONS,
33+
KNOWN_PROTOCOL_VERSIONS,
3334
LATEST_HANDSHAKE_VERSION,
3435
LATEST_MODERN_VERSION,
3536
MODERN_PROTOCOL_VERSIONS,
@@ -77,6 +78,39 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
7778
raw["ttlMs"] = 0
7879

7980

81+
@cache
82+
def _wire_fields(target: type[BaseModel] | UnionType) -> frozenset[str]:
83+
"""Top-level wire keys `target` declares (its members', for a union)."""
84+
models: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,)
85+
return frozenset(
86+
field.alias or name
87+
for model in models
88+
if isinstance(model, type) and issubclass(model, BaseModel)
89+
for name, field in model.model_fields.items()
90+
)
91+
92+
93+
@cache
94+
def _later_revision_fields(method: str, version: str) -> frozenset[str]:
95+
"""Result keys a revision newer than `version` declares for `method` but `version` doesn't.
96+
97+
The version-free result types carry every revision's fields, so such a key
98+
(e.g. 2026-07-28 `ttlMs`/`cacheScope` on a pre-2026 session) is outside the
99+
negotiated contract yet would still parse into the model and trip that later
100+
revision's constraints. Empty at the newest known revision.
101+
"""
102+
current = _methods.SERVER_RESULTS.get((method, version))
103+
if current is None or version not in KNOWN_PROTOCOL_VERSIONS:
104+
return frozenset()
105+
newer = KNOWN_PROTOCOL_VERSIONS[KNOWN_PROTOCOL_VERSIONS.index(version) + 1 :]
106+
later: set[str] = set()
107+
for revision in newer:
108+
row = _methods.SERVER_RESULTS.get((method, revision))
109+
if row is not None:
110+
later |= _wire_fields(row)
111+
return frozenset(later) - _wire_fields(current)
112+
113+
80114
def _same_schema(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
81115
"""JSON equality for two output schemas.
82116
@@ -558,13 +592,14 @@ async def send_request(
558592
_methods.validate_server_result(method, version, raw)
559593
except KeyError:
560594
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)
595+
# Drop a later revision's fields (e.g. 2026-07-28 cache hints on a pre-2026
596+
# session): they are outside the negotiated contract, and the version-free
597+
# result type would otherwise apply that revision's constraints to them.
598+
if not (foreign := _later_revision_fields(method, version)).isdisjoint(raw):
599+
raw = {key: value for key, value in raw.items() if key not in foreign}
565600
if isinstance(result_type, TypeAdapter):
566-
return result_type.validate_python(payload, by_name=False)
567-
return result_type.model_validate(payload, by_name=False)
601+
return result_type.validate_python(raw, by_name=False)
602+
return result_type.model_validate(raw, by_name=False)
568603

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

src/mcp/server/connection.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -395,16 +395,8 @@ 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-
)
406398
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
407-
return cls.model_validate(payload, by_name=False)
399+
return cls.model_validate(raw, by_name=False)
408400

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

src/mcp/server/session.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,7 @@ async def send_request(
119119
_methods.validate_client_result(request.method, self.protocol_version, result)
120120
except KeyError:
121121
pass
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)
122+
return result_type.model_validate(result, by_name=False)
131123

132124
async def send_notification(
133125
self,

tests/docs_src/test_caching.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,11 @@ 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: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -682,45 +682,15 @@ 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_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."""
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."""
688687
parsed = methods.parse_server_result(
689688
"tools/list", "2025-11-25", {"tools": [], "resultType": "complete", "ttlMs": 5, "cacheScope": "public"}
690689
)
691690
assert isinstance(parsed, types.ListToolsResult)
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)
691+
assert parsed.result_type == "complete"
692+
assert parsed.ttl_ms == 5
693+
assert parsed.cache_scope == "public"
724694

725695

726696
def test_embedded_input_request_entries_without_method_reject_at_the_surface_step():

0 commit comments

Comments
 (0)