Skip to content

Commit 06d1492

Browse files
committed
Derive transport headers from a constructor protocol_version pin
streamablehttp_client() and StreamableHTTPTransport now take protocol_version: str | None = None, seeding the existing self.protocol_version field that _prepare_headers() already reads. _body_derived_headers (which sniffed params._meta) is replaced by _per_message_headers, gated on the pin and reading message.method directly so requests and notifications are handled uniformly. The _meta envelope is request-only per spec and stays the session's responsibility; the transport no longer treats the body as the source of truth for connection-level headers. The constructor pin also wins over the InitializeResult snoop. ClientSession: pinned sessions set cancel_on_abandon=False so the dispatcher never emits notifications/cancelled (a stateless server cannot correlate it); the envelope keys now overwrite caller-supplied _meta values rather than setdefault. For now the pin is passed to both streamablehttp_client and ClientSession; the high-level Client will collapse this to one argument. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
1 parent 92c078a commit 06d1492

5 files changed

Lines changed: 102 additions & 66 deletions

File tree

src/mcp/client/session.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,16 +231,18 @@ async def send_request(
231231
if self._pinned_version is not None:
232232
params = data.setdefault("params", {})
233233
envelope_meta = params.setdefault("_meta", {})
234-
envelope_meta.setdefault(PROTOCOL_VERSION_META_KEY, self._pinned_version)
235-
envelope_meta.setdefault(
236-
CLIENT_INFO_META_KEY,
237-
self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True),
234+
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
235+
envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump(
236+
by_alias=True, mode="json", exclude_none=True
238237
)
239-
envelope_meta.setdefault(
240-
CLIENT_CAPABILITIES_META_KEY,
241-
self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True),
238+
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
239+
by_alias=True, mode="json", exclude_none=True
242240
)
243241
opts: CallOptions = {}
242+
if self._pinned_version is not None:
243+
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
244+
# dispatcher must not emit notifications/cancelled when the caller abandons.
245+
opts["cancel_on_abandon"] = False
244246
timeout = (
245247
request_read_timeout_seconds
246248
if request_read_timeout_seconds is not None

src/mcp/client/streamable_http.py

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
INTERNAL_ERROR,
2626
INVALID_REQUEST,
2727
PARSE_ERROR,
28-
PROTOCOL_VERSION_META_KEY,
2928
ErrorData,
3029
InitializeResult,
3130
JSONRPCError,
@@ -66,24 +65,6 @@ def _encode_header_value(value: str) -> str:
6665
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
6766

6867

69-
def _body_derived_headers(message: JSONRPCMessage) -> dict[str, str]:
70-
"""Derive 2026-era headers from an envelope-bearing request body. Empty dict for legacy bodies."""
71-
if not isinstance(message, JSONRPCRequest) or message.params is None:
72-
return {}
73-
meta = message.params.get("_meta")
74-
if meta is None:
75-
return {}
76-
version = meta.get(PROTOCOL_VERSION_META_KEY)
77-
if not isinstance(version, str):
78-
return {}
79-
headers: dict[str, str] = {MCP_PROTOCOL_VERSION: version, MCP_METHOD: message.method}
80-
if message.method == "tools/call":
81-
name = message.params.get("name")
82-
if isinstance(name, str):
83-
headers[MCP_NAME] = _encode_header_value(name)
84-
return headers
85-
86-
8768
class StreamableHTTPError(Exception):
8869
"""Base exception for StreamableHTTP transport errors."""
8970

@@ -106,15 +87,39 @@ class RequestContext:
10687
class StreamableHTTPTransport:
10788
"""StreamableHTTP client transport implementation."""
10889

109-
def __init__(self, url: str) -> None:
90+
def __init__(self, url: str, protocol_version: str | None = None) -> None:
11091
"""Initialize the StreamableHTTP transport.
11192
11293
Args:
11394
url: The endpoint URL.
95+
protocol_version: Pin the MCP-Protocol-Version header from the first request
96+
instead of waiting to snoop it from an InitializeResult. Required for
97+
stateless 2026-07-28 sessions that never send initialize.
11498
"""
11599
self.url = url
116100
self.session_id: str | None = None
117-
self.protocol_version: str | None = None
101+
self.protocol_version: str | None = protocol_version
102+
103+
# TODO: header derivation from the pin (not body _meta) per spec; body envelope is request-only.
104+
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
105+
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports.
106+
107+
MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it
108+
from `self.protocol_version` for every request.
109+
"""
110+
if self.protocol_version is None or self.protocol_version < "2026-07-28":
111+
return {}
112+
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
113+
return {}
114+
headers: dict[str, str] = {MCP_METHOD: message.method}
115+
if (
116+
isinstance(message, JSONRPCRequest)
117+
and message.method == "tools/call"
118+
and message.params
119+
and isinstance(name := message.params.get("name"), str)
120+
):
121+
headers[MCP_NAME] = _encode_header_value(name)
122+
return headers
118123

119124
def _prepare_headers(self) -> dict[str, str]:
120125
"""Build MCP-specific request headers.
@@ -150,6 +155,9 @@ def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> N
150155

151156
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
152157
"""Extract protocol version from initialization response message."""
158+
if self.protocol_version is not None:
159+
# Constructor pin wins over snooping the InitializeResult.
160+
return
153161
if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch
154162
try:
155163
# Parse the result as InitializeResult for type safety
@@ -289,7 +297,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None:
289297
"""Handle a POST request with response processing."""
290298
headers = self._prepare_headers()
291299
message = ctx.session_message.message
292-
headers.update(_body_derived_headers(message))
300+
headers.update(self._per_message_headers(message))
293301
is_initialization = self._is_initialization_request(message)
294302

295303
async with ctx.client.stream(
@@ -556,6 +564,7 @@ async def streamable_http_client(
556564
*,
557565
http_client: httpx.AsyncClient | None = None,
558566
terminate_on_close: bool = True,
567+
protocol_version: str | None = None,
559568
) -> AsyncGenerator[TransportStreams, None]:
560569
"""Client transport for StreamableHTTP.
561570
@@ -565,6 +574,8 @@ async def streamable_http_client(
565574
client with recommended MCP timeouts will be created. To configure headers,
566575
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
567576
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
577+
protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions.
578+
Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`.
568579
569580
Yields:
570581
Tuple containing:
@@ -582,7 +593,7 @@ async def streamable_http_client(
582593
# Create default client with recommended MCP timeouts
583594
client = create_mcp_http_client()
584595

585-
transport = StreamableHTTPTransport(url)
596+
transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
586597

587598
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
588599

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,60 @@
11
"""Unit tests for the streamable-HTTP client transport.
22
33
The full client<->server round trip is pinned by the interaction suite under
4-
tests/interaction/transports/; these tests cover the private header-derivation helpers
5-
directly because the headers are an HTTP-seam observation the public client never exposes.
4+
tests/interaction/transports/; these tests cover the transport's per-message header
5+
derivation directly because the headers are an HTTP-seam observation the public client
6+
never exposes.
67
"""
78

89
import base64
910

1011
import pytest
1112
from inline_snapshot import snapshot
1213

13-
from mcp.client.streamable_http import _body_derived_headers, _encode_header_value
14-
from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest
15-
16-
_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"}
14+
from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value
15+
from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
1716

1817

1918
@pytest.mark.parametrize(
2019
("message", "expected"),
2120
[
2221
(
23-
JSONRPCRequest(
24-
jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}, "_meta": _ENVELOPE}
25-
),
26-
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}),
27-
),
28-
(
29-
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}),
30-
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}),
22+
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}),
23+
snapshot({"mcp-method": "tools/call", "mcp-name": "add"}),
3124
),
3225
(
33-
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/call", params={"_meta": _ENVELOPE}),
34-
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call"}),
26+
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}),
27+
snapshot({"mcp-method": "tools/list"}),
3528
),
3629
(
37-
JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}),
38-
snapshot({}),
30+
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"),
31+
snapshot({"mcp-method": "notifications/cancelled"}),
3932
),
4033
(
41-
JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
34+
JSONRPCResponse(jsonrpc="2.0", id=3, result={}),
4235
snapshot({}),
4336
),
4437
],
4538
)
46-
def test_body_derived_headers_reflect_the_envelope_on_the_request_body(
39+
def test_per_message_headers_for_pinned_transport_carry_method_and_name(
4740
message: JSONRPCMessage, expected: dict[str, str]
4841
) -> None:
49-
"""An envelope-bearing body yields the three stateless headers; a legacy body yields none.
42+
"""A 2026-07-28-pinned transport derives ``Mcp-Method`` (and ``Mcp-Name`` for tools/call) from the body.
5043
51-
Legacy bodies returning ``{}`` is what keeps the unpinned wire byte-identical to a pre-2026 client.
44+
``MCP-Protocol-Version`` is not in the per-message set: ``_prepare_headers()`` adds it from the
45+
pin for every request, so only the method/name advisory headers vary per POST. Responses yield
46+
nothing because the spec only defines the headers for requests and notifications.
5247
"""
53-
assert _body_derived_headers(message) == expected
48+
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
49+
assert transport._per_message_headers(message) == expected # pyright: ignore[reportPrivateUsage]
50+
51+
52+
@pytest.mark.parametrize("protocol_version", [None, "2025-11-25"])
53+
def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol_version: str | None) -> None:
54+
"""An unpinned or 2025-era transport emits no per-message headers, keeping the wire byte-identical to v1."""
55+
transport = StreamableHTTPTransport("http://test/mcp", protocol_version=protocol_version)
56+
message = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}})
57+
assert transport._per_message_headers(message) == {} # pyright: ignore[reportPrivateUsage]
5458

