Harden the dual-era stream loop's era-lock and rejection semantics (#3040)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -11,6 +11,12 @@ Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only
|
||||
the same path. Any non-``MCPError`` exception (network/connection errors,
|
||||
anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
|
||||
propagates to the caller; an outage or in-process bug is never an era verdict.
|
||||
|
||||
The fallback handshake itself can be answered with ``-32022`` — e.g. a probe
|
||||
that timed out client-side but succeeded on a slow-starting server locked the
|
||||
connection modern before the pipelined ``initialize`` arrived. That code is
|
||||
itself positive modern evidence (it names the server's versions), so it
|
||||
triggers one re-probe at a mutual version instead of failing the connect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -49,7 +55,8 @@ async def negotiate_auto(session: ClientSession) -> None:
|
||||
|
||||
Raises:
|
||||
MCPError: The server is modern-only and shares no version with this
|
||||
client (-32022 with a disjoint ``supported`` list).
|
||||
client (-32022 with a disjoint ``supported`` list), or the
|
||||
fallback handshake failed and one corrective re-probe did too.
|
||||
Exception: Any transport/network error from the probe propagates as-is.
|
||||
"""
|
||||
version = LATEST_MODERN_VERSION
|
||||
@@ -65,7 +72,22 @@ async def negotiate_auto(session: ClientSession) -> None:
|
||||
continue
|
||||
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
|
||||
raise # server is modern-only and disjoint — real incompatibility
|
||||
await session.initialize() # every other rpc-error → legacy (the denylist)
|
||||
try:
|
||||
await session.initialize() # every other rpc-error → legacy (the denylist)
|
||||
except MCPError as handshake_exc:
|
||||
if handshake_exc.code != UNSUPPORTED_PROTOCOL_VERSION or attempt != 0:
|
||||
raise
|
||||
# -32022 from the handshake is itself modern evidence: a probe
|
||||
# that timed out client-side but succeeded on the server locked
|
||||
# the connection modern before this initialize arrived. Re-probe
|
||||
# once at a version the server names; the era is already
|
||||
# settled, so the second probe answers without the slow start.
|
||||
supported = _parse_supported(handshake_exc.error.data)
|
||||
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
|
||||
if not mutual:
|
||||
raise
|
||||
version = mutual[-1]
|
||||
continue
|
||||
return
|
||||
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
|
||||
# RuntimeError from adopt) → propagate
|
||||
|
||||
@@ -22,7 +22,7 @@ import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
import anyio
|
||||
from anyio.streams.memory import MemoryObjectSendStream
|
||||
@@ -30,13 +30,10 @@ from mcp_types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
HEADER_MISMATCH,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_REQUEST,
|
||||
PARSE_ERROR,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
ClientCapabilities,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
@@ -45,13 +42,13 @@ from mcp_types import (
|
||||
RequestId,
|
||||
)
|
||||
from mcp_types import methods as _methods
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic import ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.runner import serve_one
|
||||
from mcp.server.runner import modern_error_data, serve_one
|
||||
from mcp.server.streamable_http import check_accept_headers
|
||||
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
@@ -65,7 +62,7 @@ from mcp.shared.inbound import (
|
||||
find_duplicated_routing_header,
|
||||
validate_mcp_param_headers,
|
||||
)
|
||||
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
|
||||
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
@@ -74,7 +71,6 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||
|
||||
_OK_STATUS = 200
|
||||
|
||||
@@ -125,37 +121,20 @@ class _SingleExchangeDispatchContext:
|
||||
await self.notify("notifications/progress", params)
|
||||
|
||||
|
||||
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
|
||||
"""Validate the classifier's raw envelope value into a typed model.
|
||||
|
||||
Rung 1 guarantees the envelope key was present; a ``null`` or mis-shaped
|
||||
value falls through to ``ValidationError`` and is treated as not supplied
|
||||
so the request still routes.
|
||||
"""
|
||||
try:
|
||||
return model.model_validate(raw, by_name=False)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
async def _to_jsonrpc_response(
|
||||
request_id: RequestId, coro: Awaitable[dict[str, Any]]
|
||||
) -> JSONRPCResponse | JSONRPCError:
|
||||
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
|
||||
|
||||
The exception-to-wire boundary for the modern HTTP entry, composed around
|
||||
`serve_one`. `MCPError` and `ValidationError` map via the shared
|
||||
`handler_exception_to_error_data` ladder; any other exception is logged and
|
||||
surfaced as `INTERNAL_ERROR` so handler internals never reach the wire.
|
||||
`serve_one`: `modern_error_data` maps the shared ladder and surfaces
|
||||
anything else as a generic `INTERNAL_ERROR` so handler internals never
|
||||
reach the wire.
|
||||
"""
|
||||
try:
|
||||
result = await coro
|
||||
except Exception as exc:
|
||||
error = handler_exception_to_error_data(exc)
|
||||
if error is None:
|
||||
logger.exception("request handler raised")
|
||||
error = ErrorData(code=INTERNAL_ERROR, message="Internal server error")
|
||||
return JSONRPCError(jsonrpc="2.0", id=request_id, error=error)
|
||||
return JSONRPCError(jsonrpc="2.0", id=request_id, error=modern_error_data(exc))
|
||||
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
|
||||
|
||||
|
||||
@@ -251,8 +230,6 @@ async def _tool_input_schema(
|
||||
logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation")
|
||||
return None
|
||||
seen_cursors: set[str] = set()
|
||||
client_info = _typed(Implementation, verdict.client_info)
|
||||
client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities)
|
||||
dctx = _SingleExchangeDispatchContext(
|
||||
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
|
||||
request_id=request_id,
|
||||
@@ -260,7 +237,9 @@ async def _tool_input_schema(
|
||||
)
|
||||
for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
|
||||
# Fresh Connection per page: serve_one tears down the connection's exit stack on the way out.
|
||||
connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities)
|
||||
connection = Connection.from_envelope(
|
||||
verdict.protocol_version, verdict.client_info, verdict.client_capabilities
|
||||
)
|
||||
try:
|
||||
result = await serve_one(
|
||||
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
|
||||
@@ -409,8 +388,8 @@ async def handle_modern_request(
|
||||
|
||||
connection = Connection.from_envelope(
|
||||
verdict.protocol_version,
|
||||
_typed(Implementation, verdict.client_info),
|
||||
_typed(ClientCapabilities, verdict.client_capabilities),
|
||||
verdict.client_info,
|
||||
verdict.client_capabilities,
|
||||
)
|
||||
dctx = _SingleExchangeDispatchContext(
|
||||
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
|
||||
|
||||
@@ -42,7 +42,7 @@ from mcp_types import (
|
||||
)
|
||||
from mcp_types import methods as _methods
|
||||
from mcp_types.version import LATEST_HANDSHAKE_VERSION
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import deprecated
|
||||
|
||||
from mcp.shared.dispatcher import CallOptions, Outbound
|
||||
@@ -68,6 +68,23 @@ _RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = {
|
||||
}
|
||||
|
||||
|
||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||
|
||||
|
||||
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
|
||||
"""Validate a raw envelope value into a typed model.
|
||||
|
||||
A missing, null or mis-shaped value falls through to `ValidationError`
|
||||
and is treated as not supplied so the request still routes. Spec methods
|
||||
are separately re-validated by the kernel's per-version params surface,
|
||||
which types the reserved `_meta` keys strictly.
|
||||
"""
|
||||
try:
|
||||
return model.model_validate(raw, by_name=False)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None:
|
||||
if not meta:
|
||||
return payload
|
||||
@@ -100,26 +117,18 @@ class _NoChannelOutbound:
|
||||
_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)
|
||||
|
||||
@@ -180,26 +189,34 @@ class Connection:
|
||||
def from_envelope(
|
||||
cls,
|
||||
protocol_version: str,
|
||||
client_info: Implementation | None,
|
||||
client_capabilities: ClientCapabilities | None,
|
||||
client_info: Any,
|
||||
client_capabilities: Any,
|
||||
*,
|
||||
outbound: Outbound = _NO_CHANNEL,
|
||||
) -> Connection:
|
||||
"""A born-ready connection populated from a request's `_meta` envelope.
|
||||
|
||||
`initialized` is set and the envelope's client info/capabilities (when
|
||||
both supplied) are recorded as `client_params` so capability checks
|
||||
work. `outbound` defaults to the no-channel sentinel for the
|
||||
single-exchange HTTP path; duplex modern transports (e.g. stdio) pass
|
||||
a notify-only wrapper around the dispatcher so server notifications
|
||||
ride the pipe while server-initiated requests stay refused.
|
||||
`protocol_version` must be an already-validated version string - the
|
||||
inbound classification ladder owns rejecting non-string or unsupported
|
||||
values. `client_info` and `client_capabilities` are the raw envelope
|
||||
values: this constructor owns turning them into connection identity,
|
||||
identically on every modern entry, so a mis-shaped value degrades to
|
||||
not-supplied rather than failing the request. `initialized`
|
||||
is set and the info/capabilities (when both supplied and well-formed)
|
||||
are recorded as `client_params` so capability checks work. `outbound`
|
||||
defaults to the no-channel sentinel for the single-exchange HTTP path;
|
||||
duplex modern transports (e.g. stdio) pass a notify-only wrapper
|
||||
around the dispatcher so server notifications ride the pipe while
|
||||
server-initiated requests stay refused.
|
||||
"""
|
||||
info = _typed(Implementation, client_info)
|
||||
capabilities = _typed(ClientCapabilities, client_capabilities)
|
||||
client_params = None
|
||||
if client_info is not None and client_capabilities is not None:
|
||||
if info is not None and capabilities is not None:
|
||||
client_params = InitializeRequestParams(
|
||||
protocol_version=protocol_version,
|
||||
capabilities=client_capabilities,
|
||||
client_info=client_info,
|
||||
capabilities=capabilities,
|
||||
client_info=info,
|
||||
)
|
||||
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
|
||||
connection.initialized.set()
|
||||
@@ -230,7 +247,12 @@ class Connection:
|
||||
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
|
||||
@@ -255,7 +277,9 @@ class Connection:
|
||||
|
||||
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)
|
||||
|
||||
@@ -316,7 +340,9 @@ class Connection:
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -691,8 +691,8 @@ class Server(Generic[LifespanResultT]):
|
||||
|
||||
Thin wrapper over `serve_dual_era_loop`: enters the server lifespan,
|
||||
then drives the loop, serving the legacy handshake era and the modern
|
||||
per-request-envelope era (the first era-distinctive message locks the
|
||||
connection). Transports with their own lifespan owner (the
|
||||
per-request-envelope era (the first era-distinctive message to succeed
|
||||
locks the connection). Transports with their own lifespan owner (the
|
||||
streamable-HTTP manager) call `serve_loop` directly instead.
|
||||
"""
|
||||
async with self.lifespan(self) as lifespan_context:
|
||||
|
||||
+118
-43
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
@@ -59,7 +59,7 @@ from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
|
||||
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
||||
from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
|
||||
@@ -415,13 +415,14 @@ async def serve_loop(
|
||||
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,
|
||||
@@ -467,6 +468,23 @@ def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str,
|
||||
return {"supported": list(MODERN_PROTOCOL_VERSIONS)}
|
||||
|
||||
|
||||
def modern_error_data(exc: Exception) -> ErrorData:
|
||||
"""Map a modern request's handler exception to its wire `ErrorData`.
|
||||
|
||||
The exception-to-wire fact shared by the modern entries (the
|
||||
single-exchange HTTP path and the dual-era stream loop), so an identical
|
||||
modern request fails identically on every transport: `MCPError` and
|
||||
`ValidationError` map via the shared `handler_exception_to_error_data`
|
||||
ladder; anything else is logged server-side and surfaced as a generic
|
||||
INTERNAL_ERROR so handler internals never reach the wire.
|
||||
"""
|
||||
error = handler_exception_to_error_data(exc)
|
||||
if error is not None:
|
||||
return error
|
||||
logger.exception("modern request handler raised")
|
||||
return ErrorData(code=INTERNAL_ERROR, message="Internal server error")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _NoServerRequestsDispatchContext:
|
||||
"""Delegating `DispatchContext` that refuses server-initiated requests.
|
||||
@@ -481,7 +499,11 @@ class _NoServerRequestsDispatchContext:
|
||||
|
||||
@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:
|
||||
@@ -529,24 +551,39 @@ async def serve_dual_era_loop(
|
||||
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
|
||||
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. The lock
|
||||
settles exactly once: a request from the other era that was already in
|
||||
flight when the lock committed may still complete and its response
|
||||
stands, but the era does not move; and a success the peer cancelled away
|
||||
(it sees "Request cancelled", not the result) does not lock either.
|
||||
"""
|
||||
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
||||
read_stream,
|
||||
@@ -563,6 +600,15 @@ async def serve_dual_era_loop(
|
||||
era: Literal["unlocked", "legacy", "modern"] = "unlocked"
|
||||
modern_version = LATEST_MODERN_VERSION
|
||||
|
||||
def era_settles(dctx: DispatchContext[TransportContext]) -> bool:
|
||||
# The one definition of "this request may lock the era": it settled as
|
||||
# a client-visible success on a still-unlocked connection. The lock is
|
||||
# monotone - the first success wins, so a straggling request from the
|
||||
# other era can never overwrite a committed lock. A pending peer
|
||||
# cancel means the dispatcher is about to replace this response with
|
||||
# "Request cancelled": the client never sees the success, no lock.
|
||||
return era == "unlocked" and not dctx.cancel_requested.is_set()
|
||||
|
||||
async def serve_modern(
|
||||
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
@@ -570,8 +616,6 @@ async def serve_dual_era_loop(
|
||||
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
|
||||
@@ -585,37 +629,58 @@ async def serve_dual_era_loop(
|
||||
route.client_capabilities,
|
||||
outbound=standalone_outbound,
|
||||
)
|
||||
return await serve_one(
|
||||
server,
|
||||
_NoServerRequestsDispatchContext(dctx),
|
||||
method,
|
||||
params,
|
||||
connection=connection,
|
||||
lifespan_state=lifespan_state,
|
||||
)
|
||||
try:
|
||||
result = await serve_one(
|
||||
server,
|
||||
_NoServerRequestsDispatchContext(dctx),
|
||||
method,
|
||||
params,
|
||||
connection=connection,
|
||||
lifespan_state=lifespan_state,
|
||||
)
|
||||
except (MCPError, ValidationError):
|
||||
# The dispatcher's shared ladder maps these to the same wire error
|
||||
# the modern HTTP entry produces.
|
||||
raise
|
||||
except Exception as exc:
|
||||
if raise_exceptions:
|
||||
raise
|
||||
error = modern_error_data(exc)
|
||||
raise MCPError(code=error.code, message=error.message, data=error.data) from exc
|
||||
if era_settles(dctx):
|
||||
era, modern_version = "modern", route.protocol_version
|
||||
return result
|
||||
|
||||
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":
|
||||
if method == "initialize" and era_settles(dctx):
|
||||
# Lock only on success: a failed handshake leaves both eras open.
|
||||
era = "legacy"
|
||||
return result
|
||||
@@ -670,8 +735,11 @@ def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> O
|
||||
Wire this into the server side of a `DirectDispatcher` peer-pair to drive an
|
||||
in-process server on the modern per-request-envelope path (each request
|
||||
carries protocol version, client info, and capabilities in `params._meta`;
|
||||
no `initialize` handshake). Like `serve_one`, this raises whatever the
|
||||
handler chain raises - the dispatcher owns the exception-to-error mapping.
|
||||
no `initialize` handshake). The dispatch context is wrapped in the
|
||||
server-requests denial, so the modern prohibition on server-initiated
|
||||
JSON-RPC requests holds on this entry like on the others. Like `serve_one`,
|
||||
this raises whatever the handler chain raises - the dispatcher owns the
|
||||
exception-to-error mapping.
|
||||
"""
|
||||
|
||||
async def handle(
|
||||
@@ -683,6 +751,13 @@ def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> O
|
||||
meta.get(CLIENT_INFO_META_KEY),
|
||||
meta.get(CLIENT_CAPABILITIES_META_KEY),
|
||||
)
|
||||
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
|
||||
return await serve_one(
|
||||
server,
|
||||
_NoServerRequestsDispatchContext(dctx),
|
||||
method,
|
||||
params,
|
||||
connection=connection,
|
||||
lifespan_state=lifespan_state,
|
||||
)
|
||||
|
||||
return handle
|
||||
|
||||
@@ -385,8 +385,11 @@ def classify_inbound_request(
|
||||
body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs
|
||||
before the supported-version rung so a client that disagrees with itself
|
||||
is told so, rather than told the body's version is unsupported.
|
||||
3. The envelope's protocol version is in `supported_modern_versions` →
|
||||
else :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
|
||||
3. The envelope's protocol version is a string in
|
||||
`supported_modern_versions` → non-string values are
|
||||
:data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a
|
||||
negotiation outcome), else
|
||||
:data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
|
||||
`data = {"supported": [...], "requested": <value>}`.
|
||||
|
||||
Method existence is *not* a rung: kernel dispatch owns that decision so
|
||||
@@ -411,9 +414,11 @@ def classify_inbound_request(
|
||||
message="params._meta must carry the reserved protocol-version, client-info and "
|
||||
"client-capabilities envelope keys",
|
||||
)
|
||||
|
||||
if headers is not None:
|
||||
if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
|
||||
version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER)
|
||||
# Presence is checked explicitly: a null body version would otherwise
|
||||
# slip the equality check (None == None) and mask the absent header.
|
||||
if version_header is None or version_header != protocol_version:
|
||||
return InboundLadderRejection(
|
||||
code=HEADER_MISMATCH,
|
||||
message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version",
|
||||
@@ -434,6 +439,20 @@ def classify_inbound_request(
|
||||
message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter",
|
||||
)
|
||||
|
||||
if not isinstance(protocol_version, str):
|
||||
# Rung 3's precondition: 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`. Sits after the header rung, which fires first
|
||||
# for every header-bearing entry (an absent version header is a
|
||||
# mismatch, and a present one is a string that can never equal a
|
||||
# non-string body value) - so this rejection is reachable only on
|
||||
# header-less transports.
|
||||
return InboundLadderRejection(
|
||||
code=INVALID_PARAMS,
|
||||
message="the protocol-version envelope value must be a string",
|
||||
)
|
||||
|
||||
if protocol_version not in supported_modern_versions:
|
||||
return InboundLadderRejection(
|
||||
code=UNSUPPORTED_PROTOCOL_VERSION,
|
||||
|
||||
@@ -243,6 +243,26 @@ async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_p
|
||||
assert exc_info.value.__cause__ is None
|
||||
|
||||
|
||||
async def test_modern_inproc_path_refuses_server_initiated_requests():
|
||||
"""The in-process modern entry enforces the same prohibition as the other
|
||||
modern entries: a handler's request-scoped server-initiated request is
|
||||
refused server-side with the no-back-channel contract, instead of the
|
||||
protocol-forbidden frame being delivered to the client."""
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
schema = types.ElicitRequestedSchema(type="object", properties={"x": {"type": "string"}})
|
||||
await ctx.session.elicit_form("question", schema, related_request_id=ctx.request_id)
|
||||
raise AssertionError("unreachable: elicit_form must refuse") # pragma: no cover
|
||||
|
||||
server = Server("test", on_call_tool=handle_call_tool)
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("asker", {})
|
||||
assert exc_info.value.error.code == types.INVALID_REQUEST
|
||||
assert "no back-channel" in exc_info.value.error.message
|
||||
assert "elicitation/create" in exc_info.value.error.message
|
||||
|
||||
|
||||
async def test_get_prompt(app: MCPServer):
|
||||
"""Test getting a prompt."""
|
||||
async with Client(app) as client:
|
||||
@@ -458,6 +478,48 @@ async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_ser
|
||||
assert (await client.list_resources()).resources[0].name == "Test Resource"
|
||||
|
||||
|
||||
async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop(
|
||||
simple_server: Server, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A probe that outlives the client's discover timeout still succeeds on the
|
||||
(slow-starting) server and locks the connection modern; the fallback
|
||||
handshake's -32022 is modern evidence, so one corrective re-probe completes
|
||||
the connect instead of stranding `mode='auto'`."""
|
||||
monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05)
|
||||
c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
|
||||
relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
|
||||
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
|
||||
|
||||
async def relay() -> None:
|
||||
# Hold the client's first frame (the probe) until its second frame (the
|
||||
# post-timeout initialize) arrives - the deterministic stand-in for a
|
||||
# server too slow to answer before the client's discover timeout.
|
||||
held: SessionMessage | Exception | None = None
|
||||
first = True
|
||||
async for item in c2relay_recv:
|
||||
if first:
|
||||
held, first = item, False
|
||||
continue
|
||||
if held is not None:
|
||||
await relay2s_send.send(held)
|
||||
held = None
|
||||
await relay2s_send.send(item)
|
||||
|
||||
@asynccontextmanager
|
||||
async def transport() -> AsyncIterator[TransportStreams]:
|
||||
async with c2relay_send, c2relay_recv, relay2s_send, relay2s_recv, s2c_send, s2c_recv:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(simple_server.run, relay2s_recv, s2c_send, simple_server.create_initialization_options())
|
||||
tg.start_soon(relay)
|
||||
yield s2c_recv, c2relay_send
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
with anyio.fail_after(10):
|
||||
async with Client(transport(), mode="auto") as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert (await client.list_resources()).resources[0].name == "Test Resource"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
|
||||
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
|
||||
"""`mode='auto'`: any JSON-RPC error from `server/discover` makes
|
||||
|
||||
@@ -24,6 +24,7 @@ from mcp_types import (
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
REQUEST_TIMEOUT,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
Implementation,
|
||||
ServerCapabilities,
|
||||
@@ -45,12 +46,16 @@ class _StubSession:
|
||||
"""Minimal stand-in for `ClientSession` exposing only what `negotiate_auto` touches.
|
||||
|
||||
`send_discover` plays back a script (raise an exception, or return a dict);
|
||||
`initialize` and `adopt` just record that they were called.
|
||||
`initialize` raises the next entry of an optional `handshake` exception
|
||||
script (succeeding once it is exhausted) and records its calls; `adopt`
|
||||
just records.
|
||||
"""
|
||||
|
||||
def __init__(self, *script: dict[str, Any] | Exception) -> None:
|
||||
def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exception] | None = None) -> None:
|
||||
self._script: list[dict[str, Any] | Exception] = list(script)
|
||||
self._handshake: list[Exception] = list(handshake or [])
|
||||
self.probed_at: list[str] = []
|
||||
self.initialize_calls: int = 0
|
||||
self.initialized: bool = False
|
||||
self.adopted: types.DiscoverResult | None = None
|
||||
|
||||
@@ -62,6 +67,9 @@ class _StubSession:
|
||||
return step
|
||||
|
||||
async def initialize(self) -> None:
|
||||
self.initialize_calls += 1
|
||||
if self._handshake:
|
||||
raise self._handshake.pop(0)
|
||||
self.initialized = True
|
||||
|
||||
def adopt(self, result: types.DiscoverResult) -> None:
|
||||
@@ -201,6 +209,77 @@ async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_
|
||||
assert session.adopted is None
|
||||
|
||||
|
||||
# --- -32022 from the fallback handshake: modern evidence, one re-probe ---
|
||||
|
||||
|
||||
async def test_handshake_unsupported_after_a_timed_out_probe_reprobes_and_adopts() -> None:
|
||||
"""A probe that times out client-side but succeeds on a slow-starting
|
||||
server locks the connection modern, so the fallback handshake answers
|
||||
-32022. That code is itself modern evidence: re-probe once at a version
|
||||
the server names and adopt - the connect must not fail."""
|
||||
session = _StubSession(
|
||||
MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out"),
|
||||
_discover_dict(),
|
||||
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS))],
|
||||
)
|
||||
await _negotiate(session)
|
||||
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
|
||||
assert session.adopted is not None
|
||||
assert session.initialize_calls == 1
|
||||
assert not session.initialized
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
pytest.param({"supported": ["2099-01-01"], "requested": LATEST_MODERN_VERSION}, id="disjoint"),
|
||||
pytest.param(None, id="no-data"),
|
||||
],
|
||||
)
|
||||
async def test_handshake_unsupported_without_a_mutual_version_reraises(data: Any) -> None:
|
||||
"""-32022 from the handshake naming no version we speak (or nothing
|
||||
parseable) leaves nothing to retry with - the error propagates."""
|
||||
session = _StubSession(
|
||||
MCPError(code=METHOD_NOT_FOUND, message="nope"),
|
||||
handshake=[MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="already modern", data=data)],
|
||||
)
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await _negotiate(session)
|
||||
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
|
||||
assert session.adopted is None
|
||||
assert not session.initialized
|
||||
|
||||
|
||||
async def test_handshake_unsupported_reprobes_at_most_once() -> None:
|
||||
"""The handshake-driven re-probe is bounded: if the second attempt also
|
||||
ends in a timed-out probe and a -32022 handshake, the -32022 propagates
|
||||
instead of looping."""
|
||||
timeout = MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out")
|
||||
session = _StubSession(
|
||||
timeout,
|
||||
timeout,
|
||||
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS))],
|
||||
)
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await _negotiate(session)
|
||||
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
|
||||
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
|
||||
assert session.initialize_calls == 2
|
||||
|
||||
|
||||
async def test_any_other_handshake_error_propagates_unchanged() -> None:
|
||||
"""A non--32022 error from the fallback handshake is a real handshake
|
||||
failure, not era evidence - it propagates without a re-probe."""
|
||||
session = _StubSession(
|
||||
MCPError(code=METHOD_NOT_FOUND, message="nope"),
|
||||
handshake=[MCPError(code=INTERNAL_ERROR, message="handshake broke")],
|
||||
)
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await _negotiate(session)
|
||||
assert exc_info.value.code == INTERNAL_ERROR
|
||||
assert session.probed_at == [LATEST_MODERN_VERSION]
|
||||
|
||||
|
||||
# --- non-MCP errors propagate ---
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ async def test_each_callback_declares_its_own_capability() -> None:
|
||||
async def test_the_modern_in_memory_path_has_no_back_channel() -> None:
|
||||
"""The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`."""
|
||||
async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client:
|
||||
with pytest.raises(MCPError, match="Method not found"):
|
||||
with pytest.raises(MCPError, match="no back-channel"):
|
||||
await client.call_tool("issue_card")
|
||||
|
||||
|
||||
|
||||
+224
-5
@@ -30,6 +30,7 @@ from mcp_types import (
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
JSONRPCRequest,
|
||||
ListToolsResult,
|
||||
NotificationParams,
|
||||
PaginatedRequestParams,
|
||||
@@ -1331,8 +1332,9 @@ async def test_dual_era_loop_initialize_after_modern_lock_without_a_parseable_ve
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(server: SrvT):
|
||||
"""After a successful handshake the connection is legacy for its lifetime:
|
||||
`server/discover` and envelope-bearing requests are rejected with
|
||||
INVALID_REQUEST while plain legacy requests keep working."""
|
||||
envelope-bearing requests (including a triple-stamped `server/discover`)
|
||||
are rejected with INVALID_REQUEST while plain legacy requests keep
|
||||
working."""
|
||||
async with dual_era_client(server) as (client, _):
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
@@ -1347,6 +1349,21 @@ async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(
|
||||
assert "locked to the legacy handshake era" in discover_exc.value.error.message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_bare_discover_after_legacy_lock_is_byte_identical(server: SrvT):
|
||||
"""A bare `server/discover` on a legacy-locked connection falls through to
|
||||
the loop runner's per-version surface validation - the same
|
||||
METHOD_NOT_FOUND shape a handshake-only server produced, byte for byte
|
||||
(released probing clients key on it)."""
|
||||
async with dual_era_client(server) as (client, _):
|
||||
await client.send_raw_request("initialize", _initialize_params())
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("server/discover", None)
|
||||
assert exc_info.value.error.code == METHOD_NOT_FOUND
|
||||
assert exc_info.value.error.message == "Method not found"
|
||||
assert exc_info.value.error.data == "server/discover"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_unsupported_modern_version_rejects_without_locking(server: SrvT):
|
||||
"""A probe at an unknown modern version gets -32022 with the supported
|
||||
@@ -1402,15 +1419,60 @@ async def test_dual_era_loop_modern_request_without_envelope_rejects(server: Srv
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_rejects_subscriptions_listen_on_modern(server: SrvT):
|
||||
"""`subscriptions/listen` is rejected before dispatch on the stream-pair
|
||||
modern path: the registered handler assumes the HTTP entry's stream
|
||||
semantics."""
|
||||
modern path (the registered handler assumes the HTTP entry's stream
|
||||
semantics) - and like every failed request it does not lock the era, so
|
||||
the legacy handshake stays available."""
|
||||
async with dual_era_client(server) as (client, _):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("subscriptions/listen", _modern_params())
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
assert exc_info.value.error.code == METHOD_NOT_FOUND
|
||||
assert "not served over this transport" in exc_info.value.error.message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT):
|
||||
"""The envelope triple with mis-shaped values fails the request but never
|
||||
locks the era: the lock commits only when a modern request SUCCEEDS, so a
|
||||
buggy client's initialize fallback still works (it must never see -32022
|
||||
for a request that failed)."""
|
||||
params: dict[str, Any] = {"_meta": {**_modern_envelope(), CLIENT_INFO_META_KEY: 42}}
|
||||
async with dual_era_client(server) as (client, _):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("tools/list", params)
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT):
|
||||
"""A well-formed modern request for an unknown method fails without
|
||||
locking; the next modern request locks on its own success."""
|
||||
async with dual_era_client(server) as (client, _):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("nope/missing", _modern_params())
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
assert exc_info.value.error.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_initialize_with_envelope_takes_the_handshake_path(server: SrvT):
|
||||
"""`initialize` is legacy-distinctive by definition - it does not exist at
|
||||
modern versions - so stamping the envelope triple on it still runs the
|
||||
handshake and locks legacy."""
|
||||
init_params = {**_initialize_params(), **_modern_params()}
|
||||
async with dual_era_client(server) as (client, _):
|
||||
init = await client.send_raw_request("initialize", init_params)
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("tools/list", _modern_params())
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_modern_notification_dispatches_at_locked_version(server: SrvT):
|
||||
"""Notifications carry no envelope, so on a modern-locked connection they
|
||||
@@ -1480,6 +1542,156 @@ async def test_dual_era_loop_modern_refuses_server_initiated_requests(server: Sr
|
||||
assert "no back-channel" in exc_info.value.error.message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_late_modern_success_does_not_overwrite_a_committed_legacy_lock():
|
||||
"""The era settles exactly once, on the FIRST client-visible success: a
|
||||
modern request that was already in flight when a legacy handshake
|
||||
committed may still complete - its response stands - but the connection
|
||||
stays legacy, so the handshaked client is never stranded."""
|
||||
entered = anyio.Event()
|
||||
release = anyio.Event()
|
||||
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
|
||||
|
||||
parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools)
|
||||
async with dual_era_client(parked) as (client, _):
|
||||
modern_result: dict[str, Any] = {}
|
||||
|
||||
async def modern_call() -> None:
|
||||
modern_result.update(await client.send_raw_request("tools/list", _modern_params()))
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(modern_call)
|
||||
# The modern dispatch is parked in its handler before the
|
||||
# handshake frame is even written, so the initialize commits first.
|
||||
await entered.wait()
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
release.set()
|
||||
assert modern_result["tools"][0]["name"] == "t"
|
||||
# The straggler's success did not move the era: plain legacy requests
|
||||
# still serve (a modern overwrite would demand the envelope triple).
|
||||
result = await client.send_raw_request("tools/list", None)
|
||||
assert result["tools"][0]["name"] == "t"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_modern_success_cancelled_away_at_the_response_write_never_locks():
|
||||
"""A peer cancel that lands while the handler is finishing means the
|
||||
dispatcher replaces the computed result with "Request cancelled" - the
|
||||
client never sees the success, so the era must not lock and the legacy
|
||||
handshake must stay available."""
|
||||
entered = anyio.Event()
|
||||
release = anyio.Event()
|
||||
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
entered.set()
|
||||
# Survive the interrupt-mode scope cancel so the handler completes
|
||||
# with the cancel pending - the cancellation is then delivered at the
|
||||
# dispatcher's response-write checkpoint, after the era commit ran.
|
||||
with anyio.CancelScope(shield=True):
|
||||
await release.wait()
|
||||
return ListToolsResult(tools=[])
|
||||
|
||||
parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools)
|
||||
async with dual_era_client(parked) as (client, _):
|
||||
failures: list[MCPError] = []
|
||||
|
||||
async def modern_call() -> None:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("tools/list", _modern_params())
|
||||
failures.append(exc_info.value)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(modern_call)
|
||||
await entered.wait()
|
||||
# First request on a fresh dispatcher pair, so its id is 1.
|
||||
await client.notify("notifications/cancelled", {"requestId": 1})
|
||||
# The read loop handles frames in order: this marker's response
|
||||
# proves the cancel was processed before the handler resumes.
|
||||
with pytest.raises(MCPError):
|
||||
await client.send_raw_request("probe/marker", None)
|
||||
release.set()
|
||||
assert failures[0].error.message == "Request cancelled"
|
||||
# The cancelled-away success never locked the era.
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_maps_unmapped_handler_exceptions_like_the_modern_http_entry():
|
||||
"""An unmapped handler exception on a modern request surfaces as the
|
||||
generic INTERNAL_ERROR - the same boundary as the modern HTTP entry - so
|
||||
handler internals never reach the wire. (The dispatcher's code-0
|
||||
catch-all is a handshake-era compat pin and stays legacy-only.) The
|
||||
failed request never locks, so the handshake stays available."""
|
||||
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
raise RuntimeError("handler internals")
|
||||
|
||||
exploding = Server(name="exploding-server", version="0.0.1", on_list_tools=list_tools)
|
||||
async with dual_era_client(exploding) as (client, _):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.send_raw_request("tools/list", _modern_params())
|
||||
init = await client.send_raw_request("initialize", _initialize_params())
|
||||
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
|
||||
assert exc_info.value.error.code == INTERNAL_ERROR
|
||||
assert exc_info.value.error.message == "Internal server error"
|
||||
assert "handler internals" not in str(exc_info.value.error)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_raise_exceptions_reraises_unmapped_modern_handler_exceptions():
|
||||
"""Debug mode keeps its contract on the modern path: an unmapped handler
|
||||
exception still propagates out of the loop instead of being swallowed
|
||||
into the generic INTERNAL_ERROR mapping."""
|
||||
|
||||
async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
exploding = Server(name="exploding-server", version="0.0.1", on_list_tools=list_tools)
|
||||
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
|
||||
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
|
||||
frame = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params=_modern_params())
|
||||
with pytest.RaisesGroup(RuntimeError, flatten_subgroups=True, allow_unwrapped=True):
|
||||
async with anyio.create_task_group() as tg, c2s_send, c2s_recv, s2c_send, s2c_recv:
|
||||
tg.start_soon(
|
||||
partial(
|
||||
serve_dual_era_loop, exploding, c2s_recv, s2c_send, lifespan_state=_LIFESPAN, raise_exceptions=True
|
||||
)
|
||||
)
|
||||
with anyio.fail_after(5): # pragma: no branch - group exit misreports the with arcs
|
||||
await c2s_send.send(SessionMessage(message=frame))
|
||||
# The dispatcher answers on the wire before re-raising; waiting
|
||||
# for the answer keeps the streams open until the handler ran.
|
||||
await s2c_recv.receive()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dual_era_loop_custom_method_with_mis_shaped_envelope_values_still_routes():
|
||||
"""A mis-shaped clientInfo envelope value degrades to not-supplied - the
|
||||
`Connection.from_envelope` coercion the modern HTTP entry uses - so a
|
||||
custom method (no kernel params surface to re-reject it) serves
|
||||
identically on both transports, with `client_params is None`."""
|
||||
seen: list[object] = []
|
||||
|
||||
async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
|
||||
seen.append(ctx.session.client_params)
|
||||
return {"ok": True}
|
||||
|
||||
greeter = Server(name="greeter-server", version="0.0.1")
|
||||
greeter.add_request_handler("custom/greet", RequestParams, greet)
|
||||
params = _modern_params()
|
||||
params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object"
|
||||
async with dual_era_client(greeter) as (client, _):
|
||||
result = await client.send_raw_request("custom/greet", params)
|
||||
assert result == {"ok": True}
|
||||
assert seen == [None]
|
||||
|
||||
|
||||
def test_has_modern_envelope_requires_the_full_key_triple():
|
||||
assert not _has_modern_envelope(None)
|
||||
assert not _has_modern_envelope({})
|
||||
@@ -1526,7 +1738,8 @@ async def test_no_server_requests_dispatch_context_denies_requests_and_delegates
|
||||
inner = _RecordingInnerDctx()
|
||||
wrapper = _NoServerRequestsDispatchContext(inner)
|
||||
assert wrapper.can_send_request is False
|
||||
assert wrapper.transport is inner.transport
|
||||
# The transport metadata is masked to agree with the wrapper's denial.
|
||||
assert wrapper.transport == TransportContext(kind="jsonrpc", can_send_request=False)
|
||||
assert wrapper.request_id == 7
|
||||
assert wrapper.message_metadata is None
|
||||
assert wrapper.cancel_requested is inner.cancel_requested
|
||||
@@ -1538,6 +1751,12 @@ async def test_no_server_requests_dispatch_context_denies_requests_and_delegates
|
||||
assert inner.progresses == [0.5]
|
||||
|
||||
|
||||
def test_no_server_requests_dispatch_context_passes_an_already_denying_transport_through():
|
||||
inner = _RecordingInnerDctx()
|
||||
inner.transport = TransportContext(kind="jsonrpc", can_send_request=False)
|
||||
assert _NoServerRequestsDispatchContext(inner).transport is inner.transport
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notify_only_outbound_forwards_notifications_and_refuses_requests():
|
||||
inner = _RecordingInnerDctx()
|
||||
|
||||
@@ -16,6 +16,7 @@ from mcp_types import (
|
||||
JSONRPCResponse,
|
||||
jsonrpc_message_adapter,
|
||||
)
|
||||
from typing_extensions import Buffer
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.stdio import stdio_server
|
||||
@@ -123,10 +124,11 @@ class _GatedStdin(io.RawIOBase):
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def readinto(self, b: bytearray | memoryview) -> int: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
def readinto(self, b: Buffer) -> int:
|
||||
view = memoryview(b)
|
||||
if self._pending:
|
||||
n = min(len(b), len(self._pending))
|
||||
b[:n] = self._pending[:n]
|
||||
n = min(len(view), len(self._pending))
|
||||
view[:n] = self._pending[:n]
|
||||
self._pending = self._pending[n:]
|
||||
return n
|
||||
# A missed release falls through to EOF after the bound; the caller's
|
||||
@@ -155,7 +157,7 @@ class _NotifyingStdout(io.RawIOBase):
|
||||
def writable(self) -> bool:
|
||||
return True
|
||||
|
||||
def write(self, b: bytes | bytearray | memoryview) -> int: # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
def write(self, b: Buffer) -> int:
|
||||
data = bytes(b)
|
||||
with self._cond:
|
||||
self._chunks.append(data)
|
||||
|
||||
@@ -128,6 +128,42 @@ def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None:
|
||||
assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [7, None, ["2026-07-28"]], ids=["int", "null", "list"])
|
||||
def test_envelope_rung_rejects_non_string_protocol_version(version: Any) -> None:
|
||||
"""A present-but-non-string protocol version is a shape defect, rejected
|
||||
INVALID_PARAMS: it must never become -32022 (the one code auto-negotiating
|
||||
clients do not fall back from), and must not escape as a ValidationError
|
||||
from the version rung's own typed payload (`requested` is a `str` field)."""
|
||||
body = envelope()
|
||||
body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = version
|
||||
rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS)
|
||||
assert "string" in rejection.message
|
||||
|
||||
|
||||
def test_non_string_protocol_version_over_http_still_rejects_at_the_header_rung() -> None:
|
||||
"""SDK-defined: the non-string guard sits after the header rung, so over
|
||||
HTTP a present version header (a string, which can never equal a
|
||||
non-string body value) keeps producing HEADER_MISMATCH - the guard's wire
|
||||
delta is confined to header-less transports."""
|
||||
body = envelope()
|
||||
headers = matching_headers(body)
|
||||
body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = 7
|
||||
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("version", [7, None], ids=["int", "null"])
|
||||
def test_absent_version_header_rejects_before_the_string_guard(version: Any) -> None:
|
||||
"""SDK-defined: the version header must be PRESENT, not merely equal - a
|
||||
null body version would otherwise slip the equality check (None == None)
|
||||
- so an absent header is HEADER_MISMATCH for every body value and the
|
||||
string guard stays reachable only on header-less transports."""
|
||||
body = envelope()
|
||||
headers = matching_headers(body)
|
||||
del headers[MCP_PROTOCOL_VERSION_HEADER]
|
||||
body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = version
|
||||
assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH)
|
||||
|
||||
|
||||
# --- rung 2: protocol-version-supported ----------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user