Skip to content

First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2917

Merged
maxisbey merged 1 commit into
mainfrom
v2-2026-07-28
Jun 20, 2026
Merged

First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin)#2917
maxisbey merged 1 commit into
mainfrom
v2-2026-07-28

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Staging branch for the 2026-07-28 protocol revision work. Feature PRs target this branch; this PR tracks the diff against main and will be merged once the milestone set is complete and reviewed.

Mirrors typescript-sdk's v2-2026-07-28.

Merged so far:

Part of #2891.

AI Disclaimer

@maxisbey maxisbey marked this pull request as ready for review June 20, 2026 13:54
@maxisbey maxisbey changed the title [tracking] 2026-07-28 protocol revision First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) Jun 20, 2026
@maxisbey maxisbey merged commit 84bf9bd into main Jun 20, 2026
32 checks passed
@maxisbey maxisbey deleted the v2-2026-07-28 branch June 20, 2026 13:56
Comment on lines 158 to +162
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
"""Extract protocol version from initialization response message."""
if self.protocol_version is not None:
# Constructor pin wins over snooping the InitializeResult.
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new early return in _maybe_extract_protocol_version_from_message makes a constructor protocol_version pin permanently override the negotiated version, but the pin is accepted for any version — for a pre-2026 stateful pin the initialize handshake still runs and the server may negotiate a different (older) version, yet the transport keeps sending MCP-Protocol-Version: on every subsequent request. Servers that validate that header against their supported/negotiated versions will reject those requests with 400 even though initialization succeeded. Consider gating the early return on MODERN_PROTOCOL_VERSIONS (or letting the snooped negotiated value win for stateful pins).

Extended reasoning...

What the bug is. StreamableHTTPTransport._maybe_extract_protocol_version_from_message() now returns early whenever self.protocol_version is not None ("Constructor pin wins over snooping the InitializeResult", src/mcp/client/streamable_http.py:158-162). That is correct for a 2026-07-28 pin, where no initialize is ever sent. But the new protocol_version parameter is accepted for any version string: Client.protocol_version documents itself as a general pin ("Leave as None to negotiate"), it is forwarded to streamable_http_client(protocol_version=...) in Client.__post_init__, and tests/interaction/_connect.py now passes the pin (default LATEST_PROTOCOL_VERSION) for every streamable-http connection. So a stateful (pre-2026) pin over HTTP is a supported configuration, not a hypothetical.\n\nThe code path. For a stateful pin the initialize handshake still runs, and the server is allowed to answer with a different (older) version it supports. The session layer explicitly embraces this: ClientSession.protocol_version's new docstring says the negotiated version "can differ from a stateful pin", and the new test test_initialize_on_a_stateful_pin_requests_the_pinned_version exercises exactly that downgrade (pin 2025-06-18, server negotiates 2025-03-26, the property reports the negotiated value). At the transport, however, the early return means the snoop on the InitializeResult never overwrites the pin, and _prepare_headers() keeps stamping MCP-Protocol-Version: <pin> on every subsequent POST/GET/DELETE.\n\nWhy nothing else prevents it. The only place the transport learns the negotiated version is this snoop, and the only unit test covering the "pin wins" behaviour (test_constructor_pin_is_not_overwritten_by_an_initialize_result) pins the modern version, so the stateful-downgrade case is untested. The session and the transport now disagree about the effective protocol version on the same connection.\n\nImpact. The streamable-HTTP spec (2025-06-18+) requires the client to send the negotiated protocol version in MCP-Protocol-Version on subsequent requests. Concretely: a client pinned to 2025-11-25 talking to an older server that only supports up to 2025-06-18 completes initialize (the session correctly downgrades to 2025-06-18), but every subsequent request carries MCP-Protocol-Version: 2025-11-25. This SDK's own server rejects any header value not in SUPPORTED_PROTOCOL_VERSIONS with 400 "Unsupported protocol version" (_validate_protocol_version in src/mcp/server/streamable_http.py), and other strict servers may validate against the negotiated version. Even when the value happens to be accepted, the SDK server uses the per-request header for feature gating (e.g. is_version_at_least(protocol_version, '2025-11-25') for SSE priming/resumability), so a stale pin can make the server believe the client speaks a newer revision than was negotiated.\n\nStep-by-step proof.\n1. Client('https://old-server/mcp', protocol_version='2025-11-25')streamable_http_client(..., protocol_version='2025-11-25')StreamableHTTPTransport.protocol_version = '2025-11-25'.\n2. '2025-11-25' not in MODERN_PROTOCOL_VERSIONS, so ClientSession.initialize() runs the handshake and sends protocolVersion: 2025-11-25.\n3. The older server doesn't support 2025-11-25 and answers with protocolVersion: 2025-06-18. 2025-06-18 is in the client's SUPPORTED_PROTOCOL_VERSIONS, so the session accepts it; session.protocol_version is now 2025-06-18.\n4. The transport sees the InitializeResult in _handle_json_response/_handle_sse_event with is_initialization=True and calls _maybe_extract_protocol_version_from_message — which returns immediately because self.protocol_version is '2025-11-25'.\n5. Every later request goes through _prepare_headers() and carries MCP-Protocol-Version: 2025-11-25 — a version the server never agreed to and (for an SDK server whose SUPPORTED_PROTOCOL_VERSIONS stops at 2025-06-18) will reject with 400, breaking the connection right after a successful handshake.\n\nFix. Gate the early return on the modern era, e.g. if self.protocol_version in MODERN_PROTOCOL_VERSIONS: return (the constant is already imported), or let the snooped negotiated value overwrite a stateful pin. Either keeps the 2026-07-28 behaviour (no initialize, pin must stand) while restoring the spec-required negotiated header for stateful pins.

