Harden the dual-era stream loop's era-lock and rejection semantics (#3040)

This commit is contained in:
Max
2026-07-01 17:07:12 +01:00
committed by GitHub
parent d39c68df23
commit 080f2a869d
15 changed files with 652 additions and 125 deletions
+6
View File
@@ -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.
+3 -3
View File
@@ -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"`
+2
View File
@@ -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.
+24 -2
View File
@@ -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
+13 -34
View File
@@ -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),
+51 -25
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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
+23 -4
View File
@@ -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,
+62
View File
@@ -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
+81 -2
View File
@@ -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 ---
+1 -1
View File
@@ -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
View File
@@ -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()
+6 -4
View File
@@ -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)
+36
View File
@@ -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 ----------------------------------------