Skip to content

Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green#2950

Merged
maxisbey merged 22 commits into
mainfrom
s3-client-modern-path
Jun 25, 2026
Merged

Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green#2950
maxisbey merged 22 commits into
mainfrom
s3-client-modern-path

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Client-side support for the 2026-07-28 per-request-envelope path: ClientSession gains .discover() and .adopt() alongside .initialize(); Client gains mode='legacy'|'auto'|<version-pin> and prior_discover=. Removes request-metadata and auth/authorization-server-migration from the conformance baseline, plus the carried-forward tools_call/auth/scope-step-up/auth/scope-retry-limit entries 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. ClientSession previously branched on a _stateless_pinned flag inside send_request and held the protocol version in four places (session pin, init result, transport, OAuth context); the transport sniffed InitializeResult responses 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. A DiscoverResult selects the newest mutually-supported modern version and installs the modern stamp; an InitializeResult installs the handshake stamp.
  • .discover() probes server/discover, validates the response with DiscoverResult.model_validate before reading any field, and .adopt()s on success. On -32022 it retries once with the intersection of MODERN_PROTOCOL_VERSIONS and data.supported; on -32601 or request timeout it falls back to .initialize(); anything else propagates.

send_request and send_notification call self._stamp(data, opts) unconditionally — no era branch in the body. The _stateless_pinned flag, _pinned_version slot, and the ClientSession(protocol_version=) constructor kwarg are removed.

Client — policy layer. New mode: Literal['legacy','auto'] | str = 'legacy' and prior_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. StreamableHTTPTransport no longer holds protocol_version, no longer derives Mcp-Method/Mcp-Name headers, and no longer sniffs InitializeResult responses. Per-message headers arrive via CallOptions['headers']ClientMessageMetadata.headers → merged at the POST. The transport caches MCP-Protocol-Version from the first stamped POST for transport-internal GET/DELETE/reconnect (per-connection state, same pattern as session_id).

In-process modern path. New modern_on_request(server, lifespan_state) driver in runner.py returns an OnRequest callback that builds Connection.from_envelope per call and drives serve_one. Client(Server | MCPServer, mode != 'legacy') enters the lifespan once, creates a DirectDispatcher peer-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_VERSIONS renamed to HANDSHAKE_PROTOCOL_VERSIONS (the versions reachable via the initialize handshake); the old name survives as a deprecated union. LATEST_PROTOCOL_VERSION bumped to "2026-07-28". The three duplicate mcp-protocol-version header constant definitions collapsed to one in shared/inbound.

report_progress routes through DispatchContext.progress(). Context.report_progress was gating on a JSONRPC-specific _meta.progressToken and reimplementing the notification path; it now delegates to ServerSession.report_progressdctx.progress(), so progress reaches the client on the in-process modern path too.

Conformance fixture. .github/actions/conformance/client.py reads MCP_CONFORMANCE_PROTOCOL_VERSION and drives Client(mode='auto') for the modern leg, 'legacy' otherwise. New handlers for request-metadata and http-standard-headers.

How Has This Been Tested?

  • Conformance: request-metadata 7/7, auth/authorization-server-migration 27/27, http-standard-headers 3/3 on both legs; tools_call/auth/scope-step-up/auth/scope-retry-limit pass on the 2026-07-28 leg
  • ./scripts/test: 100% branch coverage, strict-no-cover clean
  • 9 unit tests cover each .discover() ladder rung; 10 interaction tests in test_client_connect.py cover the mode= policy and envelope stamping end to end
  • In-memory@2026-07-28: 71 interaction tests run, 67 pass (4 progress-on-streamable-http xfails remain scoped to that transport)

Breaking Changes

All documented in docs/migration.md:

  • ClientSession(protocol_version=) removed → use .adopt() after construction
  • Client(protocol_version=) removed → use mode=
  • StreamableHTTPTransport.protocol_version and streamable_http_client(protocol_version=) removed
  • SUPPORTED_PROTOCOL_VERSIONS deprecated → use HANDSHAKE_PROTOCOL_VERSIONS or MODERN_PROTOCOL_VERSIONS
  • LATEST_PROTOCOL_VERSION value changed "2025-11-25""2026-07-28"; code that meant "the version .initialize() offers" should switch to HANDSHAKE_PROTOCOL_VERSIONS[-1]
  • Client.send_progress_notification / ClientSession.send_progress_notification deprecated (client-to-server progress is server-to-client only at 2026-07-28)
  • Outbound.notify Protocol grew an opts: CallOptions | None = None parameter
  • ServerMessageMetadata.protocol_version removed (no readers)

