Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/advanced/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ Two more things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about.
* **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only.

!!! warning "Streamable HTTP only, for now"
`subscriptions/listen` is served on the streamable-HTTP transport. Over stdio (and other
stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the
open-stream semantics haven't been built for that transport yet, even though
`server/discover` still advertises the subscription capabilities there.

## One process is the default. More takes a bus

Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
Expand Down
6 changes: 3 additions & 3 deletions docs/client/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ Either way you come out connected, and `client.protocol_version` tells you which
That is the whole feature. One `Client`, any era of server, no branching in your code.

!!! info
`MCPServer` answers `server/discover`, so against your own in-memory server `auto` always lands
on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly
when you want it to.
`MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable
HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only
ever fires against a real pre-2026 server, which is exactly when you want it to.

## `mode="legacy"`

Expand Down
2 changes: 2 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,8 @@ On the high-level `Client`, `client.server_capabilities`, `client.server_info`,

In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.

The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport.

For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used.

`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
Expand Down
27 changes: 14 additions & 13 deletions src/mcp/server/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,26 +100,18 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call
_NO_CHANNEL = _NoChannelOutbound()


class NotifyOnlyOutbound:
class NotifyOnlyOutbound(_NoChannelOutbound):
"""Connection-scoped `Outbound` that forwards notifications and refuses requests.

Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections
over duplex stream transports: the pipe is real, so server notifications
ride it, but the modern protocol forbids server-initiated JSON-RPC
requests, so `send_raw_request` refuses by construction.
requests, so `send_raw_request` (inherited) refuses by construction.
"""

def __init__(self, outbound: Outbound) -> None:
self._outbound = outbound

async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
raise NoBackChannelError(method)

async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
await self._outbound.notify(method, params, opts)

Expand Down Expand Up @@ -230,7 +222,12 @@ def for_loop(
def has_standalone_channel(self) -> bool:
"""Whether this connection has a real back-channel for server-initiated
messages. Derived from `outbound` - the no-channel sentinel is the only
case that doesn't."""
case that doesn't.

Channel presence, not request permission: a modern (2026-07-28+)
duplex connection has a channel that carries notifications while
`send_raw_request` still refuses, because the protocol forbids
server-initiated requests."""
return self.outbound is not _NO_CHANNEL

@property
Expand All @@ -255,7 +252,9 @@ async def send_raw_request(

Raises:
MCPError: The peer responded with an error.
NoBackChannelError: `has_standalone_channel` is `False`.
NoBackChannelError: no back-channel for server-initiated requests -
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
connection, where the protocol forbids them.
"""
return await self.outbound.send_raw_request(method, params, opts)

Expand Down Expand Up @@ -316,7 +315,9 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non

Raises:
MCPError: The peer responded with an error.
NoBackChannelError: `has_standalone_channel` is `False`.
NoBackChannelError: no back-channel for server-initiated requests -
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
connection, where the protocol forbids them.
"""
await self.send_raw_request("ping", dump_params(None, meta), opts)

Expand Down
90 changes: 59 additions & 31 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

import logging
from collections.abc import Awaitable, Mapping
from dataclasses import KW_ONLY, dataclass
from dataclasses import KW_ONLY, dataclass, replace
from functools import cached_property, partial
from typing import TYPE_CHECKING, Any, Generic, Literal, cast

Expand Down Expand Up @@ -415,13 +415,14 @@
init_options: InitializationOptions | None = None,
raise_exceptions: bool = False,
) -> None:
"""Drive ``server`` in loop mode over a stream pair until the channel closes.
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.

Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
`serve_connection`, so loop-mode callers share one dispatcher-construction
recipe (notably the `inline_methods={"initialize"}` rule). Callers that own
a lifespan (the streamable-HTTP manager) pass it in; callers that don't
(`Server.run` for stdio/memory) enter the lifespan and then call this.
`serve_connection`. The streamable-HTTP manager (which owns its lifespan
and serves the modern era on the single-exchange entry instead) calls
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
era routing.
"""
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
read_stream,
Expand Down Expand Up @@ -481,7 +482,11 @@

@property
def transport(self) -> TransportContext:
return self._inner.transport
# Mask the per-message flag so the transport metadata agrees with this
# wrapper's denial: the modern HTTP entry builds its context with
# can_send_request=False, while the loop's default builder says True.
transport = self._inner.transport
return replace(transport, can_send_request=False) if transport.can_send_request else transport

@property
def can_send_request(self) -> bool:
Expand Down Expand Up @@ -529,24 +534,35 @@
The stream-pair counterpart of the modern HTTP entry's era router. Era is
a property of the connection, decided by how the client opens it, and
mid-stream switching is undefined - so the first era-distinctive message
locks the connection (matching the typescript-sdk):
to SUCCEED locks the connection (matching the typescript-sdk):

- `initialize` locks legacy: the connection behaves exactly like
`serve_loop` for its lifetime, and modern envelope traffic is rejected
with INVALID_REQUEST.
- A successful `initialize` locks legacy: the connection behaves exactly
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
like `serve_loop` for its lifetime, and modern envelope traffic is then
rejected with INVALID_REQUEST. `initialize` never routes modern - the
method is legacy-distinctive by definition - even when a confused
client stamps the envelope triple on it.
- A request carrying the modern `_meta` envelope triple - or
`server/discover`, a modern-only method - locks modern: every request is
classified (`classify_inbound_request`) and served single-exchange via
`serve_one` with a born-ready per-request `Connection`, the same
dispatch model as the modern HTTP entry. A later `initialize` is
rejected with UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.
`server/discover`, a modern-only method - is classified
(`classify_inbound_request`) and served single-exchange via `serve_one`
with a born-ready per-request `Connection`, the same dispatch model as
the modern HTTP entry. The first such request to succeed locks the
connection modern; a later `initialize` is then rejected with
UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.

Modern connections push notifications over the duplex pipe but refuse
server-initiated requests on both channels (the modern protocol forbids
them). A rejected classification (malformed envelope, unsupported version)
never locks the era, so a failed probe leaves the legacy handshake
available - released auto-negotiating clients fall back on any error code
except -32022.
them). A request that fails - rejected classification, malformed envelope
content, unknown method - never locks either era, so a failed probe
leaves the legacy handshake available: released auto-negotiating clients
fall back on any error code except -32022, and that code is only emitted
for genuine version negotiation or for `initialize` on an
already-modern connection.

The era lock rides the request's own dispatch. For the inline methods
(`initialize`, `server/discover`) that completes before the next frame is
read, so the canonical probe-then-go flow is race-free; a pinned-modern
client that pipelines frames ahead of its first response should expect
envelope-less notifications sent in that window to be dropped.
"""
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
read_stream,
Expand All @@ -570,8 +586,6 @@
route = classify_inbound_request({"method": method, "params": params})
if isinstance(route, InboundLadderRejection):
raise MCPError(code=route.code, message=route.message, data=route.data)
if era != "modern":
era, modern_version = "modern", route.protocol_version
if method == "subscriptions/listen":
# The registered listen handler assumes the HTTP entry's stream
# semantics; served over a stream pair it would wedge. Reject until
Expand All @@ -585,34 +599,48 @@
route.client_capabilities,
outbound=standalone_outbound,
)
return await serve_one(
result = await serve_one(
server,
_NoServerRequestsDispatchContext(dctx),
method,
params,
connection=connection,
lifespan_state=lifespan_state,
)
if era != "modern":
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
# Lock only on success, mirroring the legacy side: a request that
# failed (malformed envelope content, unknown method) must not
# strand the client, whose initialize fallback stays available.
era, modern_version = "modern", route.protocol_version
return result

Check warning on line 616 in src/mcp/server/runner.py

View check run for this annotation

Claude / Claude Code Review

Era lock can be clobbered by a concurrently-committed lock of the other era

The era lock in serve_dual_era_loop() can overwrite an already-committed lock of the other era: because envelope-bearing requests run as concurrent dispatcher tasks while initialize is handled inline, a client that pipelines a modern request followed by initialize can have the inline initialize succeed and commit era='legacy' while the modern task is still awaiting serve_one(); when it resumes, the 'if era != "modern"' guard clobbers the legacy lock (a symmetric clobber exists on the legacy side
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
async def on_request(
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
nonlocal era
if era == "legacy":
if method == "server/discover" or _has_modern_envelope(params):
if _has_modern_envelope(params):
raise MCPError(
code=INVALID_REQUEST,
message="connection is locked to the legacy handshake era; "
"modern envelope requests are not accepted",
)
# Bare modern-only methods (e.g. `server/discover`) fall through to
# the loop runner's per-version surface validation - the same
# METHOD_NOT_FOUND a handshake-only server produced, byte for byte.
return await loop_runner.on_request(dctx, method, params)
if era == "modern" and method == "initialize":
raise MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="connection already negotiated a modern protocol version",
data=_initialize_after_modern_data(params),
)
if era == "modern" or method == "server/discover" or _has_modern_envelope(params):
if era == "modern":
if method == "initialize":
raise MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="connection already negotiated a modern protocol version",
data=_initialize_after_modern_data(params),
)
return await serve_modern(dctx, method, params)
# Unlocked. `initialize` is legacy-distinctive by definition (the
# method does not exist at modern versions), so it takes the handshake
# path even when the envelope triple is stamped on it.
if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
return await serve_modern(dctx, method, params)
result = await loop_runner.on_request(dctx, method, params)
if method == "initialize":
Expand Down
12 changes: 10 additions & 2 deletions src/mcp/shared/inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,8 @@
Rungs, in order — first failure wins:

1. `params._meta` is a mapping carrying every reserved envelope key
(protocol version, client info, client capabilities) → else
:data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
(protocol version, client info, client capabilities), and the protocol
version is a string → else :data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
protocol version, `Mcp-Method` equals `body.method`, and — for the
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
Expand Down Expand Up @@ -411,6 +411,14 @@
message="params._meta must carry the reserved protocol-version, client-info and "
"client-capabilities envelope keys",
)
if not isinstance(protocol_version, str):
# A shape defect, not a version-negotiation outcome: -32022 is the one
# code auto-negotiating clients do NOT fall back from, and the typed
# rung-3 payload itself requires a string `requested`.
return InboundLadderRejection(
code=INVALID_PARAMS,
message="the protocol-version envelope value must be a string",
)

Check warning on line 421 in src/mcp/shared/inbound.py

View check run for this annotation

Claude / Claude Code Review

Non-string protocol-version guard changes the HTTP wire error, contrary to the stated stdio-only delta

The new non-string protocol-version guard (inbound.py:414) runs before the `if headers is not None:` header rung, so over HTTP a request whose envelope carries a non-string `protocolVersion` (e.g. `7`) is now rejected with INVALID_PARAMS (-32602) instead of the HEADER_MISMATCH (-32020) the header rung previously produced — contradicting the PR description's claim that the fix is "Unreachable over HTTP (the header rung fires first), so the wire delta is stdio-only." Either move the guard between
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

if headers is not None:
if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
Expand Down
Loading
Loading