Skip to content

Commit eab740b

Browse files
committed
Restore pv header on dispatcher-written POSTs; widen auto-mode probe fallback
The streamable-HTTP transport now clears its cached MCP-Protocol-Version header when an initialize POST goes out, then lets every other POST read the cache again (re-collapsing _base_headers into _prepare_headers). This restores the header on JSON-RPC response/error/cancelled POSTs the dispatcher writes without per-message metadata, while still preventing a discover-probe value from leaking onto a fallback initialize. Client(mode='auto') now also falls back to initialize() when the probe is rejected with INVALID_REQUEST — what a deployed v1.x stateful (or stateless) streamable-HTTP server returns for a session-id-less request or an unknown protocol-version header. The lifecycle:discover requirement text is updated to match.
1 parent 91c0224 commit eab740b

5 files changed

Lines changed: 75 additions & 40 deletions

File tree

src/mcp/client/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2525
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
2626
from mcp.types import (
27+
INVALID_REQUEST,
2728
METHOD_NOT_FOUND,
2829
REQUEST_TIMEOUT,
2930
CallToolResult,
@@ -252,7 +253,9 @@ async def __aenter__(self) -> Client:
252253
try:
253254
await session.discover()
254255
except MCPError as e:
255-
if e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT):
256+
# TODO(L73): invert this allowlist into a `classify_probe_outcome` denylist —
257+
# fall back on every rpc-error/4xx that isn't a recognized modern error.
258+
if e.code in (METHOD_NOT_FOUND, INVALID_REQUEST, REQUEST_TIMEOUT):
256259
await session.initialize()
257260
else:
258261
raise

src/mcp/client/streamable_http.py

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -81,33 +81,28 @@ def __init__(self, url: str) -> None:
8181
"""
8282
self.url = url
8383
self.session_id: str | None = None
84-
# Captured from the first stamped POST's metadata; reused on transport-internal
85-
# GET/DELETE that don't carry per-message metadata.
84+
# Captured from each stamped POST's metadata. Reused on outbound HTTP that carries
85+
# no per-message header (transport-internal GET/DELETE, and dispatcher-written
86+
# response/error/cancel POSTs that bypass the session's stamp). Cleared when an
87+
# `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake.
8688
self._protocol_version_header: str | None = None
8789

88-
def _base_headers(self) -> dict[str, str]:
89-
"""Build MCP-specific request headers (accept / content-type / session-id).
90-
91-
These headers will be merged with the httpx.AsyncClient's default headers,
92-
with these MCP-specific headers taking precedence. POSTs use this directly:
93-
their protocol-version header arrives per-message via ``metadata.headers``,
94-
so they must never read the cached value.
90+
def _prepare_headers(self) -> dict[str, str]:
91+
"""Build MCP-specific request headers for any outbound HTTP request.
92+
93+
These are merged with the ``httpx.AsyncClient`` defaults (these take
94+
precedence). The cached ``MCP-Protocol-Version`` is included whenever
95+
present so messages that don't pass through the session's stamp —
96+
response/error/cancel POSTs, transport-internal GET/DELETE — still
97+
carry the negotiated version. Per-message headers are layered on top
98+
by the caller.
9599
"""
96100
headers: dict[str, str] = {
97101
"accept": "application/json, text/event-stream",
98102
"content-type": "application/json",
99103
}
100104
if self.session_id:
101105
headers[MCP_SESSION_ID] = self.session_id
102-
return headers
103-
104-
def _prepare_headers(self) -> dict[str, str]:
105-
"""Base headers plus the cached protocol-version header.
106-
107-
Used by transport-internal GET/DELETE (listen stream, resumption,
108-
reconnect, terminate) which don't carry per-message metadata.
109-
"""
110-
headers = self._base_headers()
111106
if self._protocol_version_header:
112107
headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header
113108
return headers
@@ -249,13 +244,17 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None:
249244

