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 12 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.
| 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?
… 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
| ``` | ||
|
|
||
| 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. | ||
| 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. Like `session.initialize_result`, this replaces v1's `ClientSession.get_server_capabilities()`; use `client.initialize_result.capabilities` instead. | ||
|
|
There was a problem hiding this comment.
🔴 The migration guide still tells users to read client.initialize_result.capabilities, but this PR removes the Client.initialize_result property (replaced by client.protocol_version / server_info / server_capabilities / instructions), so following the guide now raises AttributeError; the removal is also missing from the Breaking Changes list. The new section at lines 162-164 has the same theme — it recommends session.initialize_result.protocol_version as the replacement for the removed transport.protocol_version, but on the modern path this PR introduces (mode='auto'/version-pin, discover()/adopt(DiscoverResult)) initialize_result stays None. Recommend the era-neutral client.server_capabilities / client.protocol_version (or session.protocol_version) accessors in both spots and add the Client.initialize_result removal to the breaking-changes documentation.
Extended reasoning...
What the bug is
This PR removes the Client.initialize_result property from src/mcp/client/client.py, replacing it with four new accessors: protocol_version, server_info, server_capabilities, and instructions. Every test that previously read client.initialize_result.* was migrated to the new accessors in this same PR (e.g. tests/client/test_client.py, tests/interaction/lowlevel/test_initialize.py, tests/client/transports/test_memory.py). However, docs/migration.md line 355 — a sentence this PR itself rewrites in the diff — still says:
The high-level
Client.initialize_resultreturns the sameInitializeResultbut is non-nullable — initialization is guaranteed inside the context manager, so noNonecheck is needed. Likesession.initialize_result, this replaces v1'sClientSession.get_server_capabilities(); useclient.initialize_result.capabilitiesinstead.
The PR edit changed the surrounding text ("Like session.initialize_result, this replaces v1's ClientSession.get_server_capabilities()") but kept the recommendation pointing at the attribute the same PR deletes.
Step-by-step proof
- A v1 user migrating to v2 reads this section and writes
caps = client.initialize_result.capabilitiesas instructed. - After this PR,
Client(a@dataclass) defines noinitialize_resultproperty —grep initialize_result src/mcp/client/client.pyreturns nothing post-diff. - Python raises
AttributeError: 'Client' object has no attribute 'initialize_result'on first use. - The user then checks the Breaking Changes section / PR description for a
Client.initialize_resultremoval entry — there isn't one (the breaking-changes list coversClientSession(protocol_version=),Client(protocol_version=), transport pv, version constants, etc., but not this property), so the failure looks like an SDK bug rather than a documented removal.
The correct guidance is the new accessor the PR's own tests use: client.server_capabilities (and client.server_info / client.protocol_version / client.instructions for the other fields).
The related issue at lines 162-164
The new section "StreamableHTTPTransport.protocol_version attribute removed" (added by this PR) recommends: "read it from session.initialize_result.protocol_version instead." In the new ClientSession, _initialize_result is populated only by initialize() / adopt(InitializeResult); on the modern path this PR introduces (Client(mode='auto') succeeding discover(), a version-pin mode, or adopt(DiscoverResult)) it stays None — the new test test_era_neutral_properties_after_discover asserts exactly that — so session.initialize_result.protocol_version raises AttributeError on None for those users.
One verifier argued this second sentence is fine because migration.md targets v1→v2 users, who land on the legacy path by default where initialize_result is populated. That's a fair point about the default audience, and it's why this part alone would be a wording nit rather than a blocker — but it doesn't make the recommendation good: a migrating user who reads transport.protocol_version precisely because they care about the negotiated version is also the user most likely to opt into mode='auto', and the PR already provides the era-neutral session.protocol_version / client.protocol_version accessor that works on both paths. Recommending the era-neutral accessor costs nothing and is correct everywhere. The line-355 issue, by contrast, is unconditionally wrong: client.initialize_result does not exist on any path after this PR.
How to fix
- Line 355: change the sentence to point at the new accessors, e.g. "The high-level
Clientexposesserver_capabilities,server_info,protocol_version, andinstructionsdirectly — these are non-nullable inside the context manager; useclient.server_capabilitiesinstead of v1'sClientSession.get_server_capabilities()." - Lines 162-164: recommend
session.protocol_version(orclient.protocol_version) instead ofsession.initialize_result.protocol_version. - Add
Client.initialize_resultremoved → useclient.server_capabilities/server_info/protocol_version/instructionsto the Breaking Changes documentation.
Note the reviewer (maxisbey) has separately questioned whether some of these new migration.md entries belong at all ("migration.md is only for changes from v1 -> v2"); if the transport.protocol_version entry is dropped entirely, the lines 162-164 part resolves itself, but the line-355 sentence still needs updating either way.
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