Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green#2950
Conversation
…nsolidate header constant - HANDSHAKE_PROTOCOL_VERSIONS names what the constant actually holds (versions reachable via the initialize handshake); SUPPORTED_PROTOCOL_VERSIONS survives as a deprecated union of HANDSHAKE + MODERN for v1.x compatibility - The three handshake-ceiling call sites (initialize offer, server negotiate fallback, for_loop seed) now read HANDSHAKE_PROTOCOL_VERSIONS[-1] instead of LATEST_PROTOCOL_VERSION - Era-routing in the streamable-HTTP manager reads HANDSHAKE_PROTOCOL_VERSIONS (interim; the body-primary classifier is the structural fix) - mcp-protocol-version header constant: three duplicate definitions collapsed to the single MCP_PROTOCOL_VERSION_HEADER in shared/inbound; client and server importers point at the canonical module - migration.md documents the SUPPORTED deprecation
…metadata sidecar Additive infrastructure for the client-side outbound stamp: - CallOptions gains a headers key; ClientMessageMetadata gains a headers field - _plan_outbound projects opts['headers'] onto the metadata (same path resumption tokens take); JSONRPCDispatcher.notify accepts opts and threads headers through - Outbound.notify Protocol grows opts=None; all implementers updated (Connection, _NoChannelOutbound, _SingleExchangeDispatchContext, peer, context, DirectDispatcher, test stubs) - StreamableHTTPTransport's POST path merges metadata.headers into the request (alongside existing _prepare_headers/_per_message_headers, which are removed in the next commit) - MCP_METHOD_HEADER, MCP_NAME_HEADER, encode_header_value moved to shared/inbound (single source for the header names) - Tests pin both new paths
…ecomes pv-agnostic The era difference is now which stamp closure was installed, not a flag send_request reads: - Three stamp builders: _preconnect_stamp (cancel-suppressed only), _make_handshake_stamp (pv header), _make_modern_stamp (_meta envelope + cancel-suppressed + pv/method/name headers) - ClientSession.adopt(InitializeResult | DiscoverResult) installs negotiated state without wire traffic; .initialize() now calls .adopt(result) so the handshake stamp is installed before notifications/initialized goes out - send_request and send_notification call self._stamp(data, opts) unconditionally — _stateless_pinned, _pinned_version, and the inline envelope branch are deleted - ClientSession(protocol_version=) and Client.protocol_version removed - StreamableHTTPTransport drops protocol_version, _per_message_headers, _maybe_extract_protocol_version_from_message; _prepare_headers no longer derives the pv header. The transport caches the pv header from the first stamped POST's metadata and reuses it on transport-internal GET/DELETE - streamable_http_client(protocol_version=) removed - Interaction-suite [streamable-http-2026-07-28] arm now drives via ClientSession + .adopt(DiscoverResult); pagination/cancellation tests adapted to the Client|ClientSession common subset - migration.md documents the removals
…on-pin) - mode='legacy' (default) performs the initialize handshake; a version string (e.g. '2026-07-28') adopts that version directly via .adopt() - prior_discover= reuses a known DiscoverResult; omitting it synthesizes a minimal one - 'auto' (server/discover probe with fallback) follows once .discover() lands - Interaction-suite connect fixture passes mode= for the modern arm and yields Client for all arms again; the W1b-era ClientSession adapter and type suppression are removed
- discover() probes server/discover via the dispatcher (bypassing the stamp), validates the response as DiscoverResult before reading any field, then .adopt()s it - Error ladder: -32022 retries once with the intersection of MODERN and data.supported (re-raises if empty or on second failure); -32601 and REQUEST_TIMEOUT fall back to .initialize(); anything else propagates - Idempotent (mirrors .initialize()) - Client.mode gains 'auto' which calls .discover() in __aenter__ - 9 unit tests cover each ladder rung, idempotency, malformed -32022 data, and the response-validation gate; 1 end-to-end test drives mode='auto' over the in-process ASGI bridge
…spatcher peer-pair - modern_on_request(server, lifespan_state) returns an OnRequest callback that builds Connection.from_envelope per call and drives serve_one — wire it into the server side of a DirectDispatcher peer-pair for an in-process server on the modern per-request path - Client(Server|MCPServer, mode!=legacy) enters lifespan once, creates a peer-pair, runs the server side with modern_on_request, and hands the client side to ClientSession; legacy in-process keeps InMemoryTransport - Interaction-suite in-memory transport unlocked for 2026-07-28: 71 tests now run on [in-memory-2026-07-28], 67 pass; the 5 streamable-http-only notify-drop xfails are scoped to that transport; 4 progress-notification tests still xfail (peer-pair progress wiring tracked separately)
…ATEST_PROTOCOL_VERSION; orphan cleanup - Context.report_progress now delegates to DispatchContext.progress() via ServerSession.report_progress (was: token-gated send_notification, which only worked under JSONRPCDispatcher). Progress now reaches the client on the in-process modern path; 4 progress-notification xfails flip to pass. ServerSession's request_outbound is typed DispatchContext (it always was one at runtime). - LATEST_PROTOCOL_VERSION bumped to '2026-07-28' (the newest revision the SDK supports). Handshake-outcome assertions and mock-InitializeResult fixtures switched to HANDSHAKE_PROTOCOL_VERSIONS[-1]. migration.md entry. - ServerMessageMetadata.protocol_version deleted (no readers, no writers). - ClientSession.send_progress_notification and Client.send_progress_notification deprecated (client-to-server progress is server-to-client only at 2026-07-28). - Mcp-Name TODO re-anchored on _make_modern_stamp.
…ion tests - 9 new requirement IDs in the Lifecycle section covering the per-request envelope, server/discover behaviour, and Client mode= policy - 10 interaction tests in tests/interaction/lowlevel/test_client_connect.py driving each via Client(server, mode=...) over in-memory and in-process ASGI
- client.py reads MCP_CONFORMANCE_PROTOCOL_VERSION and passes mode='auto' (modern) or 'legacy' (handshake-era) to the high-level Client; auth flows wrap the OAuth-authed httpx client in streamable_http_client and hand that as a Transport - New fixture handlers for request-metadata and http-standard-headers - json-schema-ref-no-deref pinned to legacy (its mock only speaks the handshake-era lifecycle; the check is lifecycle-agnostic) - Baselines: request-metadata + auth/authorization-server-migration removed from expected-failures.yml; tools_call + auth/scope-step-up + auth/scope-retry-limit + the two above removed from expected-failures.2026-07-28.yml. http-custom-headers / http-invalid-tool-headers (Mcp-Param-* headers) and sep-2322-client-request-state (multi-round-trip) stay waived.
… cached pv header. serve_one reshape + raise_exceptions - Bare HTTP 404 before a session is established now maps to METHOD_NOT_FOUND (was INVALID_REQUEST/"Session terminated", which is meaningless pre-session); with a session_id, 404 keeps the session-terminated mapping - _prepare_headers split: _base_headers (POST) vs _prepare_headers (GET/DELETE). POSTs never read the cached MCP-Protocol-Version header — they get it via per-message metadata only. Prevents the discover probe's header from leaking onto a fallback initialize POST. - serve_one reshaped to (server, dctx, method, params, *, ..., raise_exceptions); modern_on_request drops the JSONRPCRequest round-trip and threads raise_exceptions through to to_jsonrpc_response(raise_unhandled=). Client's modern in-process branch now honors raise_exceptions (handler exceptions chain via __cause__ instead of being sanitized to INTERNAL_ERROR).
…ra-neutral accessors - ClientSession.discover() -> DiscoverResult: no fallback (METHOD_NOT_FOUND/ REQUEST_TIMEOUT propagate; Client owns that policy), no InitializeResult synthesis. Separate _discover_result/_initialize_result/_negotiated_version slots. .adopt() sets the matching slot; no more synthesis. - Era-neutral properties on ClientSession and Client: .server_info, .server_capabilities, .instructions, .protocol_version read from whichever result is set. ClientSession.discover_result for prior_discover round-trip. - Client.__aenter__: mode='auto' wraps discover() with the fallback ladder (METHOD_NOT_FOUND | REQUEST_TIMEOUT -> initialize()). _build_session helper consolidates the dispatcher/transport branching to one ClientSession() site. - Client.initialize_result removed (use the era-neutral accessors). - mode= validated in __post_init__: ValueError on unknown values, with a redirect hint for handshake-era versions. - adopt()/discover() docstrings gain Raises: sections.
…s() preference - StreamableHTTPTransport.protocol_version section: attribute-only (the constructor param was v2-only churn, never on v1.x) - Delete ClientSession(protocol_version=) section (param never on v1.x) - Fix v1 surface reference: ClientSession.get_server_capabilities() (Client class did not exist in v1) - New section on handler progress reporting: ctx.report_progress() is dispatcher-agnostic; reading meta['progress_token'] + send_progress_notification is JSONRPC-specific and won't work on the in-process modern path - test_client_connect.py: pytest.fail -> raise NotImplementedError
shared/version.py gains four derived constants alongside the existing tuples: LATEST_PROTOCOL_VERSION (now derived here instead of a duplicate literal in types/_types.py), LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, and OLDEST_SUPPORTED_VERSION. Call sites that previously wrote HANDSHAKE_PROTOCOL_VERSIONS[-1] / MODERN_PROTOCOL_VERSIONS[-1] / [0] now import the named scalar so the meaning is explicit at the use site and a future version bump is one edit. This also fixes a quiet drift: a handful of tests were passing LATEST_PROTOCOL_VERSION (now "2026-07-28") into InitializeRequest on the legacy handshake path; those now use LATEST_HANDSHAKE_VERSION.
serve_one now returns the kernel's dict result and lets exceptions propagate; the modern HTTP entry composes to_jsonrpc_response around it directly, and modern_on_request no longer round-trips through JSONRPCError on the in-process path. The dctx.request_id assert drops. Client: the protocol_version/server_info/server_capabilities accessors now raise the same RuntimeError as .session instead of bare assert. __aenter__ publishes self._session only after the handshake succeeds, and a separate _entered flag makes the one-shot re-entry guard explicit. _drop_notify is renamed to say which direction it sinks. Adds a TODO at mode='legacy' for the eventual default flip and a TODO above the accessors for the connected-view shape. migration.md: point at the era-neutral session/client accessors instead of initialize_result, and stop listing client.instructions as non-nullable. The legacy-mode connect test now passes mode='legacy' explicitly so it asserts what that mode does, not what the default is.
…version-constant aliases runner.py is now JSON-RPC-wire-model-free: to_jsonrpc_response moves into _streamable_http_modern.py (its only caller) as a private helper, and the unused raise_unhandled parameter is dropped. types/__init__.py imports LATEST_PROTOCOL_VERSION directly from shared.version instead of bouncing through _types.py. migration.md: the era-neutral-accessors section now says "at most one" of initialize_result/discover_result is non-None (both are None before any handshake on the lowlevel session) and names which slot a 2025 vs 2026 server fills; notes that lowlevel ClientSession still lets you call methods before any handshake, as in v1. Test files drop the per-file MODERN/_MODERN/MODERN_VERSION aliases in favour of LATEST_MODERN_VERSION directly.
…sure __post_init__ now resolves a single _connect closure from the shape of the server argument alone (in-process vs URL vs Transport instance). mode and raise_exceptions are passed to the closure at enter time so they're read at the same moment __aenter__ reads them for the handshake step. _build_session collapses to one line of logic; the mutually-exclusive Optional fields and the assert that guarded them are gone. JSONRPCDispatcher.on_stream_exception is now public-mutable so ClientSession can install its message_handler routing after the dispatcher is built; the install only happens when no caller-supplied hook is already set. ClientSession.adopt() now clears the opposite result slot so at most one of initialize_result/discover_result is non-None by construction.
| StreamReader = ContextReceiveStream[SessionMessage] | ||
|
|
||
| MCP_SESSION_ID = "mcp-session-id" | ||
| MCP_PROTOCOL_VERSION = "mcp-protocol-version" | ||
| MCP_METHOD = "mcp-method" | ||
| MCP_NAME = "mcp-name" | ||
| LAST_EVENT_ID = "last-event-id" | ||
|
|
||
| # Reconnection defaults | ||
| DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry |
There was a problem hiding this comment.
🟡 This PR removes the module-level MCP_PROTOCOL_VERSION = 'mcp-protocol-version' constant from mcp.client.streamable_http (it now lives as MCP_PROTOCOL_VERSION_HEADER in mcp.shared.inbound), but unlike the other v1→v2 breaks in this PR the removal isn't covered in docs/migration.md and no deprecated alias is left, so external code importing it from the old location gets an ImportError. The constant exists on v1.x and was imported cross-module by the SDK's own auth code until this PR, so a one-line migration.md note (or a deprecated re-export) for MCP_PROTOCOL_VERSION would close the gap; MCP_METHOD/MCP_NAME were never on v1.x and need no note.
Extended reasoning...
What changed
The diff for src/mcp/client/streamable_http.py deletes the module-level constants MCP_PROTOCOL_VERSION = 'mcp-protocol-version', MCP_METHOD, and MCP_NAME. Their replacements (MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER) live in mcp.shared.inbound, and no alias is left behind in the old module. Of these, only MCP_PROTOCOL_VERSION is a v1→v2 break: it is defined at module level in the v1.x streamable_http.py (alongside MCP_SESSION_ID and LAST_EVENT_ID, which both survive this PR), whereas MCP_METHOD/MCP_NAME were added on main during the v2 cycle and never shipped on v1.x — so per the stated migration.md scope (v1→v2 only), only the protocol-version constant needs documenting.
Why this is plausibly user-visible
The SDK's own code treated this as a cross-module symbol until this very PR: src/mcp/client/auth/oauth2.py and src/mcp/client/auth/utils.py both did from mcp.client.streamable_http import MCP_PROTOCOL_VERSION and are repointed to mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER in this diff. External code that sets or reads the MCP-Protocol-Version header manually (custom auth flows, middleware, raw httpx wrappers) plausibly imports it from the same place. After this PR that import raises ImportError with no migration breadcrumb.
Step-by-step
- A v1.x user has
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION(the same import the SDK's auth code used). - They upgrade to v2 with this PR merged. The name no longer exists in
mcp.client.streamable_http—ImportError: cannot import name 'MCP_PROTOCOL_VERSION'. - They check
docs/migration.md. The new sections added by this PR cover theStreamableHTTPTransport.protocol_versionattribute removal and theSUPPORTED_PROTOCOL_VERSIONS/LATEST_PROTOCOL_VERSIONchanges, but say nothing about the header-name constant or where it moved (mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER).
Why existing docs/code don't cover it
AGENTS.md requires breaking changes — including ones softened by a shim — to be documented in docs/migration.md, and this PR already follows that rule for the closely-related StreamableHTTPTransport.protocol_version attribute (documented precisely because it existed on v1.x). The same criterion applies to this constant, but the entry was not added, and there is no deprecated alias to soften the break either.
Impact and fix
Impact is small: the constant is a plain header-name string that is trivially reconstructable, it isn't exported via mcp.__init__, and most users never import it directly — hence nit, not blocking. The fix is a one-line addition to the migration guide (e.g. under the streamable-HTTP section: "MCP_PROTOCOL_VERSION constant moved — import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.inbound instead"), or alternatively a deprecated re-export alias in mcp.client.streamable_http. MCP_METHOD/MCP_NAME need neither, since they never existed on v1.x.
…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.
| def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None: | ||
| # Only initialize/discover go out before connect; both forbid cancellation. | ||
| opts["cancel_on_abandon"] = False |
There was a problem hiding this comment.
🟡 Every request stamped before initialize()/discover()/adopt() now gets cancel_on_abandon=False from _preconnect_stamp, so a lowlevel ClientSession user who skips the handshake (a pattern this PR's migration.md explicitly preserves) silently loses the courtesy notifications/cancelled when a request times out or is abandoned — pre-PR only initialize was suppressed. Consider gating the suppression on the method (initialize/server/discover) inside _preconnect_stamp instead of suppressing unconditionally.
Extended reasoning...
What the bug is
ClientSession.__init__ installs _preconnect_stamp as the default outbound stamp (src/mcp/client/session.py:54-56), and that closure unconditionally sets opts['cancel_on_abandon'] = False for every request and notification stamped before one of initialize(), discover(), or adopt() swaps it out. The comment in the closure — "Only initialize/discover go out before connect; both forbid cancellation" — is true for the high-level Client (which always handshakes in __aenter__), but it is not true for the lowlevel ClientSession, which still allows feature methods to be called without any handshake. This PR's own docs/migration.md addition says so explicitly: "(The lowlevel ClientSession still lets you call methods before any handshake, as in v1; …)", and the author re-affirms the skip-init path "works exactly as in v1" in the review thread (the 2025 unofficial-stateless-server pattern).
The code path
In jsonrpc_dispatcher.py, _plan_outbound reads cancel_on_abandon and gates the courtesy notifications/cancelled on both abandonment paths — when the per-request timeout fires and when the caller's scope is cancelled while the request is in flight. Pre-PR, send_request only set cancel_on_abandon=False for method == 'initialize' (and the now-removed stateless pin), so any other request abandoned by timeout or caller cancellation emitted the spec-recommended courtesy cancel. Post-PR, the suppression applies to every pre-handshake request, because the stamp is method-blind.
Step-by-step proof
- A lowlevel user constructs
ClientSession(read, write)against a 2025-era server that doesn't require the handshake (the documented skip-init pattern) and never callsinitialize()/discover()/adopt().self._stampremains_preconnect_stamp. - They call
session.call_tool('slow_tool', {...}, read_timeout_seconds=5).send_requestbuildsopts = {}and callsself._stamp(data, opts), which setsopts['cancel_on_abandon'] = False. - The tool takes longer than 5 s. The dispatcher's timeout path consults
plan.cancel_on_abandon, finds itFalse, and does not sendnotifications/cancelled. - On main (pre-PR), the same sequence sends
notifications/cancelled, letting the server stop the abandoned work. The dispatcher's own docstring notes that a request that is neither resumable nor cancelled "would leak the peer's work" — which is exactly what now happens.
Why nothing else prevents it
The stamp is only replaced by the three connect-time entry points; a session that never connects keeps _preconnect_stamp for its whole lifetime. No other layer re-enables cancel_on_abandon, and no test covers an abandoned pre-handshake feature request, so the regression is silent.
Impact and fix
The impact is narrow: it requires the lowlevel skip-handshake pattern and an abandoned/timed-out request, the dropped notification is a best-effort courtesy (a SHOULD, not a MUST), and the client-side outcome is unchanged — hence a nit rather than a blocker. But it is an unintended wire-behavior change for a path the PR documents as unchanged from v1. The fix is one line: gate the suppression on the method inside _preconnect_stamp, e.g.
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
# initialize/discover forbid cancellation; other pre-handshake requests keep the courtesy cancel.
if data["method"] in ("initialize", "server/discover"):
opts["cancel_on_abandon"] = FalseThe mode='auto' connect path now goes through client/_probe.py's negotiate_auto, which inverts the previous allowlist into a denylist: every MCPError from the server/discover probe falls back to initialize(), the sole exception being -32022 with a disjoint modern-only supported list. An unparseable probe result also falls back. Any non-MCPError exception (network/connection errors, anyio resource errors) propagates — an outage is never an era verdict. ClientSession gains send_discover(version) (the raw probe, no retry, no adopt), and discover() is reimplemented on top of it. The __aenter__ mode='auto' arm collapses to a single negotiate_auto call. tests/client/test_probe.py covers the verdict table directly; the interaction-suite fallback test broadens to a parametrized rpc-error set, and the previous "INTERNAL_ERROR raises" assertion is replaced with a network-error case (under the denylist, INTERNAL_ERROR now falls back).
|
|
||
| No retry, no ``adopt()``. The ``_meta`` envelope and the | ||
| ``Mcp-Protocol-Version`` header are stamped at ``version`` so the | ||
| server-side era router sees a coherent probe. Used by ``discover()`` and | ||
| the connect-time auto-negotiation policy. | ||
|
|
||
| Raises: | ||
| MCPError: The server returned a JSON-RPC error. | ||
| ProbeNotRecognized: The transport bounced the request at its own | ||
| layer (HTTP 4xx without a JSON-RPC error body). | ||
| """ | ||
| client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) | ||
| capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True) |
There was a problem hiding this comment.
🟡 The new send_discover() docstring's Raises section lists ProbeNotRecognized, but no exception class with that name exists anywhere in the codebase — a caller writing except ProbeNotRecognized would get a NameError. In reality a bare HTTP 4xx is synthesized into a JSONRPCError by the streamable-HTTP transport (e.g. the new pre-session 404 → METHOD_NOT_FOUND mapping) and surfaces as MCPError, which is already covered by the first Raises entry; drop this entry or describe the actual MCPError code used for transport-level rejections.
Extended reasoning...
What the bug is
The Raises: section of the new ClientSession.send_discover() docstring (src/mcp/client/session.py:404-407) documents two exception types:
Raises:
MCPError: The server returned a JSON-RPC error.
ProbeNotRecognized: The transport bounced the request at its own
layer (HTTP 4xx without a JSON-RPC error body).
ProbeNotRecognized does not exist. A repo-wide grep finds exactly one occurrence of the name — this docstring line. There is no class definition, import, export, or raise of any such exception in src/ or tests/; mcp.shared.exceptions defines only MCPError, NoBackChannelError, and UrlElicitationRequiredError.
What actually happens on a transport-level 4xx
When the server/discover probe is bounced at the HTTP layer without a JSON-RPC error body, the streamable-HTTP transport synthesizes a JSONRPCError itself: _handle_post_request in src/mcp/client/streamable_http.py maps a pre-session bare 404 to METHOD_NOT_FOUND (the new mapping added in this PR) and other non-2xx statuses to INTERNAL_ERROR. That synthesized error flows through JSONRPCDispatcher, which raises it to the caller as MCPError. So the second Raises entry is both fictitious (the class doesn't exist) and redundant (the behavior it describes is already covered by the first entry, MCPError).
Step-by-step proof
- A caller follows the docstring and writes:
try: raw = await session.send_discover("2026-07-28") except ProbeNotRecognized: ...
- There is nothing to import —
from mcp.shared.exceptions import ProbeNotRecognizedraisesImportError, and an unqualified reference raisesNameError: name 'ProbeNotRecognized' is not definedthe first time theexceptclause is evaluated. - Meanwhile, the actual transport-level 4xx case (e.g. a gateway 404 with no JSON-RPC body) arrives at the caller as
MCPError(METHOD_NOT_FOUND), which the caller's handler — written per the docstring — does not catch as intended.
Why this matters / impact
This is a documentation-only defect introduced by this PR (the docstring is new in this diff and survived the follow-up commits through HEAD a7d1275). It has no runtime impact on the SDK itself, but send_discover() is a public method on ClientSession, and the project's convention (per the review thread, Raises sections were added at the reviewer's request) is that public-method docstrings list the exceptions a caller would reasonably catch — documenting a class that doesn't exist actively misleads.
How to fix
Either drop the second Raises entry (the first MCPError line already covers all failure modes), or fold the transport-level detail into it, e.g.:
Raises:
MCPError: The server returned a JSON-RPC error, or the transport bounced
the request at its own layer (a bare HTTP 4xx is synthesized into a
JSON-RPC error — pre-session 404 maps to METHOD_NOT_FOUND).
The default Client(...) now probes server/discover and falls back to initialize() (via negotiate_auto's denylist). For an in-process Server, the default path is now DirectDispatcher per-request rather than the legacy InMemoryTransport stream loop. DirectDispatcher gains raise_handler_exceptions (matching JSONRPCDispatcher's knob): True chains the original exception via __cause__; False sanitizes to MCPError(INTERNAL_ERROR). modern_on_request collapses to a pure envelope-builder (no exception ladder of its own), and Client threads raise_exceptions into create_direct_dispatcher_pair. Tests that exercise legacy-specific semantics — server-initiated sampling/elicitation push, message_handler delivery, ping, InMemoryTransport mechanics, JSON-RPC wire-shape recording — are pinned to mode='legacy' explicitly (~64 sites across 26 test files plus the client_via_http / connect_over_sse / auth-harness helpers). These are census-driven, not failure-driven: ~23 sites would have passed under 'auto' but silently stopped testing their subject. Client.send_ping() is deprecated (ping is removed from 2026-07-28); it only works under mode='legacy'. docs/migration.md gains a section explaining the default change and when to pin mode='legacy'; docs/testing.md notes the same for test authors.
A handler-side exception (even one caught and converted to a result, as MCPServer's tool wrapper does) desyncs CPython 3.11's CTracer when the DirectDispatcher request path resumes the awaiting coroutine chain via throw — the same python/cpython#106749 the other transport seams already work around. The resync_tracer() checkpoint at the end of _dispatch_request restores tracing for the caller's subsequent lines.
| else: | ||
| raise | ||
| else: | ||
| session.adopt(self.prior_discover or _synthesize_discover(self.mode)) |
There was a problem hiding this comment.
why do we need to synthesize discover? we should probably just handle the case properly.
like there's two distinct states:
- calling a tool without first calling discover
- first calling discover and then calling tool
but if we synthesize discover then the ClientSession will think it has called discover yet it hasn't, need to think about the implications to client represation here
| "cancel_on_abandon": False, | ||
| "headers": {MCP_PROTOCOL_VERSION_HEADER: version}, | ||
| } | ||
| return await self._dispatcher.send_raw_request("server/discover", params, opts) |
There was a problem hiding this comment.
why not use the actual types here?
…l-suppression by method - send_discover: construct params through types.DiscoverRequest/RequestParams instead of a hand-rolled dict (same wire shape; routing stays raw because the probe's _meta version is per-call, not session state). - _preconnect_stamp: only suppress cancel_on_abandon for initialize and server/discover, so lowlevel ClientSession callers that skip the handshake keep the courtesy cancel on timed-out/abandoned requests. - send_discover docstring: drop the nonexistent ProbeNotRecognized entry; fold the transport-4xx case into the MCPError Raises line. - migration.md: note the MCP_PROTOCOL_VERSION header constant move to mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER.
| return self.transport.can_send_request and not self._closed | ||
|
|
||
| async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: | ||
| async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: |
Client-side support for the 2026-07-28 per-request-envelope path:
ClientSessiongains.discover()and.adopt()alongside.initialize();Clientgainsmode='legacy'|'auto'|<version-pin>andprior_discover=. Removesrequest-metadataandauth/authorization-server-migrationfrom the conformance baseline, plus the carried-forwardtools_call/auth/scope-step-up/auth/scope-retry-limitentries from the 2026-07-28 baseline.Part of #2891. Touches #2894, #2892, #2900.
Motivation and Context
#2928 landed the server side of the 2026-07-28 era split. This PR is the client side: the era difference becomes which outbound-stamping closure was installed at connect time, not a flag the send path reads.
ClientSessionpreviously branched on a_stateless_pinnedflag insidesend_requestand held the protocol version in four places (session pin, init result, transport, OAuth context); the transport sniffedInitializeResultresponses to learn the version for header setting.What changed
ClientSession— three connect-time entry points install a stamp closure..initialize()(existing) terminates by calling.adopt(result)..adopt(InitializeResult | DiscoverResult)installs negotiated state without wire traffic. ADiscoverResultselects the newest mutually-supported modern version and installs the modern stamp; anInitializeResultinstalls the handshake stamp..discover()probesserver/discover, validates the response withDiscoverResult.model_validatebefore reading any field, and.adopt()s on success. On-32022it retries once with the intersection ofMODERN_PROTOCOL_VERSIONSanddata.supported; on-32601or request timeout it falls back to.initialize(); anything else propagates.send_requestandsend_notificationcallself._stamp(data, opts)unconditionally — no era branch in the body. The_stateless_pinnedflag,_pinned_versionslot, and theClientSession(protocol_version=)constructor kwarg are removed.Client— policy layer. Newmode: Literal['legacy','auto'] | str = 'legacy'andprior_discover: DiscoverResult | None = None.Client.__aenter__builds the session, then:'legacy'→.initialize();'auto'→.discover(); a version string →.adopt(prior_discover or synthesize(pv)).Client(protocol_version=)is removed.Transport pv-agnostic.
StreamableHTTPTransportno longer holdsprotocol_version, no longer derivesMcp-Method/Mcp-Nameheaders, and no longer sniffsInitializeResultresponses. Per-message headers arrive viaCallOptions['headers']→ClientMessageMetadata.headers→ merged at the POST. The transport cachesMCP-Protocol-Versionfrom the first stamped POST for transport-internal GET/DELETE/reconnect (per-connection state, same pattern assession_id).In-process modern path. New
modern_on_request(server, lifespan_state)driver inrunner.pyreturns anOnRequestcallback that buildsConnection.from_envelopeper call and drivesserve_one.Client(Server | MCPServer, mode != 'legacy')enters the lifespan once, creates aDirectDispatcherpeer-pair, and runs the server side with this callback. The interaction suite's in-memory transport is unlocked for 2026-07-28 (71 tests now run on that arm).Version constants.
SUPPORTED_PROTOCOL_VERSIONSrenamed toHANDSHAKE_PROTOCOL_VERSIONS(the versions reachable via the initialize handshake); the old name survives as a deprecated union.LATEST_PROTOCOL_VERSIONbumped to"2026-07-28". The three duplicatemcp-protocol-versionheader constant definitions collapsed to one inshared/inbound.report_progressroutes throughDispatchContext.progress().Context.report_progresswas gating on a JSONRPC-specific_meta.progressTokenand reimplementing the notification path; it now delegates toServerSession.report_progress→dctx.progress(), so progress reaches the client on the in-process modern path too.Conformance fixture.
.github/actions/conformance/client.pyreadsMCP_CONFORMANCE_PROTOCOL_VERSIONand drivesClient(mode='auto')for the modern leg,'legacy'otherwise. New handlers forrequest-metadataandhttp-standard-headers.How Has This Been Tested?
request-metadata7/7,auth/authorization-server-migration27/27,http-standard-headers3/3 on both legs;tools_call/auth/scope-step-up/auth/scope-retry-limitpass on the 2026-07-28 leg./scripts/test: 100% branch coverage,strict-no-coverclean.discover()ladder rung; 10 interaction tests intest_client_connect.pycover themode=policy and envelope stamping end to endBreaking Changes
All documented in
docs/migration.md:ClientSession(protocol_version=)removed → use.adopt()after constructionClient(protocol_version=)removed → usemode=StreamableHTTPTransport.protocol_versionandstreamable_http_client(protocol_version=)removedSUPPORTED_PROTOCOL_VERSIONSdeprecated → useHANDSHAKE_PROTOCOL_VERSIONSorMODERN_PROTOCOL_VERSIONSLATEST_PROTOCOL_VERSIONvalue changed"2025-11-25"→"2026-07-28"; code that meant "the version.initialize()offers" should switch toHANDSHAKE_PROTOCOL_VERSIONS[-1]Client.send_progress_notification/ClientSession.send_progress_notificationdeprecated (client-to-server progress is server-to-client only at 2026-07-28)Outbound.notifyProtocol grew anopts: CallOptions | None = NoneparameterServerMessageMetadata.protocol_versionremoved (no readers)Types of changes
Checklist
Additional context
The three stamp closures:
_preconnect_stamp(cancel-suppressed only — onlyinitialize/discovergo out before connect, both forbid cancel),_make_handshake_stamp(pv)(sets theMCP-Protocol-Versionheader),_make_modern_stamp(pv, info, caps)(the_metatriple +cancel_on_abandon=False+ all three routing headers).__init__installs the first;.initialize()/.adopt()/.discover()install one of the other two.The in-process modern path reuses the existing
DirectDispatcherpeer-pair (no new dispatcher class) — the era-specific bit is themodern_on_requestcallback wired into the server side, mirroring howServerRunner.on_requestis wired in for the legacy path.http-custom-headersandhttp-invalid-tool-headers(theMcp-Param-*header scenarios) andsep-2322-client-request-state(multi-round-trip results) stay waived — separate work.AI Disclaimer