5559

5660
@pytest.mark.parametrize(
@@ -78,3 +82,19 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field
7882
assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
7983
else:
8084
assert encoded == raw
85+
86+
87+
def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
88+
"""A protocol_version passed at construction wins over the InitializeResult snoop."""
89+
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
90+
init = JSONRPCResponse(
91+
jsonrpc="2.0",
92+
id=1,
93+
result={
94+
"protocolVersion": "2025-11-25",
95+
"capabilities": {},
96+
"serverInfo": {"name": "s", "version": "0"},
97+
},
98+
)
99+
transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage]
100+
assert transport.protocol_version == "2026-07-28"

tests/interaction/transports/test_client_transport_http_modern.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""Behaviour of the streamable-HTTP client transport under the 2026-07-28 stateless protocol.
22
33
A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing
4-
request, and the streamable-HTTP transport derives the ``MCP-Protocol-Version`` / ``Mcp-Method`` /
5-
``Mcp-Name`` headers from that body. These tests pin the composition through a real ``httpx``
6-
request against a canned ``httpx.MockTransport`` -- no in-process 2026 server exists yet to record
7-
the headers against. The header-derivation helpers themselves are unit-tested in
4+
request, and a pinned streamable-HTTP transport adds the ``MCP-Protocol-Version`` / ``Mcp-Method`` /
5+
``Mcp-Name`` headers to each POST. The pin is a tracer-bullet duplication: both
6+
``streamable_http_client()`` and ``ClientSession()`` take ``protocol_version`` until the higher-level
7+
client wires them together. These tests drive the composition through a real ``httpx`` request
8+
against a canned ``httpx.MockTransport``; the per-message header derivation itself is unit-tested in
89
``tests/client/test_streamable_http.py``.
910
"""
1011

@@ -27,14 +28,13 @@
2728
@requirement("client-transport:http:body-derived-headers")
2829
@requirement("lifecycle:stateless:request-envelope")
2930
async def test_pinned_session_post_carries_body_derived_headers_on_the_wire() -> None:
30-
"""A pinned ``call_tool`` over streamable HTTP lands as a POST whose headers were derived from its body.
31+
"""A pinned ``call_tool`` over streamable HTTP lands as a POST carrying the three stateless headers.
3132
3233
Spec-mandated for the body-derived headers and the request envelope: this is the wire-seam proof
33-
that the ``ClientSession`` envelope stamp and the transport's header derivation are actually
34-
composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request. A canned
35-
``httpx.MockTransport`` stands in for the (not-yet-existing) 2026 server; the ``isError`` result
36-
skips the client's implicit ``tools/list`` output-schema fetch so the recorded log is the single
37-
POST.
34+
that the ``ClientSession`` envelope stamp and the pinned transport's header derivation are
35+
actually composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request.
36+
A canned ``httpx.MockTransport`` stands in for the server; the ``isError`` result skips the
37+
client's implicit ``tools/list`` output-schema fetch so the recorded log is the single POST.
3838
"""
3939
recorded: list[httpx.Request] = []
4040

@@ -47,7 +47,7 @@ def handler(request: httpx.Request) -> httpx.Response:
4747
with anyio.fail_after(5):
4848
async with (
4949
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
50-
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write),
50+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version="2026-07-28") as (read, write),
5151
ClientSession(
5252
read,
5353
write,
@@ -103,7 +103,7 @@ def handler(request: httpx.Request) -> httpx.Response:
103103
with anyio.fail_after(5):
104104
async with (
105105
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
106-
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write),
106+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version="2026-07-28") as (read, write),
107107
ClientSession(read, write, protocol_version="2026-07-28") as session,
108108
):
109109
await session.call_tool("add", {"a": 2, "b": 3})

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ async def on_response(response: httpx.Response) -> None:
220220
with anyio.fail_after(5):
221221
async with (
222222
mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _),
223-
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write),
223+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version=MODERN_VERSION) as (
224+
read,
225+
write,
226+
),
224227
ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session,
225228
):
226229
result = await session.call_tool("add", {"a": 2, "b": 3})

0 commit comments

Comments
 (0)