Skip to content

Commit f35d966

Browse files
committed
Settle the era lock exactly once and unify the modern-entry boundaries
The dual-era loop's lock-on-success rule approximated "client-visible success" with "the handler returned", leaving windows where a request the client saw fail still moved the era: a pipelined envelope-bearing request completing after an inline initialize overwrote the committed legacy lock (stranding the freshly-handshaked client), and a peer cancel landing during the handler's shielded teardown locked the era while the client received "Request cancelled". Era commits now run through a single era_settles predicate: monotone (the first success wins, a straggler from the other era can never move a committed lock) and skipped when a pending cancel is about to replace the response. Two modern-path divergences between the stream loop and the HTTP entry close by sharing one pipeline: Connection.from_envelope takes the classifier's raw envelope values and owns the tolerant coercion (custom methods now degrade mis-shaped clientInfo identically everywhere), and a shared modern_error_data boundary maps unmapped handler exceptions to a generic INTERNAL_ERROR instead of the legacy code-0 catch-all putting str(e) on the wire. The classifier's non-string protocol-version guard moves below the header rung, so the HTTP wire is untouched whenever the version header is present, while the guard still backstops the header-absent null-version corner that previously escaped as a ValidationError.
1 parent 6a2ee51 commit f35d966

6 files changed

Lines changed: 307 additions & 74 deletions

File tree

src/mcp/server/_streamable_http_modern.py

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,18 @@
2222
import logging
2323
from collections.abc import Awaitable, Mapping
2424
from dataclasses import dataclass, field
25-
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
25+
from typing import TYPE_CHECKING, Any, Final, cast
2626

2727
import anyio
2828
from anyio.streams.memory import MemoryObjectSendStream
2929
from mcp_types import (
3030
CLIENT_CAPABILITIES_META_KEY,
3131
CLIENT_INFO_META_KEY,
3232
HEADER_MISMATCH,
33-
INTERNAL_ERROR,
3433
INVALID_REQUEST,
3534
PARSE_ERROR,
3635
PROTOCOL_VERSION_META_KEY,
37-
ClientCapabilities,
3836
ErrorData,
39-
Implementation,
4037
JSONRPCError,
4138
JSONRPCNotification,
4239
JSONRPCRequest,
@@ -45,13 +42,13 @@
4542
RequestId,
4643
)
4744
from mcp_types import methods as _methods
48-
from pydantic import BaseModel, ValidationError
45+
from pydantic import ValidationError
4946
from starlette.requests import Request
5047
from starlette.responses import Response
5148
from starlette.types import Receive, Scope, Send
5249

5350
from mcp.server.connection import Connection
54-
from mcp.server.runner import serve_one
51+
from mcp.server.runner import modern_error_data, serve_one
5552
from mcp.server.streamable_http import check_accept_headers
5653
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
5754
from mcp.shared.dispatcher import CallOptions
@@ -65,7 +62,7 @@
6562
find_duplicated_routing_header,
6663
validate_mcp_param_headers,
6764
)
68-
from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params
65+
from mcp.shared.jsonrpc_dispatcher import progress_token_from_params
6966
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
7067
from mcp.shared.transport_context import TransportContext
7168

@@ -74,7 +71,6 @@
7471

7572
logger = logging.getLogger(__name__)
7673

77-
_ModelT = TypeVar("_ModelT", bound=BaseModel)
7874

7975
_OK_STATUS = 200
8076

