Skip to content

Commit 7bf87f8

Browse files
committed
Thread protocol_version through Client and default CacheableResult to immediately-stale private
- Client gains protocol_version: str | None; threads to ClientSession and streamable_http_client. The interaction-suite connect factories forward the parameter they already accepted. - CacheableResult defaults to ttl_ms=0, cache_scope="private" so list/read results constructed without explicit hints validate against the 2026-07-28 surface and never accidentally enable shared caching. - TRANSPORT_SPEC_VERSIONS era-locks in-memory and streamable-http-stateless to 2025-11-25 (the former pending a modern in-memory entry; the latter collapses into stateful at the newer revision).
1 parent 47a422c commit 7bf87f8

9 files changed

Lines changed: 39 additions & 18 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,7 @@ If you relied on extra fields round-tripping through MCP types, move that data i
12411241

12421242
### 2025-11-25 and 2026-07-28 protocol fields modeled
12431243

1244-
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `None`; `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
1244+
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
12451245

12461246
### `streamable_http_app()` available on lowlevel Server
12471247

src/mcp/client/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ async def main():
9292
client_info: Implementation | None = None
9393
"""Client implementation info to send to server."""
9494

95+
protocol_version: str | None = None
96+
"""Pin the protocol version instead of negotiating it.
97+
98+
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
99+
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally),
100+
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request.
101+
Leave as ``None`` to negotiate the version via the initialize handshake.
102+
"""
103+
95104
elicitation_callback: ElicitationFnT | None = None
96105
"""Callback for handling elicitation requests."""
97106

@@ -103,7 +112,7 @@ def __post_init__(self) -> None:
103112
if isinstance(self.server, Server | MCPServer):
104113
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
105114
elif isinstance(self.server, str):
106-
self._transport = streamable_http_client(self.server)
115+
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
107116
else:
108117
self._transport = self.server
109118

@@ -126,6 +135,7 @@ async def __aenter__(self) -> Client:
126135
message_handler=self.message_handler,
127136
client_info=self.client_info,
128137
elicitation_callback=self.elicitation_callback,
138+
protocol_version=self.protocol_version,
129139
)
130140
)
131141

src/mcp/types/_types.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,15 +185,17 @@ class PaginatedResult(Result):
185185
class CacheableResult(Result):
186186
"""Base class for results that carry client-side caching directives (2026-07-28).
187187
188-
Both fields are required on the 2026-07-28 wire; the SDK declares no
189-
default, so a handler answering at 2026-07-28 must set them explicitly.
188+
Both fields are required on the 2026-07-28 wire. The SDK defaults to
189+
`ttl_ms=0` (immediately stale) and `cache_scope="private"` so a handler
190+
that doesn't set them still produces a valid 2026-07-28 result without
191+
accidentally enabling shared caching.
190192
"""
191193

192-
ttl_ms: Annotated[int, Field(ge=0)] | None = None
194+
ttl_ms: Annotated[int, Field(ge=0)] = 0
193195
"""How long (ms) the client MAY cache this response, analogous to HTTP
194196
`Cache-Control: max-age`. 0 means immediately stale."""
195197

196-
cache_scope: Literal["public", "private"] | None = None
198+
cache_scope: Literal["public", "private"] = "private"
197199
"""Analogous to HTTP `Cache-Control: public` vs `private`: "public" allows
198200
shared caches to serve the response to any user; "private" forbids that."""
199201

tests/client/test_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ async def test_complete_with_prompt_reference(simple_server: Server):
316316
def test_client_with_url_initializes_streamable_http_transport():
317317
with patch("mcp.client.client.streamable_http_client") as mock:
318318
_ = Client("http://localhost:8000/mcp")
319-
mock.assert_called_once_with("http://localhost:8000/mcp")
319+
mock.assert_called_once_with("http://localhost:8000/mcp", protocol_version=None)
320320

321321

322322
async def test_client_uses_transport_directly(app: MCPServer):

tests/interaction/_connect.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ async def connect_in_memory(
9797
message_handler=message_handler,
9898
client_info=client_info,
9999
elicitation_callback=elicitation_callback,
100+
protocol_version=protocol_version,
100101
) as client:
101102
yield client
102103

@@ -137,14 +138,15 @@ async def connect_over_streamable_http(
137138
server.session_manager.run(),
138139
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
139140
Client(
140-
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
141+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client, protocol_version=protocol_version),
141142
read_timeout_seconds=read_timeout_seconds,
142143
sampling_callback=sampling_callback,
143144
list_roots_callback=list_roots_callback,
144145
logging_callback=logging_callback,
145146
message_handler=message_handler,
146147
client_info=client_info,
147148
elicitation_callback=elicitation_callback,
149+
protocol_version=protocol_version,
148150
) as client,
149151
):
150152
yield client

tests/interaction/_requirements.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@
6363

6464
TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = {
6565
"sse": ("2025-11-25",),
66+
# Temporary lock: the in-memory transport has no modern entry point yet, so it cannot
67+
# negotiate the newer revision. Remove once an in-memory factory for the modern path lands.
68+
"in-memory": ("2025-11-25",),
69+
# At the newer revision the protocol-version header check runs before the stateless branch is
70+
# taken, so a stateless connection at that revision behaves identically to the stateful one.
71+
# Locked to avoid a redundant matrix column; revisit if the header/stateless ordering changes.
72+
"streamable-http-stateless": ("2025-11-25",),
6673
}
6774
"""Transports that only serve a subset of SPEC_VERSIONS. Absent => serves all. Consulted by compute_cells()."""
6875

tests/interaction/test_coverage.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,9 +301,7 @@ def test_compute_cells_drops_era_locked_transport_outside_its_versions() -> None
301301
"sse-2025-11-25",
302302
"streamable-http-2025-11-25",
303303
"streamable-http-stateless-2025-11-25",
304-
"in-memory-2026-07-28",
305304
"streamable-http-2026-07-28",
306-
"streamable-http-stateless-2026-07-28",
307305
]
308306

309307

tests/test_types.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -401,9 +401,10 @@ def test_concrete_wire_results_always_dump_result_type_complete():
401401
assert _wire_dump(result)["resultType"] == "complete", type(result).__name__
402402

403403

404-
def test_cacheable_results_omit_unset_caching_directives():
405-
"""`ttl_ms`/`cache_scope` default to None: the SDK declares no caching policy
406-
so a 2026-07-28 handler must set them explicitly."""
404+
def test_cacheable_results_default_to_immediately_stale_private():
405+
"""`ttl_ms`/`cache_scope` default to 0/"private" so list-results validate at
406+
2026-07-28 without the handler setting them, and never accidentally enable
407+
shared caching."""
407408
cacheable: list[Result] = [
408409
ReadResourceResult(contents=[]),
409410
ListPromptsResult(prompts=[]),
@@ -418,8 +419,8 @@ def test_cacheable_results_omit_unset_caching_directives():
418419
]
419420
for result in cacheable:
420421
dumped = _wire_dump(result)
421-
assert "ttlMs" not in dumped, type(result).__name__
422-
assert "cacheScope" not in dumped, type(result).__name__
422+
assert dumped["ttlMs"] == 0, type(result).__name__
423+
assert dumped["cacheScope"] == "private", type(result).__name__
423424
explicit = _wire_dump(ListToolsResult(tools=[], ttl_ms=5, cache_scope="public"))
424425
assert explicit["ttlMs"] == 5
425426
assert explicit["cacheScope"] == "public"

tests/types/test_wire_frames.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,13 @@ def test_non_empty_result_dump_carries_result_type_complete_before_the_sieve():
6161
)
6262

6363

64-
def test_cacheable_list_result_dump_omits_unset_caching_directives():
65-
"""`ttl_ms`/`cache_scope` default to None so the raw dump omits them; 2026 handlers set them explicitly."""
64+
def test_cacheable_list_result_dump_carries_default_caching_directives():
65+
"""`ttl_ms`/`cache_scope` default to 0/"private" so the raw dump carries them; the
66+
runner's per-version sieve drops them for pre-2026 peers."""
6667
result = ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})])
6768
frame = JSONRPCResponse(jsonrpc="2.0", id=2, result=_body(result))
6869
assert _frame(frame) == snapshot(
69-
'{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}],"resultType":"complete"}}'
70+
'{"jsonrpc":"2.0","id":2,"result":{"ttlMs":0,"cacheScope":"private","tools":[{"name":"echo","inputSchema":{"type":"object"}}],"resultType":"complete"}}'
7071
)
7172

7273

0 commit comments

Comments
 (0)