Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green#2950
Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green#2950maxisbey wants to merge 9 commits into
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.
|
|
||
| Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. | ||
|
|
||
| ### `protocol_version` removed from `StreamableHTTPTransport` and `streamable_http_client` |
There was a problem hiding this comment.
was this in v1 though? migration.md is only for changes from v1 -> v2, not changes within v2
|
|
||
| The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead. | ||
|
|
||
| ### `ClientSession(protocol_version=)` removed |
There was a problem hiding this comment.
definitely wasn't in v2, remove
|
|
||
| `SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. | ||
|
|
||
| `LATEST_PROTOCOL_VERSION` now reflects the newest protocol revision the SDK supports (`2026-07-28`). Code that used it to mean "the version `.initialize()` offers" should switch to `HANDSHAKE_PROTOCOL_VERSIONS[-1]`. |
|
|
||
| protocol_version: str | None = None | ||
| """Pin the protocol version instead of negotiating it. | ||
| mode: Literal["legacy", "auto"] | str = "legacy" |
There was a problem hiding this comment.
where did "modern" go?
| if self._inproc_server is not None and self.mode != "legacy": | ||
| # Modern in-process path: drive the server through a DirectDispatcher peer-pair | ||
| # with one `serve_one` per request — no streams, no initialize handshake. | ||
| lifespan_state = await exit_stack.enter_async_context(self._inproc_server.lifespan(self._inproc_server)) | ||
| client_disp, server_disp = create_direct_dispatcher_pair() | ||
| tg = await exit_stack.enter_async_context(anyio.create_task_group()) | ||
| exit_stack.callback(server_disp.close) | ||
| await tg.start(server_disp.run, modern_on_request(self._inproc_server, lifespan_state), _drop_notify) | ||
| session = ClientSession( | ||
| dispatcher=client_disp, | ||
| read_timeout_seconds=self.read_timeout_seconds, | ||
| sampling_callback=self.sampling_callback, | ||
| list_roots_callback=self.list_roots_callback, | ||
| logging_callback=self.logging_callback, | ||
| message_handler=self.message_handler, | ||
| client_info=self.client_info, | ||
| elicitation_callback=self.elicitation_callback, | ||
| ) | ||
| else: | ||
| if self._inproc_server is not None: | ||
| transport: Transport = InMemoryTransport( | ||
| self._inproc_server, raise_exceptions=self.raise_exceptions | ||
| ) | ||
| else: | ||
| assert self._transport is not None | ||
| transport = self._transport | ||
| read_stream, write_stream = await exit_stack.enter_async_context(transport) | ||
| session = ClientSession( |
There was a problem hiding this comment.
maybe just me but this looks kinda gross, is there a clean/cute way of writing this?
| types.EmptyResult, | ||
| ) | ||
|
|
||
| async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: |
There was a problem hiding this comment.
why a new one here? were we missing it before?
| pytest.RaisesExc(MCPError, check=is_internal_error), flatten_subgroups=True | ||
| ): # pragma: no branch | ||
| async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"): | ||
| pytest.fail("entering the Client should have raised") # pragma: no cover |
There was a problem hiding this comment.
just raise not implemented
| await ctx.session.send_progress_notification( | ||
| token, 3.0, total=3.0, message="done", related_request_id=str(ctx.request_id) | ||
| ) | ||
| await ctx.session.report_progress(1.0, total=3.0, message="first chunk") |
There was a problem hiding this comment.
ServerSession is supposed to be a shim on top of v1, why is this code needing changes?
| """ | ||
| mock_session = AsyncMock() | ||
| mock_session.send_progress_notification = AsyncMock() | ||
| mock_session.report_progress = AsyncMock() |
There was a problem hiding this comment.
yea why are we changing this p7ublic interface? ServerSession was supposed to be a v1 shim, I don't understand.
| session=mock_session, | ||
| method="tools/call", | ||
| meta={"progress_token": "tok-1"}, | ||
| meta=None, |
There was a problem hiding this comment.
why is this changing now?
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