250245
async def _handle_post_request(self, ctx: RequestContext) -> None:
251246
"""Handle a POST request with response processing."""
252-
headers = self._base_headers()
253247
message = ctx.session_message.message
248+
is_initialization = self._is_initialization_request(message)
249+
if is_initialization:
250+
# `initialize` is the negotiation, not a "subsequent request" — discard any
251+
# probe-stamped value so the discover→fallback path can't leak it onto the handshake.
252+
self._protocol_version_header = None
253+
headers = self._prepare_headers()
254254
if ctx.metadata is not None and ctx.metadata.headers is not None:
255255
headers.update(ctx.metadata.headers)
256256
if MCP_PROTOCOL_VERSION_HEADER in ctx.metadata.headers:
257257
self._protocol_version_header = ctx.metadata.headers[MCP_PROTOCOL_VERSION_HEADER]
258-
is_initialization = self._is_initialization_request(message)
259258

260259
async with ctx.client.stream(
261260
"POST",

tests/client/test_streamable_http.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from mcp.client.streamable_http import streamable_http_client
1818
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, encode_header_value
1919
from mcp.shared.message import ClientMessageMetadata, SessionMessage
20-
from mcp.types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCRequest
20+
from mcp.types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
2121

2222

2323
@pytest.mark.parametrize(
@@ -104,19 +104,25 @@ def handler(request: httpx.Request) -> httpx.Response:
104104

105105

106106
@pytest.mark.anyio
107-
async def test_post_does_not_read_cached_protocol_version_header() -> None:
108-
"""A POST's protocol-version header comes only from its own ``metadata.headers``.
109-
110-
The first POST carries (and caches) a pv header; the second POST sends no metadata
111-
and must therefore carry no pv header — a stale cached value would poison the
112-
fallback ``initialize`` after a failed discover probe. The cache exists for
113-
transport-internal GET/DELETE only.
107+
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
108+
"""``initialize`` discards the cached protocol-version header; every other POST reads it.
109+
110+
Steps:
111+
1. A stamped probe POST caches its ``MCP-Protocol-Version`` header.
112+
2. An ``initialize`` POST clears that cache before building headers, so the fallback
113+
handshake never carries a probe-stamped value.
114+
3. A subsequent stamped POST re-seeds the cache with the negotiated version.
115+
4. An unstamped POST (a JSON-RPC response written by the dispatcher, which never
116+
passes through the session's stamp) then reads the cache and carries the
117+
negotiated version — the spec MUST for all post-initialization HTTP requests.
114118
"""
115119
recorded: list[httpx.Request] = []
116120

117121
def handler(request: httpx.Request) -> httpx.Response:
118122
recorded.append(request)
119123
body = json.loads(request.content)
124+
if "id" not in body or "result" in body:
125+
return httpx.Response(202)
120126
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
121127

122128
with anyio.fail_after(5):
@@ -133,6 +139,18 @@ def handler(request: httpx.Request) -> httpx.Response:
133139
await read.receive()
134140
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={})))
135141
await read.receive()
136-
assert [r.method for r in recorded] == ["POST", "POST"]
142+
await write.send(
143+
SessionMessage(
144+
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
145+
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
146+
)
147+
)
148+
# An unstamped JSON-RPC response — what the dispatcher writes when answering
149+
# a server-initiated request (sampling/elicitation/roots).
150+
await write.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=99, result={})))
151+
152+
assert [r.method for r in recorded] == ["POST", "POST", "POST", "POST"]
137153
assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28"
138154
assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers
155+
assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
156+
assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"

tests/interaction/_requirements.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -438,12 +438,13 @@ def __post_init__(self) -> None:
438438
"lifecycle:discover:network-error-raises": Requirement(
439439
source="sdk",
440440
behavior=(
441-
"An HTTP timeout, connection error, or non-404 4xx/5xx during server/discover raises to the "
442-
"caller without falling back to initialize."
441+
"A network/connection error or 5xx during server/discover raises to the caller without "
442+
"falling back to initialize. A 4xx with a JSON-RPC error body is a server-side rejection "
443+
"and falls back (legacy servers reject the probe with 400 INVALID_REQUEST)."
443444
),
444445
transports=("streamable-http", "streamable-http-stateless"),
445446
added_in="2026-07-28",
446-
note="HTTP-only: distinguishes transport-level failures from the -32601 fallback signal.",
447+
note="HTTP-only: distinguishes transport-level failures from server-side rejection.",
447448
),
448449
"lifecycle:mode:legacy-never-probes": Requirement(
449450
source="sdk",

tests/interaction/lowlevel/test_client_connect.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
CLIENT_CAPABILITIES_META_KEY,
3333
CLIENT_INFO_META_KEY,
3434
INTERNAL_ERROR,
35+
INVALID_REQUEST,
3536
METHOD_NOT_FOUND,
3637
PROTOCOL_VERSION_META_KEY,
3738
UNSUPPORTED_PROTOCOL_VERSION,
@@ -217,7 +218,7 @@ async def discover(ctx: ServerRequestContext, params: types.RequestParams | None
217218

218219
@requirement("lifecycle:discover:network-error-raises")
219220
async def test_auto_mode_reraises_a_non_fallback_discover_error_without_initializing() -> None:
220-
"""A `server/discover` failure outside the {-32601, -32001, -32022} ladder raises without falling back.
221+
"""A `server/discover` failure that is not a recognised legacy-server rejection raises without falling back.
221222
222223
Requirement `lifecycle:discover:network-error-raises` (sdk-defined): a 5xx-class error from
223224
the probe is surfaced to the caller; the client never sends `initialize`. Exercised here as
@@ -248,14 +249,27 @@ def is_internal_error(exc: MCPError) -> bool:
248249

249250

250251
@requirement("lifecycle:discover:fallback-method-not-found")
251-
async def test_auto_mode_falls_back_to_initialize_when_discover_is_method_not_found() -> None:
252-
"""A -32601 from `server/discover` makes an auto-negotiating client run the legacy `initialize` handshake.
252+
@pytest.mark.parametrize(
253+
("probe_code", "probe_message"),
254+
[
255+
(METHOD_NOT_FOUND, "Method not found"),
256+
(INVALID_REQUEST, "Bad Request: Missing session ID"),
257+
],
258+
ids=["method-not-found", "invalid-request"],
259+
)
260+
async def test_auto_mode_falls_back_to_initialize_on_a_legacy_probe_rejection(
261+
probe_code: int, probe_message: str
262+
) -> None:
263+
"""A legacy server's rejection of `server/discover` makes an auto-negotiating client fall back to `initialize`.
253264
254265
Requirement `lifecycle:discover:fallback-method-not-found` (spec stdio#backward-compatibility):
255266
a legacy-era server that does not implement `server/discover` is connected to via the
256-
handshake, and the session lands at a handshake-era protocol version. A real `Server` always
257-
implements `server/discover`, so this test plays the server's side of the wire by hand.
258-
Reserve this pattern for behaviour no real server can be made to produce.
267+
handshake, and the session lands at a handshake-era protocol version. The probe rejection
268+
arrives as METHOD_NOT_FOUND from a server that routes the unknown method, or as
269+
INVALID_REQUEST from a deployed v1.x stateful streamable-HTTP server that rejects the
270+
session-id-less probe before dispatch. A real `Server` always implements `server/discover`,
271+
so this test plays the server's side of the wire by hand. Reserve this pattern for behaviour
272+
no real server can be made to produce.
259273
"""
260274
methods_seen: list[str] = []
261275

@@ -267,7 +281,7 @@ async def scripted_server(streams: MessageStream) -> None:
267281
assert isinstance(frame, JSONRPCRequest | JSONRPCNotification)
268282
methods_seen.append(frame.method)
269283
if isinstance(frame, JSONRPCRequest) and frame.method == "server/discover":
270-
error = types.ErrorData(code=METHOD_NOT_FOUND, message="Method not found")
284+
error = types.ErrorData(code=probe_code, message=probe_message)
271285
await server_write.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
272286
elif isinstance(frame, JSONRPCRequest) and frame.method == "initialize":
273287
result = InitializeResult(

0 commit comments

Comments
 (0)