Comment on lines +196 to +206
media_type="application/json",
)(scope, receive, send)
return

dispatcher = SingleExchangeDispatcher(request)
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893.
async with app.lifespan(app) as lifespan_state:
runner = ServerRunner(
server=app,
dispatcher=dispatcher,
lifespan_state=lifespan_state,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The 2026-07-28 entry parses every POST body with JSONRPCRequest.model_validate_json, so any syntactically-valid JSON that is not a request — most notably a JSON-RPC notification (no id), which a 2026-pinned ClientSession will still POST via send_progress_notification/send_roots_list_changed — is answered 400 with -32700 Parse error. Per JSON-RPC, -32700 is reserved for malformed JSON; a well-formed body that is not a valid request should get -32600 Invalid Request, and a notification-only POST should be accepted with 202 (as the legacy transport does). Distinguishing "JSON parse failed" from "valid JSON but not a JSONRPCRequest" (and 202-ing notification bodies) would close the gap.

Extended reasoning...

What happens. handle_modern_request (src/mcp/server/_streamable_http_modern.py:196-206) does req = JSONRPCRequest.model_validate_json(body) and maps any ValidationError to a single response: HTTP 400 with ErrorData(code=PARSE_ERROR, message="Parse error"). JSONRPCRequest requires an id and a method, so this branch catches two very different situations under one error: (a) the body is not JSON at all, and (b) the body is perfectly valid JSON but is not a request — a notification (no id), a bare {}, a response object, etc. JSON-RPC 2.0 reserves -32700 strictly for unparseable JSON; case (b) should be -32600 Invalid Request. And under the streamable-HTTP transport contract a POST whose body is only a notification should be accepted with 202 and no body — which is exactly what the legacy transport does (pinned by hosting:http:notifications-202 in tests/interaction/transports/test_hosting_http.py).\n\nHow it's reached. This isn't only a hand-rolled-curl scenario: the SDK's own 2026 pieces in this PR can produce the offending POST. StreamableHTTPTransport._per_message_headers explicitly handles JSONRPCNotification for MODERN_PROTOCOL_VERSIONS (it stamps Mcp-Method on it, and the new unit test in tests/client/test_streamable_http.py snapshots notifications/cancelled), and a stateless-pinned ClientSession still exposes send_progress_notification, send_roots_list_changed, and send_notification, all of which go through dispatcher.notify and POST a JSONRPCNotification with the MCP-Protocol-Version: 2026-07-28 header. The manager's header-only routing then sends that POST into handle_modern_request, which answers 400/-32700.\n\nStep-by-step proof.\n1. Build a session pinned to 2026: streamable_http_client(url, protocol_version="2026-07-28") + ClientSession(read, write, protocol_version="2026-07-28").\n2. Call await session.send_roots_list_changed(). The dispatcher serializes {"jsonrpc":"2.0","method":"notifications/roots/list_changed"} (no id) and the transport POSTs it with mcp-protocol-version: 2026-07-28 and mcp-method: notifications/roots/list_changed.\n3. StreamableHTTPSessionManager.handle_request sees the header in MODERN_PROTOCOL_VERSIONS and routes to handle_modern_request.\n4. JSONRPCRequest.model_validate_json(body) raises ValidationError because id is missing.\n5. The server replies 400 with {"error": {"code": -32700, "message": "Parse error"}} — a Parse error for a body that parsed fine. The client transport silently swallows the >=400 response for notifications, so the failure is also invisible to the caller.\n\nWhy existing code/tests don't cover it. The module's TODOs cover the Accept-header check, GET/DELETE rejection, and the error.code→HTTP-status mapping; the manager's TODO covers body-primary routing classification. None of them covers classifying a valid-JSON non-request body, and the only unit test for this path (test_handle_modern_request_rejects_malformed_body_with_parse_error) uses a non-JSON body, so the conflation is untested. One verifier argued this is acknowledged staged work and the trigger is implausible (no notifications/initialized, cancel_on_abandon=False suppresses notifications/cancelled, and the manifest excludes client-to-server progress at 2026). Those points reduce the blast radius — the notification would be dropped either way since SingleExchangeDispatcher has no inbound-notification path — but they don't make the wire response correct: the public send_progress_notification/send_roots_list_changed APIs remain callable on a pinned session, the conformance server-stateless baseline entry is about unimplemented features, not about emitting the wrong reserved error code, and no TODO at this code site marks body classification as deferred. The wrong code is visible to any non-SDK 2026 client that legitimately POSTs a notification.\n\nSuggested fix. Split the failure modes: first json.loads (or pydantic_core.from_json) the body and answer -32700 only when that fails; then try JSONRPCRequest and, on failure, check for a notification shape (method present, no id) and answer 202 with no body, falling back to -32600 Invalid Request for everything else. That matches the legacy transport's behaviour and the JSON-RPC error-code semantics, and it is a small, self-contained change to this new module.

Comment thread src/mcp/client/client.py
Comment on lines 112 to 118
if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server)
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
else:
self._transport = self.server

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pinning the new Client.protocol_version to 2026-07-28 with an in-memory Server/MCPServer instance silently produces a broken connection: only the URL branch threads the pin into the transport, so the server runs the legacy stateful path (initialize gate) while the pinned session never sends initialize, and every subsequent request fails with a cryptic INVALID_PARAMS 'Invalid request parameters'. Consider raising a clear ValueError (or adding a docstring caveat) when protocol_version is in MODERN_PROTOCOL_VERSIONS and the server is an in-memory instance, until the in-memory modern entry point lands.

