Skip to content

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

Open
maxisbey wants to merge 16 commits into
mainfrom
s3-client-modern-path
Open

Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green#2950
maxisbey wants to merge 16 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

`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`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly.

`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 `LATEST_HANDSHAKE_VERSION`.

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.

remove this line, pointless

Comment thread src/mcp/client/client.py Outdated
Comment on lines +168 to +173
if isinstance(self.server, MCPServer):
self._inproc_server = self.server._lowlevel_server # pyright: ignore[reportPrivateUsage]
elif isinstance(self.server, Server):
self._inproc_server = self.server
elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
self._transport = streamable_http_client(self.server)

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 have an inproc_server field? could we not generalize Transport to this new world?

Comment thread src/mcp/server/runner.py Outdated


async def to_jsonrpc_response(request_id: RequestId, coro: Awaitable[dict[str, Any]]) -> JSONRPCResponse | JSONRPCError:
async def to_jsonrpc_response(

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.

probably shouldn't be in this file anymore? what is it even used by?

Comment thread src/mcp/types/_types.py Outdated
from pydantic.alias_generators import to_camel
from typing_extensions import NotRequired, TypedDict

from mcp.shared.version import LATEST_PROTOCOL_VERSION as LATEST_PROTOCOL_VERSION

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.

it's already called that

Comment thread tests/shared/test_inbound.py Outdated
)

MODERN = MODERN_PROTOCOL_VERSIONS[0]
MODERN = LATEST_MODERN_VERSION

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? just use LATEST_MODERN_VERSION? remove MODERN?

Comment on lines 250 to 258
async def _handle_post_request(self, ctx: RequestContext) -> None:
"""Handle a POST request with response processing."""
headers = self._prepare_headers()
headers = self._base_headers()
message = ctx.session_message.message
headers.update(self._per_message_headers(message))
if ctx.metadata is not None and ctx.metadata.headers is not None:
headers.update(ctx.metadata.headers)
if MCP_PROTOCOL_VERSION_HEADER in ctx.metadata.headers:
self._protocol_version_header = ctx.metadata.headers[MCP_PROTOCOL_VERSION_HEADER]
is_initialization = self._is_initialization_request(message)

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.

🔴 On the legacy (initialize-handshake) streamable-HTTP path, JSON-RPC response POSTs the client sends back for server-initiated requests (sampling/createMessage, elicitation/create, roots/list) and the dispatcher-emitted notifications/cancelled POSTs no longer carry the MCP-Protocol-Version header, which they did before this PR — _handle_post_request now builds headers from _base_headers() plus ctx.metadata.headers, but those messages are written by JSONRPCDispatcher with no headers metadata. Per the 2025-06-18+ spec the client MUST send this header on all post-initialization HTTP requests, and strict servers/gateways may 400 or assume the wrong version; consider falling back to the cached _protocol_version_header for POSTs that carry no per-message pv header, or attaching headers metadata when the dispatcher writes results/errors/cancellations.

Extended reasoning...

What the bug is

Before this PR, StreamableHTTPTransport._handle_post_request() built its headers via _prepare_headers(), which added the MCP-Protocol-Version header (sniffed from the InitializeResult) to every POST after initialization. After this PR, _handle_post_request() (src/mcp/client/streamable_http.py:250-258) builds headers from _base_headers() (accept / content-type / session-id only) and merges only ctx.metadata.headers. The docstring and the new test_post_does_not_read_cached_protocol_version_header deliberately pin that POSTs never read the cached _protocol_version_header — that cache is reserved for transport-internal GET/DELETE.

The code path that loses the header

The only producers of ClientMessageMetadata.headers are the stamp closures applied in ClientSession.send_request / send_notification (_make_handshake_stamp on the legacy path). But two classes of outbound messages never go through those methods:

  1. Client responses to server-initiated requests. Answers to sampling/createMessage, elicitation/create, and roots/list are written by JSONRPCDispatcher._write_result / _write_error (src/mcp/shared/jsonrpc_dispatcher.py:~728-738), which call self._write(message) with metadata=None.
  2. Dispatcher-emitted notifications/cancelled. _cancel_outbound calls self.notify(...) with no opts; for an abandoned top-level client request related_request_id is None, so _plan_outbound(None, None) yields metadata=None as well.

So on the legacy streamable-HTTP path, those POSTs reach the transport with ctx.metadata=None (or metadata without headers), get only _base_headers(), and go out without MCP-Protocol-Version — a wire change relative to main.

Why nothing else prevents it

The session stamp only fires inside send_request/send_notification; the dispatcher's response/cancel writes bypass it entirely. The transport's cached pv header exists, but the new design explicitly forbids POSTs from reading it (to avoid a stale discover-probe value poisoning a fallback initialize POST), so there is no remaining path that supplies the header for these messages.

Step-by-step proof

  1. Client(url, mode='legacy') connects: initialize succeeds, adopt(InitializeResult) installs _make_handshake_stamp('2025-06-18') (or whatever was negotiated). Subsequent tools/call POSTs correctly carry MCP-Protocol-Version via metadata.headers.
  2. The server (which supports sampling) sends a sampling/createMessage request to the client over the SSE stream of an in-flight tools/call.
  3. The client's sampling callback returns a result; JSONRPCDispatcher._write_result builds the JSONRPCResponse and calls self._write(msg) — note: no metadata argument.
  4. The transport's post_writer sees ctx.metadata is None, so _handle_post_request sends the response POST with only accept / content-type / mcp-session-id headers. No MCP-Protocol-Version.
  5. Pre-PR, step 4 used _prepare_headers(), which included the sniffed protocol version — so the same POST carried the header. The 2025-06-18 streamable-HTTP spec says the client MUST include MCP-Protocol-Version on all subsequent HTTP requests after initialization; servers receiving no header are told to assume 2025-03-26, and strict implementations/gateways are allowed to reject with 400.

The same trace applies to the courtesy notifications/cancelled POST emitted by _cancel_outbound when a caller abandons or times out a request.

Impact

This contradicts the PR's "mode='legacy' is byte-identical" claim — the author's end-to-end trace covered only the session-stamped request/notification POSTs. Practical blast radius is moderate: this SDK's own server and most lenient servers fall back to assuming an older version when the header is missing, but strict servers/proxies that validate the header (or apply version-gated behavior to it) will 400 or misbehave on exactly the sampling/elicitation/roots-answer and cancellation POSTs of the legacy path.

How to fix

Either (a) in _handle_post_request, when the message carries no per-message pv header but a session is established, fall back to the cached _protocol_version_header (the cache already exists; the no-stale-leak concern only applies before the cache has been written by a stamped POST), or (b) attach headers metadata when the dispatcher writes results/errors and emits its internal cancellation notification (e.g. by routing them through the same stamp/CallOptions path). Option (a) is the smaller change and restores the pre-PR wire behavior for the legacy path.

Comment thread src/mcp/client/client.py
Comment on lines +233 to +241
await session.initialize()
elif self.mode == "auto":
try:
await session.discover()
except MCPError as e:
if e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT):
await session.initialize()
else:
raise

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.

🔴 Client(url, mode='auto') only falls back to initialize() when discover() fails with METHOD_NOT_FOUND (-32601) or REQUEST_TIMEOUT (-32001), but the most common rejection from a deployed legacy stateful streamable-HTTP server (including this repo's own legacy transport and v1.x SDK servers) is HTTP 400 with a JSON-RPC INVALID_REQUEST body ('Bad Request: Missing session ID' / unsupported MCP-Protocol-Version header), so 'auto' raises instead of falling back against widely-deployed legacy servers — defeating its documented purpose, and more so if 'auto' becomes the default. Consider including INVALID_REQUEST (or any pre-session non-success other than UNSUPPORTED_PROTOCOL_VERSION) in the fallback ladder, or mapping the pre-session 400 in the transport like the new pre-session 404→METHOD_NOT_FOUND mapping.

Extended reasoning...

What the bug is

Client.__aenter__ with mode='auto' (src/mcp/client/client.py:233-241) catches MCPError from session.discover() and falls back to initialize() only when e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT). Every other error code re-raises. The problem is that the dominant real-world rejection of the server/discover probe by an existing legacy stateful streamable-HTTP server is HTTP 400 with a JSON-RPC INVALID_REQUEST (-32600) error body, not a 404 or a -32601 — so mode='auto' hard-fails against exactly the class of servers the docstring says it falls back for ('probes server/discover and falls back to initialize() on legacy servers').

The code path

  1. ClientSession.discover() sends a server/discover POST carrying MCP-Protocol-Version: 2026-07-28 (via opts['headers']) and no mcp-session-id — the session does not exist yet.
  2. A legacy stateful server rejects any non-initialize POST that arrives without a session id. This repo's own legacy transport does it in _validate_session_create_error_response('Bad Request: Missing session ID', HTTPStatus.BAD_REQUEST) (src/mcp/server/streamable_http.py:836-841), where _create_error_response defaults to error_code=INVALID_REQUEST (src/mcp/server/streamable_http.py:310-316). v1.x SDK deployments behave the same, and pre-2026 spec text also mandates a 400 for an unsupported MCP-Protocol-Version header, which 2026-07-28 is to a v1.x server.
  3. The client transport's non-2xx handling (src/mcp/client/streamable_http.py:270-288) deliberately parses an application/json error body as a JSONRPCError and forwards the server's code verbatim ('Surface that error rather than the status-derived stand-in'), so the dispatcher raises MCPError(INVALID_REQUEST). If the 400 body is not parseable JSON-RPC, the non-404 fallback maps it to INTERNAL_ERROR (line 298). Neither -32600 nor -32603 is in the fallback set.
  4. Client.__aenter__ therefore re-raises, and async with Client(url, mode='auto') fails against a working legacy server that mode='legacy' would connect to fine.

Step-by-step proof

  • Client('https://legacy.example/mcp', mode='auto').__aenter__()session.discover() → POST server/discover, headers {mcp-protocol-version: 2026-07-28}, no session id.
  • Legacy server (this repo's StreamableHTTPServerTransport or any v1.x server): _validate_session sees no mcp-session-id → 400, body {"jsonrpc":"2.0","id":"server-error","error":{"code":-32600,"message":"Bad Request: Missing session ID"}}.
  • Client transport: status ≥ 400, content-type is JSON, body parses as JSONRPCError → forwarded with code -32600.
  • Dispatcher raises MCPError(-32600); __aenter__'s ladder checks (METHOD_NOT_FOUND, REQUEST_TIMEOUT) → no match → re-raise. Connection fails; no initialize() is attempted.

Why existing code doesn't prevent it

Commit b6be755 added a pre-session bare-404 → METHOD_NOT_FOUND mapping in the transport precisely so HTTP-level legacy/gateway rejections feed the fallback ladder — but it only covers 404s, not the 400/INVALID_REQUEST shape that this SDK's own legacy server (and v1.x deployments) actually produce. The PR's conformance fixture works around the gap rather than exercising it: client_mode() pins handshake-era legs to mode='legacy' 'so no server/discover probe is sent against a mock that would 400 it', and json-schema-ref-no-deref is pinned to legacy because the mock '400s a modern-stamped tools/list'. The new lifecycle:discover:network-error-raises requirement shows non-404 4xx/5xx propagation is intentional for transport faults, but that intent leaves the documented legacy-server fallback non-functional for the most common legacy rejection.

Impact

mode='auto' cannot connect to the large installed base of v1.x stateful streamable-HTTP servers — the primary scenario 'auto' exists for. The reviewer has asked for mode='auto' to become the Client default, which would turn this into a default-configuration regression for anyone pointing the v2 client at an existing server.

How to fix

Either (a) widen the fallback ladder in Client.__aenter__ to also fall back on INVALID_REQUEST (or, more robustly, on any pre-session probe failure other than UNSUPPORTED_PROTOCOL_VERSION, which has its own retry rung inside discover()), or (b) extend the transport's pre-session mapping so that a 400 received before any session id is held is surfaced as METHOD_NOT_FOUND the same way the pre-session 404 now is. Option (b) keeps the policy ladder narrow but changes legitimate-error fidelity, so (a) at the policy layer is probably the cleaner shape; either way the choice should be deliberate and tested (a scripted-server test mirroring test_client_auto_mode_falls_back_to_initialize_on_legacy_signal but answering with -32600 would pin it).

maxisbey added 2 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.
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.

1 participant