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
commit 080f2a869d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 652 additions and 125 deletions

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

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),

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)

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:

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

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,