Skip to content

Commit 411c21f

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

3 files changed

Lines changed: 76 additions & 19 deletions

File tree

src/mcp/client/session.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,20 @@ def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
8080

8181
@cache
8282
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-
)
83+
"""Top-level wire keys `target` declares (its members', for a union).
84+
85+
A `RootModel` row (e.g. an empty result carried as `RootModel[Result]`)
86+
reports its wrapped type's keys, not the pydantic-internal `root`.
87+
"""
88+
members: tuple[Any, ...] = get_args(target) if isinstance(target, UnionType) else (target,)
89+
models = [m for m in members if isinstance(m, type) and issubclass(m, BaseModel)]
90+
fields: set[str] = set()
91+
for model in models:
92+
if getattr(model, "__pydantic_root_model__", False): # a RootModel wrapper row
93+
fields |= _wire_fields(model.model_fields["root"].annotation)
94+
else:
95+
fields.update(field.alias or name for name, field in model.model_fields.items())
96+
return frozenset(fields)
9197

9298

9399
@cache

tests/client/test_session.py

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1767,29 +1767,67 @@ async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(w
17671767
("read_resource", {"contents": []}),
17681768
]
17691769

1770+
_LEGACY_TAGGED_RESULTS: list[tuple[str, dict[str, Any]]] = [
1771+
("call_tool", {"content": []}),
1772+
("get_prompt", {"messages": []}),
1773+
]
1774+
1775+
1776+
def _legacy_init(version: str) -> dict[str, Any]:
1777+
return InitializeResult(
1778+
protocol_version=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+
1783+
1784+
async def _call_legacy(session: ClientSession, verb: str) -> Any:
1785+
if verb == "read_resource":
1786+
return await session.read_resource("mem://x")
1787+
if verb == "call_tool":
1788+
return await session.call_tool("t", {})
1789+
if verb == "get_prompt":
1790+
return await session.get_prompt("p")
1791+
return await getattr(session, verb)()
1792+
17701793

17711794
@pytest.mark.anyio
1795+
@pytest.mark.parametrize("version", HANDSHAKE_PROTOCOL_VERSIONS)
17721796
@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
1797+
async def test_cache_hints_from_a_legacy_server_never_reach_the_result(
1798+
version: str, verb: str, body: dict[str, Any]
1799+
) -> None:
1800+
"""SDK-defined: on any pre-2026 session the caching fields are outside the negotiated
17751801
schema, so whatever a server puts in them - even values the 2026-07-28 enum
17761802
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)
1803+
dispatcher = _ScriptedDispatcher(_legacy_init(version), {**body, "ttlMs": -1, "cacheScope": "session"})
17841804
with anyio.fail_after(5):
17851805
async with ClientSession(dispatcher=dispatcher) as session:
17861806
await session.initialize()
1787-
call = getattr(session, verb)
1788-
result = await (call("mem://x") if verb == "read_resource" else call())
1807+
result = await _call_legacy(session, verb)
17891808
assert (result.ttl_ms, result.cache_scope) == (0, "private")
17901809
assert not {"ttl_ms", "cache_scope"} & result.model_fields_set
17911810

17921811

1812+
@pytest.mark.anyio
1813+
@pytest.mark.parametrize(("verb", "body"), _LEGACY_TAGGED_RESULTS)
1814+
async def test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_result(
1815+
verb: str, body: dict[str, Any]
1816+
) -> None:
1817+
"""SDK-defined: `resultType` is 2026-07-28 vocabulary that also feeds result-union
1818+
routing, so a tag on a pre-2026 wire (even one no union arm claims) is dropped and
1819+
the plain result is returned rather than mis-routing or failing."""
1820+
# `call_tool` re-lists tools to validate structured content; that answer trails harmlessly for `get_prompt`.
1821+
dispatcher = _ScriptedDispatcher(
1822+
_legacy_init(LATEST_HANDSHAKE_VERSION), {**body, "resultType": "task"}, {"tools": []}
1823+
)
1824+
with anyio.fail_after(5):
1825+
async with ClientSession(dispatcher=dispatcher) as session:
1826+
await session.initialize()
1827+
result = await _call_legacy(session, verb)
1828+
assert result.result_type == "complete"
1829+
1830+
17931831
@pytest.mark.anyio
17941832
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
17951833
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the

tests/interaction/_requirements.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3278,6 +3278,19 @@ def __post_init__(self) -> None:
32783278
"at the transport seam."
32793279
),
32803280
),
3281+
"hosting:client:legacy-inbound-modern-vocabulary-dropped": Requirement(
3282+
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
3283+
behavior=(
3284+
"A client on a pre-2026 session that receives 2026-07-28 result vocabulary "
3285+
"(resultType, ttlMs, cacheScope) drops those fields before parsing, so a value "
3286+
"the 2026-07-28 types reject cannot fail the call and the result shows its defaults."
3287+
),
3288+
deferred=(
3289+
"Client-inbound seam, covered by tests/client/test_session.py::"
3290+
"test_cache_hints_from_a_legacy_server_never_reach_the_result and "
3291+
"test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_result"
3292+
),
3293+
),
32813294
"hosting:http:modern:tools-call-stateless": Requirement(
32823295
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
32833296
behavior=(

0 commit comments

Comments
 (0)