Types of changes

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The three stamp closures: _preconnect_stamp (cancel-suppressed only — only initialize/discover go out before connect, both forbid cancel), _make_handshake_stamp(pv) (sets the MCP-Protocol-Version header), _make_modern_stamp(pv, info, caps) (the _meta triple + 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 DirectDispatcher peer-pair (no new dispatcher class) — the era-specific bit is the modern_on_request callback wired into the server side, mirroring how ServerRunner.on_request is wired in for the legacy path.

http-custom-headers and http-invalid-tool-headers (the Mcp-Param-* header scenarios) and sep-2322-client-request-state (multi-round-trip results) stay waived — separate work.

AI Disclaimer

maxisbey added 9 commits June 22, 2026 15:34
…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.
Comment thread docs/migration.md Outdated
Comment thread docs/migration.md Outdated
Comment thread docs/migration.md Outdated
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/server/session.py
Comment thread tests/interaction/lowlevel/test_client_connect.py Outdated
Comment thread tests/interaction/lowlevel/test_progress.py
Comment thread tests/issues/test_176_progress_token.py
Comment thread tests/server/mcpserver/test_server.py
maxisbey added 3 commits June 23, 2026 11:27
… 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
@maxisbey maxisbey marked this pull request as ready for review June 23, 2026 11:56
Comment thread docs/migration.md
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/client/client.py
Comment thread docs/migration.md Outdated
Comment thread docs/migration.md Outdated
Comment thread tests/issues/test_176_progress_token.py
Comment thread tests/server/mcpserver/test_server.py
Comment thread src/mcp/server/runner.py Outdated
Comment thread tests/interaction/lowlevel/test_client_connect.py Outdated
Comment thread tests/interaction/lowlevel/test_client_connect.py Outdated
maxisbey added 2 commits June 23, 2026 16:38
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.
Comment thread docs/migration.md
Comment thread src/mcp/server/session.py
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/client/client.py
Comment thread docs/migration.md Outdated
Comment thread src/mcp/client/client.py Outdated
Comment thread src/mcp/server/runner.py Outdated
Comment thread src/mcp/types/_types.py Outdated
Comment thread tests/shared/test_inbound.py Outdated
Comment thread src/mcp/client/streamable_http.py Outdated
Comment thread src/mcp/client/client.py Outdated
maxisbey added 3 commits June 23, 2026 18:15
…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.
Comment on lines 44 to 50
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

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.

🟡 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

  1. A v1.x user has from mcp.client.streamable_http import MCP_PROTOCOL_VERSION (the same import the SDK's auth code used).
  2. They upgrade to v2 with this PR merged. The name no longer exists in mcp.client.streamable_httpImportError: cannot import name 'MCP_PROTOCOL_VERSION'.
  3. They check docs/migration.md. The new sections added by this PR cover the StreamableHTTPTransport.protocol_version attribute removal and the SUPPORTED_PROTOCOL_VERSIONS/LATEST_PROTOCOL_VERSION changes, 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.
Comment thread src/mcp/client/session.py Outdated
Comment on lines +54 to +56
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

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.

🟡 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

  1. 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 calls initialize()/discover()/adopt(). self._stamp remains _preconnect_stamp.
  2. They call session.call_tool('slow_tool', {...}, read_timeout_seconds=5). send_request builds opts = {} and calls self._stamp(data, opts), which sets opts['cancel_on_abandon'] = False.
  3. The tool takes longer than 5 s. The dispatcher's timeout path consults plan.cancel_on_abandon, finds it False, and does not send notifications/cancelled.
  4. 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"] = False

The 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).
Comment thread src/mcp/client/session.py
Comment on lines +398 to +410

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)

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 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

  1. A caller follows the docstring and writes:
    try:
        raw = await session.send_discover("2026-07-28")
    except ProbeNotRecognized:
        ...
  2. There is nothing to import — from mcp.shared.exceptions import ProbeNotRecognized raises ImportError, and an unqualified reference raises NameError: name 'ProbeNotRecognized' is not defined the first time the except clause is evaluated.
  3. 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).

maxisbey added 2 commits June 25, 2026 13:16
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.
Comment thread src/mcp/client/client.py
else:
raise
else:
session.adopt(self.prior_discover or _synthesize_discover(self.mode))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Comment thread src/mcp/client/session.py Outdated
"cancel_on_abandon": False,
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
}
return await self._dispatcher.send_raw_request("server/discover", params, opts)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

call it options: please

@maxisbey maxisbey enabled auto-merge (squash) June 25, 2026 14:09
@maxisbey maxisbey merged commit f226d00 into main Jun 25, 2026
32 checks passed
@maxisbey maxisbey deleted the s3-client-modern-path branch June 25, 2026 14:09
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