diff --git a/docs/advanced/subscriptions.md b/docs/advanced/subscriptions.md index 46014ef77..6ff85dd86 100644 --- a/docs/advanced/subscriptions.md +++ b/docs/advanced/subscriptions.md @@ -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. diff --git a/docs/client/protocol-versions.md b/docs/client/protocol-versions.md index 0d4b9ab97..43624549c 100644 --- a/docs/client/protocol-versions.md +++ b/docs/client/protocol-versions.md @@ -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"` diff --git a/docs/migration.md b/docs/migration.md index a671ea493..6cf4913f2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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. diff --git a/src/mcp/client/_probe.py b/src/mcp/client/_probe.py index 39a5c5296..7e0754119 100644 --- a/src/mcp/client/_probe.py +++ b/src/mcp/client/_probe.py @@ -11,6 +11,12 @@ 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 diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 52a9a7017..f61251156 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -22,7 +22,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ logger = logging.getLogger(__name__) -_ModelT = TypeVar("_ModelT", bound=BaseModel) _OK_STATUS = 200 @@ -125,37 +121,20 @@ async def progress(self, progress: float, total: float | None = None, message: s 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), diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 73e775a91..8cb7dc421 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -42,7 +42,7 @@ ) 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 @@ } +_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 @@ 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) @@ -180,26 +189,34 @@ def __init__( 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 @@ 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 @@ -255,7 +277,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) @@ -316,7 +340,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) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 81eaa2b86..dc8356396 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -691,8 +691,8 @@ async def run( 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: diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index d5783a598..3b53335ae 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -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 @@ -59,7 +59,7 @@ 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_modern( 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_modern( 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 @@ async def handle( 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 diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index a1baf0f6e..c3e0ea338 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -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": }`. 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, diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 5b4cc5478..f8c02c973 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -243,6 +243,26 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ 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 diff --git a/tests/client/test_probe.py b/tests/client/test_probe.py index 34a347fa7..3b5977bfb 100644 --- a/tests/client/test_probe.py +++ b/tests/client/test_probe.py @@ -24,6 +24,7 @@ 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 @@ async def send_discover(self, version: str) -> dict[str, Any]: 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 --- diff --git a/tests/docs_src/test_client_callbacks.py b/tests/docs_src/test_client_callbacks.py index b615c4700..420bfe8d7 100644 --- a/tests/docs_src/test_client_callbacks.py +++ b/tests/docs_src/test_client_callbacks.py @@ -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") diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 8281e8897..29d3f07fa 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -30,6 +30,7 @@ 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 wants_roots(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: 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() diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index f0c8b1c29..218e34d5a 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -16,6 +16,7 @@ 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 @@ def __init__(self, payload: bytes) -> None: 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 @@ def __init__(self) -> None: 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) diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 8ba9cb935..2bf9c3641 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -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 ----------------------------------------