@@ -125,37 +121,20 @@ async def progress(self, progress: float, total: float | None = None, message: s
125121
await self.notify("notifications/progress", params)
126122

127123

128-
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
129-
"""Validate the classifier's raw envelope value into a typed model.
130-
131-
Rung 1 guarantees the envelope key was present; a ``null`` or mis-shaped
132-
value falls through to ``ValidationError`` and is treated as not supplied
133-
so the request still routes.
134-
"""
135-
try:
136-
return model.model_validate(raw, by_name=False)
137-
except ValidationError:
138-
return None
139-
140-
141124
async def _to_jsonrpc_response(
142125
request_id: RequestId, coro: Awaitable[dict[str, Any]]
143126
) -> JSONRPCResponse | JSONRPCError:
144127
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
145128
146129
The exception-to-wire boundary for the modern HTTP entry, composed around
147-
`serve_one`. `MCPError` and `ValidationError` map via the shared
148-
`handler_exception_to_error_data` ladder; any other exception is logged and
149-
surfaced as `INTERNAL_ERROR` so handler internals never reach the wire.
130+
`serve_one`: `modern_error_data` maps the shared ladder and surfaces
131+
anything else as a generic `INTERNAL_ERROR` so handler internals never
132+
reach the wire.
150133
"""
151134
try:
152135
result = await coro
153136
except Exception as exc:
154-
error = handler_exception_to_error_data(exc)
155-
if error is None:
156-
logger.exception("request handler raised")
157-
error = ErrorData(code=INTERNAL_ERROR, message="Internal server error")
158-
return JSONRPCError(jsonrpc="2.0", id=request_id, error=error)
137+
return JSONRPCError(jsonrpc="2.0", id=request_id, error=modern_error_data(exc))
159138
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
160139

161140

@@ -251,16 +230,16 @@ async def _tool_input_schema(
251230
logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation")
252231
return None
253232
seen_cursors: set[str] = set()
254-
client_info = _typed(Implementation, verdict.client_info)
255-
client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities)
256233
dctx = _SingleExchangeDispatchContext(
257234
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
258235
request_id=request_id,
259236
message_metadata=ServerMessageMetadata(request_context=request),
260237
)
261238
for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
262239
# Fresh Connection per page: serve_one tears down the connection's exit stack on the way out.
263-
connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities)
240+
connection = Connection.from_envelope(
241+
verdict.protocol_version, verdict.client_info, verdict.client_capabilities
242+
)
264243
try:
265244
result = await serve_one(
266245
app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state
@@ -409,8 +388,8 @@ async def handle_modern_request(
409388

410389
connection = Connection.from_envelope(
411390
verdict.protocol_version,
412-
_typed(Implementation, verdict.client_info),
413-
_typed(ClientCapabilities, verdict.client_capabilities),
391+
verdict.client_info,
392+
verdict.client_capabilities,
414393
)
415394
dctx = _SingleExchangeDispatchContext(
416395
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),

src/mcp/server/connection.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
)
4343
from mcp_types import methods as _methods
4444
from mcp_types.version import LATEST_HANDSHAKE_VERSION
45-
from pydantic import BaseModel
45+
from pydantic import BaseModel, ValidationError
4646
from typing_extensions import deprecated
4747

4848
from mcp.shared.dispatcher import CallOptions, Outbound
@@ -68,6 +68,23 @@
6868
}
6969

7070

71+
_ModelT = TypeVar("_ModelT", bound=BaseModel)
72+
73+
74+
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
75+
"""Validate a raw envelope value into a typed model.
76+
77+
A missing, null or mis-shaped value falls through to `ValidationError`
78+
and is treated as not supplied so the request still routes. Spec methods
79+
are separately re-validated by the kernel's per-version params surface,
80+
which types the reserved `_meta` keys strictly.
81+
"""
82+
try:
83+
return model.model_validate(raw, by_name=False)
84+
except ValidationError:
85+
return None
86+
87+
7188
def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None:
7289
if not meta:
7390
return payload
@@ -172,26 +189,34 @@ def __init__(
172189
def from_envelope(
173190
cls,
174191
protocol_version: str,
175-
client_info: Implementation | None,
176-
client_capabilities: ClientCapabilities | None,
192+
client_info: Any,
193+
client_capabilities: Any,
177194
*,
178195
outbound: Outbound = _NO_CHANNEL,
179196
) -> Connection:
180197
"""A born-ready connection populated from a request's `_meta` envelope.
181198
182-
`initialized` is set and the envelope's client info/capabilities (when
183-
both supplied) are recorded as `client_params` so capability checks
184-
work. `outbound` defaults to the no-channel sentinel for the
185-
single-exchange HTTP path; duplex modern transports (e.g. stdio) pass
186-
a notify-only wrapper around the dispatcher so server notifications
187-
ride the pipe while server-initiated requests stay refused.
199+
`protocol_version` must be an already-validated version string - the
200+
inbound classification ladder owns rejecting non-string or unsupported
201+
values. `client_info` and `client_capabilities` are the raw envelope
202+
values: this constructor owns turning them into connection identity,
203+
identically on every modern entry, so a mis-shaped value degrades to
204+
not-supplied rather than failing the request. `initialized`
205+
is set and the info/capabilities (when both supplied and well-formed)
206+
are recorded as `client_params` so capability checks work. `outbound`
207+
defaults to the no-channel sentinel for the single-exchange HTTP path;
208+
duplex modern transports (e.g. stdio) pass a notify-only wrapper
209+
around the dispatcher so server notifications ride the pipe while
210+
server-initiated requests stay refused.
188211
"""
212+
info = _typed(Implementation, client_info)
213+
capabilities = _typed(ClientCapabilities, client_capabilities)
189214
client_params = None
190-
if client_info is not None and client_capabilities is not None:
215+
if info is not None and capabilities is not None:
191216
client_params = InitializeRequestParams(
192217
protocol_version=protocol_version,
193-
capabilities=client_capabilities,
194-
client_info=client_info,
218+
capabilities=capabilities,
219+
client_info=info,
195220
)
196221
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
197222
connection.initialized.set()

src/mcp/server/runner.py

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
6060
from mcp.shared.exceptions import MCPError, NoBackChannelError
6161
from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request
62-
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
62+
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
6363
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
6464
from mcp.shared.transport_context import TransportContext
6565

@@ -468,6 +468,23 @@ def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str,
468468
return {"supported": list(MODERN_PROTOCOL_VERSIONS)}
469469

470470

471+
def modern_error_data(exc: Exception) -> ErrorData:
472+
"""Map a modern request's handler exception to its wire `ErrorData`.
473+
474+
The exception-to-wire fact shared by the modern entries (the
475+
single-exchange HTTP path and the dual-era stream loop), so an identical
476+
modern request fails identically on every transport: `MCPError` and
477+
`ValidationError` map via the shared `handler_exception_to_error_data`
478+
ladder; anything else is logged server-side and surfaced as a generic
479+
INTERNAL_ERROR so handler internals never reach the wire.
480+
"""
481+
error = handler_exception_to_error_data(exc)
482+
if error is not None:
483+
return error
484+
logger.exception("modern request handler raised")
485+
return ErrorData(code=INTERNAL_ERROR, message="Internal server error")
486+
487+
471488
@dataclass
472489
class _NoServerRequestsDispatchContext:
473490
"""Delegating `DispatchContext` that refuses server-initiated requests.
@@ -562,7 +579,11 @@ async def serve_dual_era_loop(
562579
(`initialize`, `server/discover`) that completes before the next frame is
563580
read, so the canonical probe-then-go flow is race-free; a pinned-modern
564581
client that pipelines frames ahead of its first response should expect
565-
envelope-less notifications sent in that window to be dropped.
582+
envelope-less notifications sent in that window to be dropped. The lock
583+
settles exactly once: a request from the other era that was already in
584+
flight when the lock committed may still complete and its response
585+
stands, but the era does not move; and a success the peer cancelled away
586+
(it sees "Request cancelled", not the result) does not lock either.
566587
"""
567588
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
568589
read_stream,
@@ -579,6 +600,15 @@ async def serve_dual_era_loop(
579600
era: Literal["unlocked", "legacy", "modern"] = "unlocked"
580601
modern_version = LATEST_MODERN_VERSION
581602

603+
def era_settles(dctx: DispatchContext[TransportContext]) -> bool:
604+
# The one definition of "this request may lock the era": it settled as
605+
# a client-visible success on a still-unlocked connection. The lock is
606+
# monotone - the first success wins, so a straggling request from the
607+
# other era can never overwrite a committed lock. A pending peer
608+
# cancel means the dispatcher is about to replace this response with
609+
# "Request cancelled": the client never sees the success, no lock.
610+
return era == "unlocked" and not dctx.cancel_requested.is_set()
611+
582612
async def serve_modern(
583613
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
584614
) -> dict[str, Any]:
@@ -599,18 +629,25 @@ async def serve_modern(
599629
route.client_capabilities,
600630
outbound=standalone_outbound,
601631
)
602-
result = await serve_one(
603-
server,
604-
_NoServerRequestsDispatchContext(dctx),
605-
method,
606-
params,
607-
connection=connection,
608-
lifespan_state=lifespan_state,
609-
)
610-
if era != "modern":
611-
# Lock only on success, mirroring the legacy side: a request that
612-
# failed (malformed envelope content, unknown method) must not
613-
# strand the client, whose initialize fallback stays available.
632+
try:
633+
result = await serve_one(
634+
server,
635+
_NoServerRequestsDispatchContext(dctx),
636+
method,
637+
params,
638+
connection=connection,
639+
lifespan_state=lifespan_state,
640+
)
641+
except (MCPError, ValidationError):
642+
# The dispatcher's shared ladder maps these to the same wire error
643+
# the modern HTTP entry produces.
644+
raise
645+
except Exception as exc:
646+
if raise_exceptions:
647+
raise
648+
error = modern_error_data(exc)
649+
raise MCPError(code=error.code, message=error.message, data=error.data) from exc
650+
if era_settles(dctx):
614651
era, modern_version = "modern", route.protocol_version
615652
return result
616653

@@ -643,7 +680,7 @@ async def on_request(
643680
if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
644681
return await serve_modern(dctx, method, params)
645682
result = await loop_runner.on_request(dctx, method, params)
646-
if method == "initialize":
683+
if method == "initialize" and era_settles(dctx):
647684
# Lock only on success: a failed handshake leaves both eras open.
648685
era = "legacy"
649686
return result

src/mcp/shared/inbound.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -377,16 +377,19 @@ def classify_inbound_request(
377377
Rungs, in order — first failure wins:
378378
379379
1. `params._meta` is a mapping carrying every reserved envelope key
380-
(protocol version, client info, client capabilities), and the protocol
381-
version is a string → else :data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
380+
(protocol version, client info, client capabilities) → else
381+
:data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
382382
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
383383
protocol version, `Mcp-Method` equals `body.method`, and — for the
384384
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
385385
body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs
386386
before the supported-version rung so a client that disagrees with itself
387387
is told so, rather than told the body's version is unsupported.
388-
3. The envelope's protocol version is in `supported_modern_versions` →
389-
else :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
388+
3. The envelope's protocol version is a string in
389+
`supported_modern_versions` → non-string values are
390+
:data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a
391+
negotiation outcome), else
392+
:data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
390393
`data = {"supported": [...], "requested": <value>}`.
391394
392395
Method existence is *not* a rung: kernel dispatch owns that decision so
@@ -411,15 +414,6 @@ def classify_inbound_request(
411414
message="params._meta must carry the reserved protocol-version, client-info and "
412415
"client-capabilities envelope keys",
413416
)
414-
if not isinstance(protocol_version, str):
415-
# A shape defect, not a version-negotiation outcome: -32022 is the one
416-
# code auto-negotiating clients do NOT fall back from, and the typed
417-
# rung-3 payload itself requires a string `requested`.
418-
return InboundLadderRejection(
419-
code=INVALID_PARAMS,
420-
message="the protocol-version envelope value must be a string",
421-
)
422-
423417
if headers is not None:
424418
if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
425419
return InboundLadderRejection(
@@ -442,6 +436,18 @@ def classify_inbound_request(
442436
message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter",
443437
)
444438

439+
if not isinstance(protocol_version, str):
440+
# Rung 3's precondition: a shape defect, not a version-negotiation
441+
# outcome - -32022 is the one code auto-negotiating clients do NOT
442+
# fall back from, and the typed rung-3 payload itself requires a
443+
# string `requested`. Sits after the header rung so the HTTP wire is
444+
# untouched when the version header is present (a string header can
445+
# never equal a non-string body value, so rung 2 fires first there).
446+
return InboundLadderRejection(
447+
code=INVALID_PARAMS,
448+
message="the protocol-version envelope value must be a string",
449+
)
450+
445451
if protocol_version not in supported_modern_versions:
446452
return InboundLadderRejection(
447453
code=UNSUPPORTED_PROTOCOL_VERSION,

0 commit comments

Comments
 (0)