Extended reasoning...

What happens. Client.__post_init__ only threads protocol_version into the transport on the URL branch (streamable_http_client(self.server, protocol_version=...)). When server is a Server/MCPServer instance, it builds a plain InMemoryTransport with no awareness of the pin (src/mcp/client/client.py:112-118), while the pin is still passed to ClientSession (line 138). A 2026-07-28-pinned ClientSession is "born initialized": initialize() returns the locally synthesized result without sending anything on the wire, so Client.__aenter__ succeeds and the connection looks healthy.

Why every request then fails. InMemoryTransport._connect runs actual_server.run(...) on the legacy stateful path (no stateless=True), so ServerRunner's init gate is active: if not self.connection.initialize_accepted and method not in _INIT_EXEMPT: raise MCPError(INVALID_PARAMS, 'Invalid request parameters'). The pinned session never sends initialize or notifications/initialized, so initialize_accepted never becomes true. The per-request _meta envelope doesn't help either: _resolve_protocol_version only honours the _meta version if it is in SUPPORTED_PROTOCOL_VERSIONS, which does not include 2026-07-28, so it falls back to 2025-11-25 and the gate still fires.

Step-by-step proof.

  1. async with Client(my_mcpserver, protocol_version=\"2026-07-28\") as client:__post_init__ builds InMemoryTransport (no pin), __aenter__ calls session.initialize(), which returns the synthesized result without touching the wire. The context manager enters successfully.
  2. await client.call_tool(\"add\", {\"a\": 1, \"b\": 2}) — the request reaches ServerRunner._on_request with connection.initialize_accepted still false (no handshake ever ran, and stateless is False so connection.initialized was never pre-set).
  3. The init gate raises MCPError(INVALID_PARAMS, \"Invalid request parameters\") — the user sees an error that suggests their arguments are wrong, with no hint that the version pin is the cause.

Why nothing prevents it. The new Client.protocol_version docstring explicitly invites pinning to 2026-07-28 ('selects the stateless transport era: no initialize handshake is sent') and frames HTTP behavior as an additional detail, not a precondition. The only acknowledgement of the gap is buried in test infrastructure: tests/interaction/_requirements.py locks the in-memory transport to 2025-11-25 with the comment 'the in-memory transport has no modern entry point yet'. The public API neither documents nor rejects the combination, so a user following the docstring with the in-memory server (the first usage shown in the Client class example) hits the cryptic failure.

Impact and fix. This is a missing-guard/diagnostics gap rather than silent corruption — the failure is loud, just misleading. Since the in-memory modern factory is explicitly slated to land later in this milestone, the lightweight fix is either (a) raise a clear ValueError in __post_init__ when protocol_version in MODERN_PROTOCOL_VERSIONS and the server is a Server/MCPServer instance ('the in-memory transport does not support 2026-07-28 yet'), or (b) add a caveat to the protocol_version docstring stating that modern pins currently only work with URL/HTTP servers. Either removes the trap until the in-memory modern path lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants