Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)

This commit is contained in:
Max 2026-06-21 19:34:17 +01:00 • committed by GitHub
commit 2397319a68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2256 additions and 1088 deletions

View file

@ -69,9 +69,6 @@ server:
- json-schema-2020-12 - json-schema-2020-12
# --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) ---
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode,
# _meta-derived capabilities, error-code mappings, or server/discover yet.
- server-stateless
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented. # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
- input-required-result-basic-elicitation - input-required-result-basic-elicitation
- input-required-result-basic-sampling - input-required-result-basic-sampling
@ -83,14 +80,12 @@ server:
- input-required-result-result-type - input-required-result-result-type
- input-required-result-tampered-state - input-required-result-tampered-state
- input-required-result-capability-check - input-required-result-capability-check
- input-required-result-validate-input # SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and # against the request body is not implemented.
# case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation - http-header-validation
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# --- WARNING-only entries --- # expected-failures evaluator counts WARNINGs as failures (the summary line
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but # only shows passed/failed, not warnings, so a local re-probe can mis-read
# the expected-failures evaluator counts WARNINGs as failures. Same entries # these as stale).
# as the draft suite in expected-failures.yml.
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- input-required-result-missing-input-response - input-required-result-missing-input-response
- input-required-result-validate-input

View file

@ -34,12 +34,7 @@ client:
server: server:
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) --- # --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
# SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode, # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
# _meta-derived capabilities, error-code mappings, or server/discover yet.
- server-stateless
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented;
# most scenarios currently fail early with "Missing session ID" because
# mcp-everything-server only runs in stateful mode.
- input-required-result-basic-elicitation - input-required-result-basic-elicitation
- input-required-result-basic-sampling - input-required-result-basic-sampling
- input-required-result-basic-list-roots - input-required-result-basic-list-roots
@ -50,17 +45,12 @@ server:
- input-required-result-result-type - input-required-result-result-type
- input-required-result-tampered-state - input-required-result-tampered-state
- input-required-result-capability-check - input-required-result-capability-check
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and # SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# case-insensitive/whitespace-trimmed header validation not implemented. # against the request body is not implemented.
- http-header-validation - http-header-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNING-only entries: these scenarios emit no FAILURE checks but the
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. # expected-failures evaluator counts WARNINGs as failures (the summary line
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses). # only shows passed/failed, not warnings, so a local re-probe can mis-read
# these as stale).
- input-required-result-missing-input-response - input-required-result-missing-input-response
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
# now baselined (added when the stateless path landed — the stateless server
# reaches the handler, so the previous accidental pass via -32600 "Missing
# session ID" no longer applies). input-required-result-unsupported-methods
# is intentionally NOT baselined: it still passes for now; add it once it
# starts failing for real.
- input-required-result-validate-input - input-required-result-validate-input

View file

@ -19,6 +19,20 @@ If you call `MCPServer.call_tool()` directly, read `.content` and
`.structured_content` off the returned `CallToolResult` instead of branching on `.structured_content` off the returned `CallToolResult` instead of branching on
the result type. the result type.
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside
an `@mcp.tool()` handler now produces a top-level JSON-RPC error response with
the raised `code`, `message`, and `data` intact. Previously the tool wrapper
caught it like any other exception and returned `CallToolResult(isError=True)`,
which discarded the error code and structured `data`.
`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it
when the request itself should be rejected (missing client capability,
elicitation required, invalid parameters). For tool *execution* failures the
calling LLM should see and react to, raise any other exception or return
`CallToolResult(is_error=True, ...)` directly; that path is unchanged.
### `streamablehttp_client` removed ### `streamablehttp_client` removed
The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead.
@ -487,6 +501,18 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru
If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor.
### Streamable HTTP: lifespan now entered once at manager startup
When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently.
Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (the public surface for reaching it from high-level `@mcp.tool()` handlers is being finalised as part of the public-surface review).
### `Server.run()` no longer takes a `stateless` flag; `StatelessModeNotSupported` removed
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
`StatelessModeNotSupported` has been removed. Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. If you were catching `StatelessModeNotSupported`, catch `NoBackChannelError` instead.
### `MCPServer.get_context()` removed ### `MCPServer.get_context()` removed
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. `MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
@ -1202,8 +1228,8 @@ from mcp.server import ServerRequestContext
session = ServerSession(read_stream, write_stream, init_options, stateless=False) session = ServerSession(read_stream, write_stream, init_options, stateless=False)
# After (v2) # After (v2)
session = ServerSession(dispatcher, connection, stateless=False) session = ServerSession(request_outbound, connection)
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection # where `request_outbound` is an Outbound and `connection` is a Connection
``` ```
In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up. In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up.

View file

@ -14,6 +14,7 @@ from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage from mcp.server.mcpserver.prompts.base import UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError
from mcp.types import ( from mcp.types import (
AudioContent, AudioContent,
Completion, Completion,
@ -32,6 +33,7 @@ from mcp.types import (
TextResourceContents, TextResourceContents,
UnsubscribeRequestParams, UnsubscribeRequestParams,
) )
from mcp.types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -311,6 +313,26 @@ def test_error_handling() -> str:
raise RuntimeError("This tool intentionally returns an error for testing") raise RuntimeError("This tool intentionally returns an error for testing")
@mcp.tool()
async def test_missing_capability(ctx: Context) -> str:
"""Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error.
Requires the client to declare the ``sampling`` capability. When absent, raises
`MCPError` (which the tool dispatch re-raises rather than wrapping in
``CallToolResult.isError``) so the conformance harness observes a protocol-level
error response with ``data.requiredCapabilities``.
"""
client_params = ctx.session.client_params
sampling_declared = client_params is not None and client_params.capabilities.sampling is not None
if not sampling_declared:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="This tool requires the client 'sampling' capability",
data={"requiredCapabilities": ["sampling"]},
)
return "Client declared sampling capability; proceeding."
@mcp.tool() @mcp.tool()
async def test_reconnection(ctx: Context) -> str: async def test_reconnection(ctx: Context) -> str:
"""Tests SSE polling by closing stream mid-call (SEP-1699)""" """Tests SSE polling by closing stream mid-call (SEP-1699)"""

View file

@ -314,16 +314,29 @@ class StreamableHTTPTransport:
logger.debug("Received 202 Accepted") logger.debug("Received 202 Accepted")
return return
if response.status_code == 404:
if isinstance(message, JSONRPCRequest):
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
await ctx.read_stream_writer.send(session_message)
return
if response.status_code >= 400: if response.status_code >= 400:
if isinstance(message, JSONRPCRequest): if isinstance(message, JSONRPCRequest):
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") # A spec-correct server may return the JSON-RPC error in the
# body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404
# for METHOD_NOT_FOUND). Surface that error rather than the
# status-derived stand-in below.
if response.headers.get("content-type", "").lower().startswith("application/json"):
try:
body = await response.aread()
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
if isinstance(parsed, JSONRPCError):
# The server may have set `id: null` (request rejected before its
# id was parsed); use this request's id so correlation works.
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
await ctx.read_stream_writer.send(SessionMessage(reply))
return
except (httpx.StreamError, ValidationError):
pass
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
if response.status_code == 404:
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
else:
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
await ctx.read_stream_writer.send(session_message) await ctx.read_stream_writer.send(session_message)
return return

View file

@ -12,33 +12,32 @@ no per-request task group, no `JSONRPCDispatcher`.
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, TypeVar
import anyio import anyio
import anyio.abc from pydantic import BaseModel, ValidationError
from pydantic import ValidationError
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from starlette.types import Receive, Scope, Send from starlette.types import Receive, Scope, Send
from mcp.server.runner import ( from mcp.server.connection import Connection
_EXIT_STACK_CLOSE_TIMEOUT, # type: ignore[reportPrivateUsage] from mcp.server.runner import serve_one
ServerRunner,
otel_middleware,
)
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest from mcp.shared.dispatcher import CallOptions
from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.inbound import ERROR_CODE_HTTP_STATUS, InboundLadderRejection, classify_inbound_request
from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.types import ( from mcp.types import (
INTERNAL_ERROR, INVALID_REQUEST,
INVALID_PARAMS,
PARSE_ERROR, PARSE_ERROR,
ClientCapabilities,
ErrorData, ErrorData,
Implementation,
JSONRPCError, JSONRPCError,
JSONRPCRequest, JSONRPCRequest,
JSONRPCResponse, JSONRPCResponse,
@ -50,6 +49,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ModelT = TypeVar("_ModelT", bound=BaseModel)
_OK_STATUS = 200
@dataclass @dataclass
class _SingleExchangeDispatchContext: class _SingleExchangeDispatchContext:
@ -64,7 +67,7 @@ class _SingleExchangeDispatchContext:
request_id: RequestId request_id: RequestId
message_metadata: MessageMetadata message_metadata: MessageMetadata
cancel_requested: anyio.Event = field(default_factory=anyio.Event) cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = False can_send_request: bool = field(default=False, init=False)
async def send_raw_request( async def send_raw_request(
self, self,
@ -75,91 +78,51 @@ class _SingleExchangeDispatchContext:
raise NoBackChannelError(method) raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
# TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
return None return None
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
# TODO: no progressToken plumbing yet. # TODO(D-005a): no progressToken plumbing yet; ships with the SSE response mode.
return None return None
class SingleExchangeDispatcher: def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
"""Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST. """Validate the classifier's raw envelope value into a typed model.
The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s Rung 1 guarantees the envelope key was present; a ``null`` or mis-shaped
role). Implements the `Dispatcher` Protocol so `ServerRunner` / value falls through to ``ValidationError`` and is treated as not supplied
`Connection` / `ServerSession` accept it; `run()` is never driven. so the request still routes.
""" """
try:
def __init__(self, request: Request) -> None: return model.model_validate(raw, by_name=False)
self._request = request except ValidationError:
self._tctx = TransportContext(
kind="streamable-http",
can_send_request=False,
headers=request.headers,
)
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
*,
_related_request_id: RequestId | None = None,
) -> dict[str, Any]:
raise NoBackChannelError(method)
async def notify(
self,
method: str,
params: Mapping[str, Any] | None,
*,
_related_request_id: RequestId | None = None,
) -> None:
# TODO: buffer and stream as SSE once the response-mode design lands.
return None return None
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
raise RuntimeError("SingleExchangeDispatcher.run() is never driven; use handle()")
async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCResponse | JSONRPCError: async def _write(
"""Dispatch one request and map any exception to a `JSONRPCError`.""" msg: JSONRPCResponse | JSONRPCError,
dctx = _SingleExchangeDispatchContext( scope: Scope,
transport=self._tctx, receive: Receive,
request_id=req.id, send: Send,
message_metadata=ServerMessageMetadata(request_context=self._request), ) -> None:
) """Serialise a JSON-RPC reply with the table-mapped HTTP status."""
try: status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS
result = await on_request(dctx, req.method, req.params) body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
return JSONRPCResponse(jsonrpc="2.0", id=req.id, result=result) if isinstance(msg, JSONRPCError) and msg.id is None:
except MCPError as e: # JSON-RPC requires `id: null` to appear on the wire when the request
return JSONRPCError(jsonrpc="2.0", id=req.id, error=e.error) # id couldn't be parsed; `exclude_none` would otherwise drop it.
except ValidationError: body["id"] = None
return JSONRPCError( await Response(
jsonrpc="2.0", json.dumps(body, separators=(",", ":")),
id=req.id, status_code=status,
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""), media_type="application/json",
) )(scope, receive, send)
# TODO: consolidate the three exception->ErrorData copies once the
# code=0 compat pin in JSONRPCDispatcher is lifted.
except Exception:
logger.exception("handler for %r raised", req.method)
return JSONRPCError(
jsonrpc="2.0",
id=req.id,
error=ErrorData(code=INTERNAL_ERROR, message="Internal server error"),
)
async def handle_modern_request( async def handle_modern_request(
app: Server[Any], app: Server[Any],
security_settings: TransportSecuritySettings | None, security_settings: TransportSecuritySettings | None,
protocol_version: str, lifespan_state: Any,
scope: Scope, scope: Scope,
receive: Receive, receive: Receive,
send: Send, send: Send,
@ -167,8 +130,9 @@ async def handle_modern_request(
"""ASGI handler for a single stateless-era POST. """ASGI handler for a single stateless-era POST.
Called from `StreamableHTTPSessionManager.handle_request` when the Called from `StreamableHTTPSessionManager.handle_request` when the
`MCP-Protocol-Version` header is in `MODERN_PROTOCOL_VERSIONS`; the header `MCP-Protocol-Version` header names a modern revision; the manager enters
value is passed as `protocol_version`. Never sets `Mcp-Session-Id`. `app.lifespan` once at startup and passes the state in. Never sets
`Mcp-Session-Id`.
""" """
request = Request(scope, receive) request = Request(scope, receive)
@ -178,54 +142,56 @@ async def handle_modern_request(
await err(scope, receive, send) await err(scope, receive, send)
return return
# TODO: validate Accept header once the JSON-vs-SSE response-mode design is settled. # TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled.
if request.method != "POST": if request.method != "POST":
# TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder. # HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
# before JSON-RPC parsing, so it doesn't go through `_write`.
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send) await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
return return
body = await request.body() body = await request.body()
try: try:
req = JSONRPCRequest.model_validate_json(body) decoded = json.loads(body)
except json.JSONDecodeError:
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
await _write(rej, scope, receive, send)
return
try:
req = JSONRPCRequest.model_validate(decoded)
except ValidationError: except ValidationError:
msg = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) # Well-formed JSON that isn't a single request object. The transport
await Response( # spec permits notification POSTs and gives the server two responses
msg.model_dump_json(by_alias=True), # (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
status_code=400, # item 5). The core protocol defines no client→server notifications
media_type="application/json", # over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
)(scope, receive, send) # entry takes the cannot-accept branch. TODO(L57): S4 owns the
# strict-vs-lenient choice.
rej = JSONRPCError(
jsonrpc="2.0",
id=None,
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
)
await _write(rej, scope, receive, send)
return return
dispatcher = SingleExchangeDispatcher(request) verdict = classify_inbound_request(decoded, headers=dict(request.headers))
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893. if isinstance(verdict, InboundLadderRejection):
async with app.lifespan(app) as lifespan_state: rej = JSONRPCError(
runner = ServerRunner( jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data)
server=app,
dispatcher=dispatcher,
lifespan_state=lifespan_state,
has_standalone_channel=False,
stateless=True,
dispatch_middleware=[otel_middleware],
) )
runner.connection.protocol_version = protocol_version await _write(rej, scope, receive, send)
try: return
msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage]
finally:
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as cancel_scope:
try:
await runner.connection.exit_stack.aclose()
except Exception:
logger.exception("connection exit_stack cleanup raised")
if cancel_scope.cancelled_caught:
logger.warning(
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
_EXIT_STACK_CLOSE_TIMEOUT,
)
# TODO: error.code -> HTTP status mapping is a follow-up; 200 for all JSONRPCError bodies for now. connection = Connection.from_envelope(
await Response( verdict.protocol_version,
msg.model_dump_json(by_alias=True, exclude_none=True), _typed(Implementation, verdict.client_info),
status_code=200, _typed(ClientCapabilities, verdict.client_capabilities),
media_type="application/json", )
)(scope, receive, send) dctx = _SingleExchangeDispatchContext(
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=request),
)
msg = await serve_one(app, req, connection=connection, dctx=dctx, lifespan_state=lifespan_state)
await _write(msg, scope, receive, send)

View file

@ -1,18 +1,24 @@
"""`Connection` - per-client connection state and the standalone outbound channel. """`Connection` - per-client connection state and the standalone outbound channel.
Always present on `Context` (never `None`), even in stateless deployments. Always present on `Context` (never `None`), even in stateless deployments.
Holds peer info populated at `initialize` time, per-connection scratch Holds peer info, per-connection scratch `state` and an `exit_stack` for
`state` and an `exit_stack` for teardown, and an `Outbound` for the teardown, and an `Outbound` for the standalone stream (the SSE GET stream in
standalone stream (the SSE GET stream in streamable HTTP, or the single duplex streamable HTTP, or the single duplex stream in stdio).
stream in stdio).
Construct via the factories: `Connection.from_envelope` for the 2026-era
single-exchange path (born ready, no back-channel) and `Connection.for_loop`
for the handshake-driven loop path. Both populate `protocol_version` so the
kernel reads it as a fact.
`notify` is best-effort: it never raises. If there's no standalone channel `notify` is best-effort: it never raises. If there's no standalone channel
(stateless HTTP) or the stream has been dropped, the notification is or the stream has been dropped, the notification is debug-logged and silently
debug-logged and silently discarded - server-initiated notifications are discarded - server-initiated notifications are inherently advisory.
inherently advisory. `send_raw_request` *does* raise `NoBackChannelError` when `send_raw_request` raises `NoBackChannelError` when there's no channel; `ping`
there's no channel; `ping` is the only spec-sanctioned standalone request. is the only spec-sanctioned standalone request.
""" """
from __future__ import annotations
import logging import logging
from collections.abc import Mapping from collections.abc import Mapping
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
@ -26,12 +32,14 @@ from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
from mcp.shared.peer import Meta, dump_params from mcp.shared.peer import Meta, dump_params
from mcp.types import ( from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities, ClientCapabilities,
CreateMessageRequest, CreateMessageRequest,
CreateMessageResult, CreateMessageResult,
ElicitRequest, ElicitRequest,
ElicitResult, ElicitResult,
EmptyResult, EmptyResult,
Implementation,
InitializeRequestParams, InitializeRequestParams,
ListRootsRequest, ListRootsRequest,
ListRootsResult, ListRootsResult,
@ -68,32 +76,57 @@ def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> d
return out return out
class _NoChannelOutbound:
"""Connection-scoped `Outbound` for the no-back-channel case.
The structural answer to "this connection cannot push to its peer":
`send_raw_request` raises `NoBackChannelError`; `notify` drops with a
debug log. `Connection.from_envelope` installs this so the modern
single-exchange path never needs a mode flag - the channel itself says no.
"""
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) -> None:
logger.debug("dropped %s: no standalone channel", method)
_NO_CHANNEL = _NoChannelOutbound()
class Connection: class Connection:
"""Per-client connection state and standalone-stream `Outbound`. """Per-client connection state and standalone-stream `Outbound`.
Constructed by `ServerRunner` once per connection. The peer-info fields Construct via `from_envelope` (modern single-exchange: born ready, no
are `None` until `initialize` completes; `initialized` is set later, when back-channel) or `for_loop` (handshake-driven: ready once the client's
the client's `notifications/initialized` follow-up arrives. In stateless `notifications/initialized` arrives). Either way `protocol_version` is
deployments the runner sets `initialized` immediately and peer-info populated at construction.
remains `None` (no handshake reaches a stateless connection).
""" """
has_standalone_channel: bool outbound: Outbound
"""The connection-scoped channel for server-initiated messages."""
session_id: str | None session_id: str | None
client_params: InitializeRequestParams | None client_params: InitializeRequestParams | None
"""The full `initialize` request params; `None` before initialization.""" """The full `initialize` request params, or the equivalent built from the
2026-era envelope. `None` when no client info was supplied."""
protocol_version: str | None protocol_version: str
"""The protocol version negotiated during `initialize`; `None` before """The protocol version this connection speaks. Populated at construction
initialization. Stateless connections don't require the handshake, so this by the factory and overwritten by `_handle_initialize` once the handshake
normally stays `None` there (a client that sends `initialize` anyway still commits on the loop path."""
commits it). For the per-request value, read `ctx.protocol_version`."""
initialized: anyio.Event initialized: anyio.Event
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`); """Set when `notifications/initialized` arrives (matches TS `oninitialized`);
the point from which the spec permits server-initiated requests beyond the point from which the spec permits server-initiated requests beyond
ping/logging. Pre-set on stateless connections.""" ping/logging. Pre-set on connections built via `from_envelope`."""
state: dict[str, Any] state: dict[str, Any]
"""Per-connection scratch state; persists across requests on this connection.""" """Per-connection scratch state; persists across requests on this connection."""
@ -103,24 +136,83 @@ class Connection:
closes. Push cleanup from handlers or middleware; exceptions are logged closes. Push cleanup from handlers or middleware; exceptions are logged
and swallowed.""" and swallowed."""
def __init__(self, outbound: Outbound, *, has_standalone_channel: bool, session_id: str | None = None) -> None: def __init__(
self._outbound = outbound self,
self.has_standalone_channel = has_standalone_channel outbound: Outbound,
*,
protocol_version: str,
session_id: str | None = None,
client_params: InitializeRequestParams | None = None,
) -> None:
self.outbound = outbound
self.protocol_version = protocol_version
self.session_id = session_id self.session_id = session_id
self.client_params = client_params
self.client_params = None
self.protocol_version = None
self.initialized = anyio.Event() self.initialized = anyio.Event()
self.state = {} self.state = {}
self.exit_stack = AsyncExitStack() self.exit_stack = AsyncExitStack()
@classmethod
def from_envelope(
cls,
protocol_version: str,
client_info: Implementation | None,
client_capabilities: ClientCapabilities | None,
*,
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
the dispatcher so server-initiated messages have a back-channel.
"""
client_params = None
if client_info is not None and client_capabilities is not None:
client_params = InitializeRequestParams(
protocol_version=protocol_version,
capabilities=client_capabilities,
client_info=client_info,
)
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
connection.initialized.set()
return connection
@classmethod
def for_loop(
cls,
outbound: Outbound,
*,
session_id: str | None = None,
protocol_version_hint: str | None = None,
) -> Connection:
"""A connection for the handshake-driven loop path.
Not born-ready: `initialized` is set later by the kernel when
`notifications/initialized` arrives. `protocol_version` is seeded from
the transport hint (or `LATEST_PROTOCOL_VERSION`) so it's never `None`;
the handshake overwrites it once negotiated.
"""
return cls(
outbound,
protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_PROTOCOL_VERSION,
session_id=session_id,
)
@property
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."""
return self.outbound is not _NO_CHANNEL
@property @property
def initialize_accepted(self) -> bool: def initialize_accepted(self) -> bool:
"""True once the inbound request gate is open: `initialize` recorded the """True once the inbound request gate is open: `initialize` recorded the
peer info, or the handshake completed outright (stateless birth, or a peer info, or the handshake completed outright (born-ready, or a bare
bare `notifications/initialized`). Derived, never stored.""" `notifications/initialized`). Derived, never stored."""
return self.client_params is not None or self.initialized.is_set() return self.client_params is not None or self.initialized.is_set()
async def send_raw_request( async def send_raw_request(
@ -140,9 +232,7 @@ class Connection:
MCPError: The peer responded with an error. MCPError: The peer responded with an error.
NoBackChannelError: `has_standalone_channel` is `False`. NoBackChannelError: `has_standalone_channel` is `False`.
""" """
if not self.has_standalone_channel: return await self.outbound.send_raw_request(method, params, opts)
raise NoBackChannelError(method)
return await self._outbound.send_raw_request(method, params, opts)
@overload @overload
async def send_request( async def send_request(
@ -177,11 +267,9 @@ class Connection:
KeyError: `result_type` omitted for a non-spec request type. KeyError: `result_type` omitted for a non-spec request type.
""" """
raw = await self.send_raw_request(req.method, dump_params(req.params), opts) raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
# Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25"
if req.method in _methods.MONOLITH_REQUESTS: if req.method in _methods.MONOLITH_REQUESTS:
try: try:
_methods.validate_client_result(req.method, version, raw) _methods.validate_client_result(req.method, self.protocol_version, raw)
except KeyError: except KeyError:
pass pass
cls = result_type if result_type is not None else _RESULT_FOR[type(req)] cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
@ -193,11 +281,8 @@ class Connection:
Never raises. If there's no standalone channel or the stream is broken, Never raises. If there's no standalone channel or the stream is broken,
the notification is dropped and debug-logged. the notification is dropped and debug-logged.
""" """
if not self.has_standalone_channel:
logger.debug("dropped %s: no standalone channel", method)
return
try: try:
await self._outbound.notify(method, params) await self.outbound.notify(method, params)
except (anyio.BrokenResourceError, anyio.ClosedResourceError): except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped %s: standalone stream closed", method) logger.debug("dropped %s: standalone stream closed", method)
@ -233,9 +318,9 @@ class Connection:
def check_capability(self, capability: ClientCapabilities) -> bool: def check_capability(self, capability: ClientCapabilities) -> bool:
"""Return whether the connected client declared the given capability. """Return whether the connected client declared the given capability.
Returns `False` if `initialize` hasn't completed yet. Returns `False` when no client info has been recorded.
""" """
# TODO: redesign - mirrors v1 ServerSession.check_client_capability # TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
# verbatim for parity. # verbatim for parity.
if self.client_params is None: if self.client_params is None:
return False return False

View file

@ -58,14 +58,13 @@ from mcp.server.auth.routes import build_resource_metadata_url, create_auth_rout
from mcp.server.auth.settings import AuthSettings from mcp.server.auth.settings import AuthSettings
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.models import InitializationOptions from mcp.server.models import InitializationOptions
from mcp.server.runner import ServerRunner, otel_middleware from mcp.server.runner import serve_loop
from mcp.server.streamable_http import EventStore from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import SessionMessage from mcp.shared.message import SessionMessage
from mcp.shared.transport_context import TransportContext from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -117,6 +116,15 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
return types.EmptyResult() return types.EmptyResult()
def _package_version(package: str) -> str:
try:
return importlib_version(package)
except Exception: # pragma: no cover
pass
return "unknown" # pragma: no cover
class Server(Generic[LifespanResultT]): class Server(Generic[LifespanResultT]):
def __init__( def __init__(
self, self,
@ -218,7 +226,7 @@ class Server(Generic[LifespanResultT]):
# Context-tier middleware: wraps every inbound request (including # Context-tier middleware: wraps every inbound request (including
# `initialize`, lookup, validation, handler) with # `initialize`, lookup, validation, handler) with
# `(ctx, method, params, call_next)`. Applied in `ServerRunner._on_request`. # `(ctx, method, params, call_next)`. Applied in `ServerRunner._on_request`.
# TODO(maxisbey): provisional - signature and semantics change with the # TODO(L54): provisional - signature and semantics change with the
# Context/middleware rework (covariant `Context[L]`, outbound seam) before # Context/middleware rework (covariant `Context[L]`, outbound seam) before
# v2 final. # v2 final.
self.middleware: list[ServerMiddleware[LifespanResultT]] = [] self.middleware: list[ServerMiddleware[LifespanResultT]] = []
@ -226,6 +234,7 @@ class Server(Generic[LifespanResultT]):
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
("ping", types.RequestParams, on_ping), ("ping", types.RequestParams, on_ping),
("server/discover", types.RequestParams, self._handle_discover),
("prompts/list", types.PaginatedRequestParams, on_list_prompts), ("prompts/list", types.PaginatedRequestParams, on_list_prompts),
("prompts/get", types.GetPromptRequestParams, on_get_prompt), ("prompts/get", types.GetPromptRequestParams, on_get_prompt),
("resources/list", types.PaginatedRequestParams, on_list_resources), ("resources/list", types.PaginatedRequestParams, on_list_resources),
@ -298,7 +307,7 @@ class Server(Generic[LifespanResultT]):
"""Return the registered entry for a notification method, or `None`.""" """Return the registered entry for a notification method, or `None`."""
return self._notification_handlers.get(method) return self._notification_handlers.get(method)
# TODO: Rethink capabilities API. Currently capabilities are derived from registered # TODO(L53): Rethink capabilities API. Currently capabilities are derived from registered
# handlers but require NotificationOptions to be passed externally for list_changed # handlers but require NotificationOptions to be passed externally for list_changed
# flags, and experimental_capabilities as a separate dict. Consider deriving capabilities # flags, and experimental_capabilities as a separate dict. Consider deriving capabilities
# entirely from server state (e.g. constructor params for list_changed) instead of # entirely from server state (e.g. constructor params for list_changed) instead of
@ -309,18 +318,9 @@ class Server(Generic[LifespanResultT]):
experimental_capabilities: dict[str, dict[str, Any]] | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None,
) -> InitializationOptions: ) -> InitializationOptions:
"""Create initialization options from this server instance.""" """Create initialization options from this server instance."""
def pkg_version(package: str) -> str:
try:
return importlib_version(package)
except Exception: # pragma: no cover
pass
return "unknown" # pragma: no cover
return InitializationOptions( return InitializationOptions(
server_name=self.name, server_name=self.name,
server_version=self.version if self.version else pkg_version("mcp"), server_version=self.version if self.version else _package_version("mcp"),
title=self.title, title=self.title,
description=self.description, description=self.description,
capabilities=self.get_capabilities( capabilities=self.get_capabilities(
@ -334,10 +334,11 @@ class Server(Generic[LifespanResultT]):
def get_capabilities( def get_capabilities(
self, self,
notification_options: NotificationOptions, notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]], experimental_capabilities: dict[str, dict[str, Any]] | None = None,
) -> types.ServerCapabilities: ) -> types.ServerCapabilities:
"""Convert existing handlers to a ServerCapabilities object.""" """Convert existing handlers to a ServerCapabilities object."""
notification_options = notification_options or NotificationOptions()
prompts_capability = None prompts_capability = None
resources_capability = None resources_capability = None
tools_capability = None tools_capability = None
@ -377,6 +378,40 @@ class Server(Generic[LifespanResultT]):
) )
return capabilities return capabilities
@property
def server_info(self) -> types.Implementation:
"""The `serverInfo` block describing this implementation.
Derived from the constructor's identity fields. `version` falls back to
the installed `mcp` package version when not supplied explicitly.
"""
return types.Implementation(
name=self.name,
version=self.version if self.version else _package_version("mcp"),
title=self.title,
description=self.description,
website_url=self.website_url,
icons=self.icons,
)
async def _handle_discover(
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None
) -> types.DiscoverResult:
"""Default `server/discover` handler.
Auto-derived from server state at call time, so capabilities reflect
whatever has been registered (constructor `on_*` kwargs and later
`add_request_handler` calls). Operators can replace it wholesale via
`add_request_handler("server/discover", ...)`. Reachability for legacy
peers is decided at the boundary (`types.methods`), not here.
"""
return types.DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=self.get_capabilities(),
server_info=self.server_info,
instructions=self.instructions,
)
@property @property
def session_manager(self) -> StreamableHTTPSessionManager: def session_manager(self) -> StreamableHTTPSessionManager:
"""Get the StreamableHTTP session manager. """Get the StreamableHTTP session manager.
@ -401,36 +436,22 @@ class Server(Generic[LifespanResultT]):
# but also make tracing exceptions much easier during testing and when using # but also make tracing exceptions much easier during testing and when using
# in-process servers. # in-process servers.
raise_exceptions: bool = False, raise_exceptions: bool = False,
# When True, the server is stateless and
# clients can perform initialization with any node. The client must still follow
# the initialization lifecycle, but can do so with any available node
# rather than requiring initialization for each connection.
stateless: bool = False,
) -> None: ) -> None:
"""Serve a single connection over the given streams until the read side closes.
Thin wrapper over `serve_loop`: enters the server lifespan,
then drives the loop. Transports with their own lifespan owner
(the streamable-HTTP manager) call `serve_loop` directly instead.
"""
async with self.lifespan(self) as lifespan_context: async with self.lifespan(self) as lifespan_context:
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( await serve_loop(
self,
read_stream, read_stream,
write_stream, write_stream,
raise_handler_exceptions=raise_exceptions,
# Handle `initialize` inline so a client that pipelines it with
# the next request (spec says SHOULD NOT, not MUST NOT) sees
# the initialized state instead of failing the init-gate.
inline_methods=frozenset({"initialize"}),
)
runner = ServerRunner(
server=self,
dispatcher=dispatcher,
lifespan_state=lifespan_context, lifespan_state=lifespan_context,
init_options=initialization_options, init_options=initialization_options,
# Stateless HTTP has no standalone GET stream, so server-initiated raise_exceptions=raise_exceptions,
# requests on `runner.connection` must fail fast with
# `NoBackChannelError` rather than write to a channel that will
# never deliver a response.
has_standalone_channel=not stateless,
stateless=stateless,
dispatch_middleware=[otel_middleware],
) )
await runner.run()
def streamable_http_app( def streamable_http_app(
self, self,

View file

@ -10,7 +10,7 @@ from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
from mcp.shared._callable_inspection import is_async_callable from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.shared.exceptions import MCPError
from mcp.shared.tool_name_validation import validate_and_warn_tool_name from mcp.shared.tool_name_validation import validate_and_warn_tool_name
from mcp.types import Icon, ToolAnnotations from mcp.types import Icon, ToolAnnotations
@ -111,9 +111,12 @@ class Tool(BaseModel):
result = self.fn_metadata.convert_result(result) result = self.fn_metadata.convert_result(result)
return result return result
except UrlElicitationRequiredError: except MCPError:
# Re-raise UrlElicitationRequiredError so it can be properly handled # `MCPError` (and subclasses such as `UrlElicitationRequiredError`)
# as an MCP error response with code -32042 # carries a JSON-RPC `ErrorData(code, message, data)` and means
# "respond with a protocol error" - re-raise so the kernel surfaces
# it as a top-level JSON-RPC error rather than wrapping it as a
# `CallToolResult(isError=True)` execution failure.
raise raise
except Exception as e: except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e raise ToolError(f"Error executing tool {self.name}: {e}") from e

View file

@ -1,14 +1,12 @@
"""`ServerRunner` - per-connection orchestrator over a `Dispatcher`. """`ServerRunner` - the per-connection handler kernel.
`ServerRunner` is the bridge between the dispatcher layer (`on_request` / `ServerRunner` bridges the dispatch layer (`on_request` / `on_notify`, untyped
`on_notify`, untyped dicts) and the user's handler layer (typed `Context`, dicts) and the user's handler layer (typed `Context`, typed params). It is a
typed params). One instance per client connection. It: pure kernel: it holds a pre-populated `Connection` and reads
`connection.protocol_version` / `connection.outbound` as facts. Driving a
* handles the `initialize` handshake and populates `Connection` dispatcher loop and tearing down the connection live in the free-function
* gates requests until initialized (`ping` exempt) drivers (`serve_connection`, `serve_loop`, `serve_one`); the entry constructs
* looks up the handler in the server's registry, validates params, builds the `Connection`, the driver tears it down.
`Context`, runs the middleware chain, returns the result dict
* drives `dispatcher.run()` and the per-connection lifespan
`ServerRunner` holds a `Server` directly - `Server` is the registry. `ServerRunner` holds a `Server` directly - `Server` is the registry.
""" """
@ -16,11 +14,12 @@ typed params). One instance per client connection. It:
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping from collections.abc import Awaitable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import KW_ONLY, dataclass
from functools import partial, reduce from functools import cached_property, partial, reduce
from typing import TYPE_CHECKING, Any, Generic, cast from typing import TYPE_CHECKING, Any, Generic, cast
import anyio
import anyio.abc import anyio.abc
from opentelemetry.trace import SpanKind, StatusCode from opentelemetry.trace import SpanKind, StatusCode
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
@ -31,9 +30,11 @@ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, Server
from mcp.server.models import InitializationOptions from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from mcp.shared._otel import extract_trace_context, otel_span from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnRequest from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import ( from mcp.types import (
@ -41,11 +42,14 @@ from mcp.types import (
INVALID_PARAMS, INVALID_PARAMS,
LATEST_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND, METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
ErrorData, ErrorData,
Implementation, Implementation,
InitializeRequestParams, InitializeRequestParams,
InitializeResult, InitializeResult,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
RequestParams, RequestParams,
RequestParamsMeta, RequestParamsMeta,
) )
@ -54,7 +58,17 @@ from mcp.types import methods as _methods
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.lowlevel.server import Server from mcp.server.lowlevel.server import Server
__all__ = ["CallNext", "ServerMiddleware", "ServerRunner", "otel_middleware"] __all__ = [
"CallNext",
"ServerMiddleware",
"ServerRunner",
"aclose_shielded",
"otel_middleware",
"serve_connection",
"serve_loop",
"serve_one",
"to_jsonrpc_response",
]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -64,8 +78,8 @@ LifespanT = TypeVar("LifespanT", default=Any)
_INIT_EXEMPT: frozenset[str] = frozenset({"ping"}) _INIT_EXEMPT: frozenset[str] = frozenset({"ping"})
_EXIT_STACK_CLOSE_TIMEOUT: float = 5 _EXIT_STACK_CLOSE_TIMEOUT: float = 5
"""Bound for the shielded exit-stack unwind in `run()`; a hung cleanup """Bound for `aclose_shielded`'s exit-stack unwind; a hung cleanup callback
callback must not wedge shutdown.""" must not wedge shutdown."""
def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None: def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
@ -79,30 +93,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
return None return None
def _resolve_protocol_version(
negotiated: str | None,
meta: RequestParamsMeta | None,
md: MessageMetadata,
) -> str:
"""Resolve the protocol version for this inbound message.
Handshake-committed value wins; else per-request `_meta`, else the
transport hint. Unsupported values fall through so surface validation
never sees them.
"""
if negotiated is not None:
return negotiated
if meta is not None:
v = meta.get(PROTOCOL_VERSION_META_KEY)
if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS:
return v
if isinstance(md, ServerMessageMetadata):
hint = md.protocol_version
if hint is not None and hint in SUPPORTED_PROTOCOL_VERSIONS:
return hint
return "2025-11-25"
def otel_middleware(next_on_request: OnRequest) -> OnRequest: def otel_middleware(next_on_request: OnRequest) -> OnRequest:
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span. """Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
@ -169,73 +159,73 @@ def _dump_result(result: Any) -> dict[str, Any]:
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None") raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
async def aclose_shielded(connection: Connection) -> None:
"""Unwind ``connection.exit_stack`` under a shielded, bounded scope.
Called from a driver's ``finally``: the shield lets per-connection cleanup
callbacks run even when the driver itself is being cancelled, the
`_EXIT_STACK_CLOSE_TIMEOUT` bound stops a hung callback wedging shutdown,
and a raising callback is logged-and-swallowed so it never masks the
driver's own exception.
"""
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
try:
await connection.exit_stack.aclose()
except Exception:
logger.exception("connection exit_stack cleanup raised")
if scope.cancelled_caught:
logger.warning(
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
_EXIT_STACK_CLOSE_TIMEOUT,
)
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 request-per-call drivers
(`serve_one`, the modern HTTP entry). `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.
"""
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 JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
@dataclass @dataclass
class ServerRunner(Generic[LifespanT]): class ServerRunner(Generic[LifespanT]):
"""Per-connection orchestrator. One instance per client connection.""" """Per-connection handler kernel. One instance per client connection."""
server: Server[LifespanT] server: Server[LifespanT]
dispatcher: Dispatcher[Any] connection: Connection
lifespan_state: LifespanT lifespan_state: LifespanT
has_standalone_channel: bool _: KW_ONLY
init_options: InitializationOptions | None = None init_options: InitializationOptions | None = None
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`.""" """`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""
session_id: str | None = None dispatch_middleware: Sequence[DispatchMiddleware] = (otel_middleware,)
stateless: bool = False
dispatch_middleware: list[DispatchMiddleware] = field(default_factory=list[DispatchMiddleware])
connection: Connection = field(init=False) @cached_property
session: ServerSession = field(init=False) def on_request(self) -> OnRequest:
"""Connection-scoped: the same instance reaches every request as `ctx.session`.""" """`_on_request` wrapped in `dispatch_middleware`, outermost-first.
def __post_init__(self) -> None: Dispatch-tier middleware sees raw `(dctx, method, params) -> dict` and
if self.init_options is None: wraps everything - initialize, METHOD_NOT_FOUND, validation failures
self.init_options = self.server.create_initialization_options() included.
self.connection = Connection(
self.dispatcher, has_standalone_channel=self.has_standalone_channel, session_id=self.session_id
)
if self.stateless:
# No handshake ever arrives on a stateless connection; born ready.
self.connection.initialized.set()
self.session = ServerSession(self.dispatcher, self.connection, stateless=self.stateless)
async def run(self, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None:
"""Drive the dispatcher until the underlying channel closes.
Composes `dispatch_middleware` over `_on_request` and hands the result
to `dispatcher.run()`. `task_status.started()` is forwarded so callers
can `await tg.start(runner.run)` and resume once the dispatcher is
ready to accept requests. Once the dispatcher exits,
`connection.exit_stack` is unwound (shielded from outer cancellation,
bounded by `_EXIT_STACK_CLOSE_TIMEOUT`) so any per-connection cleanup
registered by handlers or middleware gets a chance to run without a
misbehaving callback hanging shutdown indefinitely.
"""
try:
await self.dispatcher.run(self._compose_on_request(), self._on_notify, task_status=task_status)
finally:
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
try:
await self.connection.exit_stack.aclose()
except Exception:
# Raising here would mask dispatcher.run()'s exception and
# crash stdio servers on normal disconnect.
logger.exception("connection exit_stack cleanup raised")
if scope.cancelled_caught:
logger.warning(
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
_EXIT_STACK_CLOSE_TIMEOUT,
)
def _compose_on_request(self) -> OnRequest:
"""Wrap `_on_request` in `dispatch_middleware`, outermost-first.
Dispatch-tier middleware sees raw `(dctx, method, params) -> dict`
and wraps everything - initialize, METHOD_NOT_FOUND, validation
failures included. `run()` calls this once and hands the result to
`dispatcher.run()`.
""" """
return reduce(lambda h, mw: mw(h), reversed(self.dispatch_middleware), self._on_request) return reduce(lambda h, mw: mw(h), reversed(self.dispatch_middleware), self._on_request)
@cached_property
def on_notify(self) -> OnNotify:
return self._on_notify
async def _on_request( async def _on_request(
self, self,
dctx: DispatchContext[TransportContext], dctx: DispatchContext[TransportContext],
@ -243,7 +233,7 @@ class ServerRunner(Generic[LifespanT]):
params: Mapping[str, Any] | None, params: Mapping[str, Any] | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
meta = _extract_meta(params) meta = _extract_meta(params)
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata) version = self.connection.protocol_version
ctx = self._make_context(dctx, meta, version) ctx = self._make_context(dctx, meta, version)
is_spec_method = method in _methods.SPEC_CLIENT_METHODS is_spec_method = method in _methods.SPEC_CLIENT_METHODS
@ -257,7 +247,7 @@ class ServerRunner(Generic[LifespanT]):
_methods.validate_client_request(method, version, params) _methods.validate_client_request(method, version, params)
except KeyError: except KeyError:
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None
# TODO(maxisbey): the 2026-07-28 spec drops the handshake; this branch and # TODO(L29): the 2026-07-28 spec drops the handshake; this branch and
# the gate become a per-version legacy path then. Initialize runs inline # the gate become a per-version legacy path then. Initialize runs inline
# (read loop parked), so awaiting the peer anywhere on this path deadlocks. # (read loop parked), so awaiting the peer anywhere on this path deadlocks.
if method == "initialize": if method == "initialize":
@ -284,7 +274,7 @@ class ServerRunner(Generic[LifespanT]):
call = self._compose_server_middleware(ctx, method, params, _inner) call = self._compose_server_middleware(ctx, method, params, _inner)
result = _dump_result(await call()) result = _dump_result(await call())
# TODO: reject resultType values outside {"complete", "input_required"} unless the # TODO(L56): reject resultType values outside {"complete", "input_required"} unless the
# corresponding extension is in this request's _meta clientCapabilities.extensions; the # corresponding extension is in this request's _meta clientCapabilities.extensions; the
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
if is_spec_method: if is_spec_method:
@ -312,7 +302,7 @@ class ServerRunner(Generic[LifespanT]):
params: Mapping[str, Any] | None, params: Mapping[str, Any] | None,
) -> None: ) -> None:
meta = _extract_meta(params) meta = _extract_meta(params)
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata) version = self.connection.protocol_version
ctx = self._make_context(dctx, meta, version) ctx = self._make_context(dctx, meta, version)
async def _inner() -> None: async def _inner() -> None:
@ -373,7 +363,7 @@ class ServerRunner(Generic[LifespanT]):
def _make_context( def _make_context(
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
) -> ServerRequestContext[LifespanT, Any]: ) -> ServerRequestContext[LifespanT, Any]:
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request # TODO(L54): remove for Context rework. Reads the SHTTP per-request
# data off the raw `dctx.message_metadata` carrier; replace with the # data off the raw `dctx.message_metadata` carrier; replace with the
# per-transport context once that lands. # per-transport context once that lands.
md = dctx.message_metadata md = dctx.message_metadata
@ -383,8 +373,12 @@ class ServerRunner(Generic[LifespanT]):
close_standalone_sse_stream = md.close_standalone_sse_stream close_standalone_sse_stream = md.close_standalone_sse_stream
else: else:
request = close_sse_stream = close_standalone_sse_stream = None request = close_sse_stream = close_standalone_sse_stream = None
# Per-request session: `dctx` is the request-scoped channel (auto-threads
# its own request_id on streamable HTTP); the standalone channel is read
# off `connection.outbound`. `related_request_id` on the public API selects.
session = ServerSession(dctx, self.connection)
return ServerRequestContext( return ServerRequestContext(
session=self.session, session=session,
lifespan_context=self.lifespan_state, lifespan_context=self.lifespan_state,
request_id=dctx.request_id, request_id=dctx.request_id,
meta=meta, meta=meta,
@ -405,8 +399,7 @@ class ServerRunner(Generic[LifespanT]):
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult: def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
"""Build the `initialize` result; state commits later in `_on_request`.""" """Build the `initialize` result; state commits later in `_on_request`."""
_, negotiated = self._negotiate_initialize(params) _, negotiated = self._negotiate_initialize(params)
assert self.init_options is not None opts = self.init_options if self.init_options is not None else self.server.create_initialization_options()
opts = self.init_options
return InitializeResult( return InitializeResult(
protocol_version=negotiated, protocol_version=negotiated,
capabilities=opts.capabilities, capabilities=opts.capabilities,
@ -420,3 +413,82 @@ class ServerRunner(Generic[LifespanT]):
), ),
instructions=opts.instructions, instructions=opts.instructions,
) )
async def serve_connection(
server: Server[LifespanT],
dispatcher: Dispatcher[Any],
*,
connection: Connection,
lifespan_state: LifespanT,
init_options: InitializationOptions | None = None,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Drive ``dispatcher`` until the underlying channel closes.
The loop-mode driver: builds the kernel, hands `on_request`/`on_notify`
to `dispatcher.run()`, and tears down `connection.exit_stack` (shielded)
on the way out. The entry constructs the `Connection`; this only consumes
it.
"""
runner = ServerRunner(server, connection, lifespan_state, init_options=init_options)
try:
await dispatcher.run(runner.on_request, runner.on_notify, task_status=task_status)
finally:
await aclose_shielded(connection)
async def serve_loop(
server: Server[LifespanT],
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
*,
lifespan_state: LifespanT,
session_id: str | None = None,
init_options: InitializationOptions | None = None,
raise_exceptions: bool = False,
) -> None:
"""Drive ``server`` in 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.
"""
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
read_stream,
write_stream,
raise_handler_exceptions=raise_exceptions,
# Handle `initialize` inline so a client that pipelines it with the
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
# state instead of failing the init-gate.
inline_methods=frozenset({"initialize"}),
)
connection = Connection.for_loop(dispatcher, session_id=session_id)
await serve_connection(
server, dispatcher, connection=connection, lifespan_state=lifespan_state, init_options=init_options
)
async def serve_one(
server: Server[LifespanT],
request: JSONRPCRequest,
*,
connection: Connection,
dctx: DispatchContext[TransportContext],
lifespan_state: LifespanT,
) -> JSONRPCResponse | JSONRPCError:
"""Handle a single ``request`` and return its JSON-RPC reply.
The single-exchange driver: builds the kernel, runs `on_request` once for
`request` under `dctx`, maps the outcome to a `JSONRPCResponse` /
`JSONRPCError` via `to_jsonrpc_response`, and tears down
`connection.exit_stack` (shielded) on the way out. The entry constructs
the (born-ready) `Connection` and the `dctx`; this only consumes them.
"""
runner = ServerRunner(server, connection, lifespan_state)
try:
return await to_jsonrpc_response(request.id, runner.on_request(dctx, request.method, request.params))
finally:
await aclose_shielded(connection)

View file

@ -1,15 +1,12 @@
"""`ServerSession`: server-to-client requests and notifications. """`ServerSession`: server-to-client requests and notifications.
A thin proxy over `JSONRPCDispatcher` and `Connection`. One instance per A per-request proxy built by the kernel for each inbound request. Exposes the
client connection (built by `ServerRunner`). Handlers reach it as request-scoped outbound channel and the connection's standalone channel.
`ctx.session` and use the typed helpers (`create_message`, `elicit_form`, Handlers reach it as `ctx.session` and use the typed helpers (`elicit_form`,
`send_log_message`, ...) to call back to the client. `send_log_message`, ...) to call back to the client.
The receive-loop, initialize handling, and per-request task isolation that
used to live here are now owned by `JSONRPCDispatcher` and `ServerRunner`.
""" """
from typing import Any, TypeVar, cast, overload from typing import Any, TypeVar, overload
from pydantic import AnyUrl, BaseModel from pydantic import AnyUrl, BaseModel
from typing_extensions import deprecated from typing_extensions import deprecated
@ -17,8 +14,8 @@ from typing_extensions import deprecated
from mcp import types from mcp import types
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT from mcp.shared.dispatcher import CallOptions, Outbound, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError, StatelessModeNotSupported from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.message import ServerMessageMetadata from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods from mcp.types import methods as _methods
@ -28,35 +25,32 @@ ResultT = TypeVar("ResultT", bound=BaseModel)
class ServerSession: class ServerSession:
"""Connection-scoped proxy for server-to-client requests and notifications. """Per-request proxy for server-to-client requests and notifications.
`send_request` / `send_notification` model-dump their argument and forward Built once per inbound request by the kernel's `_make_context`. Holds two
to the dispatcher; the typed helpers below are unchanged from the previous `Outbound` channels: the request-scoped one (the per-request
implementation and only call those two methods. `DispatchContext`, which on streamable HTTP routes onto the originating
POST's response stream) and the connection's standalone channel
(`connection.outbound`). `related_request_id` on the public methods is the
selector — present means request-scoped, absent means standalone — and
never crosses the `Outbound` Protocol.
""" """
def __init__( def __init__(self, request_outbound: Outbound, connection: Connection) -> None:
self, self._request_outbound = request_outbound
dispatcher: Dispatcher[Any],
connection: Connection,
*,
stateless: bool = False,
) -> None:
self._dispatcher = dispatcher
self._connection = connection self._connection = connection
self._stateless = stateless
@property @property
def client_params(self) -> types.InitializeRequestParams | None: def client_params(self) -> types.InitializeRequestParams | None:
"""The client's `initialize` request params; `None` before initialization.""" """The client's `initialize` request params; `None` when no client info was supplied."""
return self._connection.client_params return self._connection.client_params
@property @property
def protocol_version(self) -> str | None: def protocol_version(self) -> str:
"""The protocol version negotiated during `initialize`. """The protocol version this connection speaks.
`None` before initialization, and normally `None` on stateless Populated at `Connection` construction and overwritten once the
connections. For the per-request value, read `ctx.protocol_version`. handshake commits on the loop path; never `None`.
""" """
return self._connection.protocol_version return self._connection.protocol_version
@ -70,46 +64,23 @@ class ServerSession:
) -> ResultT: ) -> ResultT:
"""Send a typed server-to-client request and validate the result. """Send a typed server-to-client request and validate the result.
`metadata.related_request_id` (when supplied) routes the outgoing
message onto the originating request's response stream over
streamable HTTP; it is the only metadata field honored here.
Raises: Raises:
MCPError: The peer responded with an error. MCPError: The peer responded with an error.
NoBackChannelError: If there is no related request to ride on and NoBackChannelError: The connection has no back-channel for
the connection has no standalone channel (stateless HTTP), so server-initiated requests (raised by the held `Outbound`).
a response could never arrive.
pydantic.ValidationError: The peer's result does not match `result_type`. pydantic.ValidationError: The peer's result does not match `result_type`.
""" """
related = metadata.related_request_id if metadata is not None else None
channel = self._request_outbound if related is not None else self._connection.outbound
data = request.model_dump(by_alias=True, mode="json", exclude_none=True) data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {} opts: CallOptions = {}
if request_read_timeout_seconds is not None: if request_read_timeout_seconds is not None:
opts["timeout"] = request_read_timeout_seconds opts["timeout"] = request_read_timeout_seconds
if progress_callback is not None: if progress_callback is not None:
opts["on_progress"] = progress_callback opts["on_progress"] = progress_callback
related = metadata.related_request_id if metadata is not None else None result = await channel.send_raw_request(data["method"], data.get("params"), opts or None)
if related is None and not self._connection.has_standalone_channel:
# Fail fast instead of parking forever on a response that cannot
# arrive; matches `Connection.send_raw_request`.
raise NoBackChannelError(data["method"])
# TODO: _related_request_id is not on the Dispatcher Protocol (and must not
# be — it's transport-specific). The fix is to give `ctx.session` a per-request
# Outbound (the DispatchContext, which threads its own request_id) alongside
# the connection-level one, with `related_request_id` as the selector; that
# belongs with the ServerSession/Context rework, not here.
result = cast(
"dict[str, Any]",
await self._dispatcher.send_raw_request(
data["method"],
data.get("params"),
opts or None,
_related_request_id=related, # type: ignore[call-arg]
),
)
# Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25"
try: try:
_methods.validate_client_result(request.method, version, result) _methods.validate_client_result(request.method, self.protocol_version, result)
except KeyError: except KeyError:
pass pass
return result_type.model_validate(result, by_name=False) return result_type.model_validate(result, by_name=False)
@ -120,8 +91,9 @@ class ServerSession:
related_request_id: types.RequestId | None = None, related_request_id: types.RequestId | None = None,
) -> None: ) -> None:
"""Send a typed server-to-client notification.""" """Send a typed server-to-client notification."""
channel = self._request_outbound if related_request_id is not None else self._connection.outbound
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg] await channel.notify(data["method"], data.get("params"))
def check_client_capability(self, capability: types.ClientCapabilities) -> bool: def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
"""Check if the client supports a specific capability.""" """Check if the client supports a specific capability."""
@ -236,10 +208,9 @@ class ServerSession:
Raises: Raises:
MCPError: If tools are provided but client doesn't support them. MCPError: If tools are provided but client doesn't support them.
ValueError: If tool_use or tool_result message structure is invalid. ValueError: If tool_use or tool_result message structure is invalid.
StatelessModeNotSupported: If called in stateless HTTP mode. NoBackChannelError: The connection has no back-channel for
server-initiated requests.
""" """
if self._stateless:
raise StatelessModeNotSupported(method="sampling")
client_caps = self.client_params.capabilities if self.client_params else None client_caps = self.client_params.capabilities if self.client_params else None
validate_sampling_tools(client_caps, tools, tool_choice) validate_sampling_tools(client_caps, tools, tool_choice)
validate_tool_use_result_messages(messages) validate_tool_use_result_messages(messages)
@ -274,9 +245,12 @@ class ServerSession:
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
async def list_roots(self) -> types.ListRootsResult: async def list_roots(self) -> types.ListRootsResult:
"""Send a roots/list request.""" """Send a roots/list request.
if self._stateless:
raise StatelessModeNotSupported(method="list_roots") Raises:
NoBackChannelError: The connection has no back-channel for
server-initiated requests.
"""
return await self.send_request( return await self.send_request(
types.ListRootsRequest(), types.ListRootsRequest(),
types.ListRootsResult, types.ListRootsResult,
@ -321,10 +295,9 @@ class ServerSession:
The client's response with form data. The client's response with form data.
Raises: Raises:
StatelessModeNotSupported: If called in stateless HTTP mode. NoBackChannelError: The connection has no back-channel for
server-initiated requests.
""" """
if self._stateless:
raise StatelessModeNotSupported(method="elicitation")
return await self.send_request( return await self.send_request(
types.ElicitRequest( types.ElicitRequest(
params=types.ElicitRequestFormParams( params=types.ElicitRequestFormParams(
@ -358,10 +331,9 @@ class ServerSession:
The client's response indicating acceptance, decline, or cancellation. The client's response indicating acceptance, decline, or cancellation.
Raises: Raises:
StatelessModeNotSupported: If called in stateless HTTP mode. NoBackChannelError: The connection has no back-channel for
server-initiated requests.
""" """
if self._stateless:
raise StatelessModeNotSupported(method="elicitation")
return await self.send_request( return await self.send_request(
types.ElicitRequest( types.ElicitRequest(
params=types.ElicitRequestURLParams( params=types.ElicitRequestURLParams(

View file

@ -28,7 +28,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least from mcp.shared.version import is_version_at_least
from mcp.types import ( from mcp.types import (
DEFAULT_NEGOTIATED_VERSION, DEFAULT_NEGOTIATED_VERSION,
INTERNAL_ERROR, INTERNAL_ERROR,
@ -248,12 +248,11 @@ class StreamableHTTPServerTransport:
metadata = ServerMessageMetadata( metadata = ServerMessageMetadata(
request_context=request, request_context=request,
protocol_version=protocol_version,
close_sse_stream=close_stream_callback, close_sse_stream=close_stream_callback,
close_standalone_sse_stream=close_standalone_stream_callback, close_standalone_sse_stream=close_standalone_stream_callback,
) )
else: else:
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version) metadata = ServerMessageMetadata(request_context=request)
return SessionMessage(message, metadata=metadata) return SessionMessage(message, metadata=metadata)
@ -507,10 +506,7 @@ class StreamableHTTPServerTransport:
await response(scope, receive, send) await response(scope, receive, send)
# Process the message after sending the response # Process the message after sending the response
metadata = ServerMessageMetadata( metadata = ServerMessageMetadata(request_context=request)
request_context=request,
protocol_version=request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION),
)
session_message = SessionMessage(message, metadata=metadata) session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message) await writer.send(session_message)
@ -533,7 +529,7 @@ class StreamableHTTPServerTransport:
if self.is_json_response_enabled: if self.is_json_response_enabled:
# Process the message # Process the message
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version) metadata = ServerMessageMetadata(request_context=request)
session_message = SessionMessage(message, metadata=metadata) session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message) await writer.send(session_message)
try: try:
@ -818,11 +814,10 @@ class StreamableHTTPServerTransport:
await response(request.scope, request.receive, send) await response(request.scope, request.receive, send)
async def _validate_request_headers(self, request: Request, send: Send) -> bool: async def _validate_request_headers(self, request: Request, send: Send) -> bool:
if not await self._validate_session(request, send): # Protocol-version validation lives in the manager's era-routing: only
return False # values in `SUPPORTED_PROTOCOL_VERSIONS` (or no header at all) reach
if not await self._validate_protocol_version(request, send): # this transport, so the legacy version-gate is gone.
return False return await self._validate_session(request, send)
return True
async def _validate_session(self, request: Request, send: Send) -> bool: async def _validate_session(self, request: Request, send: Send) -> bool:
"""Validate the session ID in the request.""" """Validate the session ID in the request."""
@ -853,28 +848,6 @@ class StreamableHTTPServerTransport:
return True return True
async def _validate_protocol_version(self, request: Request, send: Send) -> bool:
"""Validate the protocol version header in the request."""
# Get the protocol version from the request headers
protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
# If no protocol version provided, assume default version
if protocol_version is None:
protocol_version = DEFAULT_NEGOTIATED_VERSION
# Check if the protocol version is supported
if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS:
supported_versions = ", ".join(SUPPORTED_PROTOCOL_VERSIONS)
response = self._create_error_response(
f"Bad Request: Unsupported protocol version: {protocol_version}. "
+ f"Supported versions: {supported_versions}",
HTTPStatus.BAD_REQUEST,
)
await response(request.scope, request.receive, send)
return False
return True
async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None: async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None:
"""Replays events that would have been sent after the specified event ID. """Replays events that would have been sent after the specified event ID.
@ -894,7 +867,7 @@ class StreamableHTTPServerTransport:
if self.mcp_session_id: # pragma: no branch if self.mcp_session_id: # pragma: no branch
headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
# Get protocol version from header (already validated in _validate_protocol_version) # The manager only routes supported (or absent) header values to this transport
replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION) replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION)
# Create SSE stream for replay # Create SSE stream for replay

View file

@ -16,15 +16,20 @@ from starlette.types import Receive, Scope, Send
from mcp.server._streamable_http_modern import handle_modern_request from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
from mcp.server.connection import Connection
from mcp.server.runner import serve_connection, serve_loop
from mcp.server.streamable_http import ( from mcp.server.streamable_http import (
MCP_PROTOCOL_VERSION_HEADER,
MCP_SESSION_ID_HEADER, MCP_SESSION_ID_HEADER,
EventStore, EventStore,
StreamableHTTPServerTransport, StreamableHTTPServerTransport,
) )
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
if TYPE_CHECKING: if TYPE_CHECKING:
from mcp.server.lowlevel.server import Server from mcp.server.lowlevel.server import Server
@ -96,8 +101,9 @@ class StreamableHTTPSessionManager:
# session must present the same credential. # session must present the same credential.
self._session_owners: dict[str, AuthorizationContext] = {} self._session_owners: dict[str, AuthorizationContext] = {}
# The task group will be set during lifespan # The task group and lifespan state are set during run()
self._task_group = None self._task_group = None
self._lifespan_state: Any = None
# Thread-safe tracking of run() calls # Thread-safe tracking of run() calls
self._run_lock = anyio.Lock() self._run_lock = anyio.Lock()
self._has_started = False self._has_started = False
@ -128,8 +134,11 @@ class StreamableHTTPSessionManager:
) )
self._has_started = True self._has_started = True
async with anyio.create_task_group() as tg: async with self.app.lifespan(self.app) as lifespan_state, anyio.create_task_group() as tg:
# Store the task group for later use # Store for handle_request: lifespan is entered once for the
# manager's lifetime, not per request (per-connection cleanup
# belongs on `connection.exit_stack`).
self._lifespan_state = lifespan_state
self._task_group = tg self._task_group = tg
logger.info("StreamableHTTP session manager started") logger.info("StreamableHTTP session manager started")
try: try:
@ -139,6 +148,7 @@ class StreamableHTTPSessionManager:
# Cancel task group to stop all spawned tasks # Cancel task group to stop all spawned tasks
tg.cancel_scope.cancel() tg.cancel_scope.cancel()
self._task_group = None self._task_group = None
self._lifespan_state = None
# Clear any remaining server instances # Clear any remaining server instances
self._server_instances.clear() self._server_instances.clear()
self._session_owners.clear() self._session_owners.clear()
@ -152,20 +162,26 @@ class StreamableHTTPSessionManager:
if self._task_group is None: if self._task_group is None:
raise RuntimeError("Task group is not initialized. Make sure to use run().") raise RuntimeError("Task group is not initialized. Make sure to use run().")
# TODO: header-only routing for now; body-primary classification # TODO(L49): header-only era-routing for now; body-primary classification
# (per SEP-2575) is a follow-up. 2025 paths below remain unchanged. # is a follow-up. The legacy paths below own only the known
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == b"mcp-protocol-version"), None) # initialize-handshake versions; anything else (including unknown
if pv in MODERN_PROTOCOL_VERSIONS: # values) goes to the modern entry so the classifier can validate it
await handle_modern_request(self.app, self.security_settings, pv, scope, receive, send) # and return a structured rejection. 2025 paths below remain unchanged.
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None)
if pv is not None and pv not in SUPPORTED_PROTOCOL_VERSIONS:
await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send)
return return
# Dispatch to the appropriate handler # Dispatch to the appropriate handler
if self.stateless: if self.stateless:
await self._handle_stateless_request(scope, receive, send) await self._handle_stateless_request(pv, scope, receive, send)
else: else:
await self._handle_stateful_request(scope, receive, send) await self._handle_stateful_request(scope, receive, send)
async def _handle_stateless_request(self, scope: Scope, receive: Receive, send: Send) -> None: async def _handle_stateless_request(
self, protocol_version_hint: str | None, scope: Scope, receive: Receive, send: Send
) -> None:
"""Process request in stateless mode - creating a new transport for each request.""" """Process request in stateless mode - creating a new transport for each request."""
logger.debug("Stateless mode: Creating new transport for this request") logger.debug("Stateless mode: Creating new transport for this request")
# No session ID needed in stateless mode # No session ID needed in stateless mode
@ -181,12 +197,29 @@ class StreamableHTTPSessionManager:
async with http_transport.connect() as streams: async with http_transport.connect() as streams:
read_stream, write_stream = streams read_stream, write_stream = streams
task_status.started() task_status.started()
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
read_stream,
write_stream,
inline_methods=frozenset({"initialize"}),
# No session ID means a server-to-client request can be
# written to this POST's response stream, but the client's
# reply has nowhere to land — `can_send_request=False`
# makes the per-request channel raise `NoBackChannelError`
# for requests while still allowing notifications.
transport_builder=lambda _md: TransportContext(kind="streamable-http", can_send_request=False),
)
# Born-ready, no standalone channel: the legacy stateless path
# never opens a GET stream and need not see `initialize`. The
# header (or the spec's default-absent value) seeds
# `ctx.protocol_version`.
connection = Connection.from_envelope(
protocol_version_hint if protocol_version_hint is not None else DEFAULT_NEGOTIATED_VERSION,
None,
None,
)
try: try:
await self.app.run( await serve_connection(
read_stream, self.app, dispatcher, connection=connection, lifespan_state=self._lifespan_state
write_stream,
self.app.create_initialization_options(),
stateless=True,
) )
except Exception: # pragma: lax no cover except Exception: # pragma: lax no cover
logger.exception("Stateless session crashed") logger.exception("Stateless session crashed")
@ -263,7 +296,7 @@ class StreamableHTTPSessionManager:
task_status.started() task_status.started()
try: try:
# Use a cancel scope for idle timeout — when the # Use a cancel scope for idle timeout — when the
# deadline passes the scope cancels app.run() and # deadline passes the scope cancels the loop and
# execution continues after the ``with`` block. # execution continues after the ``with`` block.
# Incoming requests push the deadline forward. # Incoming requests push the deadline forward.
idle_scope = anyio.CancelScope() idle_scope = anyio.CancelScope()
@ -272,11 +305,15 @@ class StreamableHTTPSessionManager:
http_transport.idle_scope = idle_scope http_transport.idle_scope = idle_scope
with idle_scope: with idle_scope:
await self.app.run( # Drive via `serve_loop` (not `Server.run()`) so the
# manager's already-entered lifespan is reused
# rather than re-entered per session.
await serve_loop(
self.app,
read_stream, read_stream,
write_stream, write_stream,
self.app.create_initialization_options(), lifespan_state=self._lifespan_state,
stateless=False, session_id=http_transport.mcp_session_id,
) )
if idle_scope.cancelled_caught: if idle_scope.cancelled_caught:
@ -309,7 +346,7 @@ class StreamableHTTPSessionManager:
await http_transport.handle_request(scope, receive, send) await http_transport.handle_request(scope, receive, send)
else: else:
# Unknown or expired session ID - return 404 per MCP spec # Unknown or expired session ID - return 404 per MCP spec
# TODO: Align error code once spec clarifies # TODO(L62): Align error code once spec clarifies
# See: https://github.com/modelcontextprotocol/python-sdk/issues/1821 # See: https://github.com/modelcontextprotocol/python-sdk/issues/1821
logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}") logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}")
body = JSONRPCError( body = JSONRPCError(

View file

@ -71,23 +71,6 @@ class NoBackChannelError(MCPError):
self.method = method self.method = method
class StatelessModeNotSupported(RuntimeError):
"""Raised when attempting to use a method that is not supported in stateless mode.
Server-to-client requests (sampling, elicitation, list_roots) are not
supported in stateless HTTP mode because there is no persistent connection
for bidirectional communication.
"""
def __init__(self, method: str):
super().__init__(
f"Cannot use {method} in stateless HTTP mode. "
"Stateless mode does not support server-to-client requests. "
"Use stateful mode (stateless_http=False) to enable this feature."
)
self.method = method
class UrlElicitationRequiredError(MCPError): class UrlElicitationRequiredError(MCPError):
"""Specialized error for when a tool requires URL mode elicitation(s) before proceeding. """Specialized error for when a tool requires URL mode elicitation(s) before proceeding.

153
src/mcp/shared/inbound.py Normal file
View file

@ -0,0 +1,153 @@
"""Inbound request classification for the modern per-request-envelope path.
Pure module: no I/O, no transport, no ``mcp.server`` imports. Runs the
validation ladder against a decoded JSON-RPC body and returns either an
:class:`InboundModernRoute` (every rung passed) or an
:class:`InboundLadderRejection` (the first rung that failed). Callers map a
rejection's ``code`` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP
status.
"""
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Any, Final
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
PROTOCOL_VERSION_META_KEY,
UnsupportedProtocolVersionErrorData,
)
from mcp.types.jsonrpc import (
HEADER_MISMATCH,
INVALID_PARAMS,
INVALID_REQUEST,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
PARSE_ERROR,
UNSUPPORTED_PROTOCOL_VERSION,
)
__all__ = [
"ERROR_CODE_HTTP_STATUS",
"InboundLadderRejection",
"InboundModernRoute",
"MCP_PROTOCOL_VERSION_HEADER",
"classify_inbound_request",
]
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
"""Canonical lowercase name of the HTTP header carrying the MCP protocol version."""
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
{
PARSE_ERROR: 400,
INVALID_REQUEST: 400,
INVALID_PARAMS: 400,
HEADER_MISMATCH: 400,
MISSING_REQUIRED_CLIENT_CAPABILITY: 400,
UNSUPPORTED_PROTOCOL_VERSION: 400,
METHOD_NOT_FOUND: 404,
}
)
"""HTTP status to send for a JSON-RPC ``error.code``.
Consulted for classifier-origin *and* handler-origin errors, so one table
decides the wire status regardless of where the error was produced. Unmapped
codes fall back to the caller's default (typically 200).
"""
@dataclass(frozen=True)
class InboundModernRoute:
"""A modern-protocol request whose envelope passed every ladder rung.
``client_info`` and ``client_capabilities`` are the raw envelope values;
the classifier checks presence only, not shape. Method existence is not a
ladder rung — kernel dispatch is the single source of truth for that.
"""
protocol_version: str
client_info: Any
client_capabilities: Any
@dataclass(frozen=True)
class InboundLadderRejection:
"""The first ladder rung that failed, as JSON-RPC error fields."""
code: int
message: str
data: Any = None
def classify_inbound_request(
body: Mapping[str, Any],
*,
headers: Mapping[str, str] | None = None,
supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS,
) -> InboundModernRoute | InboundLadderRejection:
"""Run the modern-protocol validation ladder over a decoded JSON-RPC body.
Rungs, in order — first failure wins:
1. ``params._meta`` is a mapping carrying every reserved envelope key
(protocol version, client info, client capabilities) → else
:data:`~mcp.types.jsonrpc.INVALID_PARAMS`.
2. When ``headers`` is given, its ``MCP-Protocol-Version`` entry equals
the envelope's protocol version → 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
``data = {"supported": [...], "requested": <value>}``.
Method existence is *not* a rung: kernel dispatch owns that decision so
custom-registered methods route and the answer lives in one place.
Args:
body: The decoded JSON-RPC request mapping. Envelope shape
(``jsonrpc`` / ``id``) is not checked here.
headers: Transport headers keyed by lowercase name, or ``None`` to
skip the header rung (non-HTTP callers).
supported_modern_versions: Modern protocol revisions this server
accepts on the per-request-envelope path.
"""
try:
meta = body["params"]["_meta"]
protocol_version = meta[PROTOCOL_VERSION_META_KEY]
client_info = meta[CLIENT_INFO_META_KEY]
client_capabilities = meta[CLIENT_CAPABILITIES_META_KEY]
except (KeyError, TypeError):
return InboundLadderRejection(
code=INVALID_PARAMS,
message="params._meta must carry the reserved protocol-version, client-info and "
"client-capabilities envelope keys",
)
# TODO(L59): also validate Mcp-Method / Mcp-Name per SEP-2243 §Server Validation
if headers is not None and headers.get(MCP_PROTOCOL_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",
)
if protocol_version not in supported_modern_versions:
return InboundLadderRejection(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="Unsupported protocol version",
data=UnsupportedProtocolVersionErrorData(
supported=list(supported_modern_versions), requested=protocol_version
).model_dump(mode="json"),
)
return InboundModernRoute(
protocol_version=protocol_version,
client_info=client_info,
client_capabilities=client_capabilities,
)

View file

@ -49,7 +49,7 @@ from mcp.types import (
RequestId, RequestId,
) )
__all__ = ["JSONRPCDispatcher"] __all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data"]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,6 +67,23 @@ PeerCancelMode = Literal["interrupt", "signal"]
the handler's scope; `"signal"` only sets `ctx.cancel_requested`.""" the handler's scope; `"signal"` only sets `ctx.cancel_requested`."""
def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
"""Map a handler-raised exception to its wire `ErrorData`.
The two rungs every dispatcher shares: an `MCPError` carries its own
`ErrorData`; a pydantic `ValidationError` is the spec's INVALID_PARAMS
with empty ``data`` (no pydantic text on the wire). Returns ``None`` for
any other exception so each caller applies its own catch-all -
`JSONRPCDispatcher` currently pins ``code=0`` for v1 compat,
`to_jsonrpc_response` uses `INTERNAL_ERROR`.
"""
if isinstance(exc, MCPError):
return exc.error
if isinstance(exc, ValidationError):
return ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
return None
def _coerce_id(request_id: RequestId) -> RequestId: def _coerce_id(request_id: RequestId) -> RequestId:
"""Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK).""" """Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK)."""
if isinstance(request_id, str): if isinstance(request_id, str):
@ -667,7 +684,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
# anyio absorbs the scope's own cancel at __exit__, and # anyio absorbs the scope's own cancel at __exit__, and
# `cancelled_caught` (unlike `cancel_called`) guarantees the # `cancelled_caught` (unlike `cancel_called`) guarantees the
# result write above did not happen - no double response. # result write above did not happen - no double response.
# TODO(maxisbey): spec says SHOULD NOT respond after cancel; # TODO(L38): spec says SHOULD NOT respond after cancel;
# the existing server always has, so match that for now. # the existing server always has, so match that for now.
answer_write_started = True answer_write_started = True
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled")) await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
@ -684,21 +701,17 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
describe=f"shutdown error response for request {req.id!r}", describe=f"shutdown error response for request {req.id!r}",
) )
raise raise
except MCPError as e:
await self._write_error(req.id, e.error)
except ValidationError:
# TODO(maxisbey): data="" pins existing-server compat (no pydantic
# text on the wire); revisit per the suite's divergence entry.
await self._write_error(
req.id, ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
)
except Exception as e: except Exception as e:
logger.exception("handler for %r raised", req.method) error = handler_exception_to_error_data(e)
# TODO(maxisbey): code=0 pins existing-server compat; JSON-RPC says if error is not None:
# INTERNAL_ERROR. Revisit per the suite's divergence entry. await self._write_error(req.id, error)
await self._write_error(req.id, ErrorData(code=0, message=str(e))) else:
if self._raise_handler_exceptions: logger.exception("handler for %r raised", req.method)
raise # TODO(L58): code=0 pins existing-server compat; JSON-RPC says
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
if self._raise_handler_exceptions:
raise
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id. # No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
def _allocate_id(self) -> int: def _allocate_id(self) -> int:

View file

@ -4,7 +4,8 @@ from pydantic import FileUrl
from mcp import Client from mcp import Client
from mcp.client import ClientRequestContext from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ListRootsResult, Root, TextContent from mcp.shared.exceptions import MCPError
from mcp.types import INVALID_REQUEST, ListRootsResult, Root, TextContent
@pytest.mark.anyio @pytest.mark.anyio
@ -37,10 +38,10 @@ async def test_list_roots_callback():
assert isinstance(result.content[0], TextContent) assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true" assert result.content[0].text == "true"
# Test without list_roots callback # Without a list_roots callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server) as client: async with Client(server) as client:
# Make a request to trigger sampling callback with pytest.raises(MCPError) as exc_info:
result = await client.call_tool("test_list_roots", {"message": "test message"}) await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is True assert exc_info.value.error.code == INVALID_REQUEST
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"

View file

@ -201,3 +201,62 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch with pytest.raises(MCPError, match="Failed to parse JSON response"): # pragma: no branch
await session.list_tools() await session.list_tools()
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
async def handle_mcp_request(request: Request) -> Response:
data = json.loads(await request.body())
if data.get("method") == "initialize":
return _init_json_response(data)
if "id" not in data:
return Response(status_code=202)
return Response(content=body, status_code=status, media_type="application/json")
return Starlette(debug=True, routes=[Route("/mcp", handle_mcp_request, methods=["POST"])])
async def test_client_surfaces_jsonrpc_error_from_non_2xx_body_with_correlated_id() -> None:
"""SDK-defined: a JSON-RPC error in a non-2xx body is surfaced verbatim even when the
server set ``id: null`` — the client rewraps it under the pending request's id, so
the awaiting call resolves with the server's error code instead of the generic fallback."""
body = json.dumps(
{"jsonrpc": "2.0", "id": None, "error": {"code": types.METHOD_NOT_FOUND, "message": "nope"}}
).encode()
app = _create_non_2xx_json_body_app(400, body)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.METHOD_NOT_FOUND
async def test_client_falls_back_to_generic_error_when_non_2xx_body_is_a_jsonrpc_result() -> None:
"""SDK-defined: a non-2xx response whose JSON body parses as a JSON-RPC *result* (not an
error) falls through to the generic ``INTERNAL_ERROR`` fallback rather than being
treated as the request's reply."""
app = _create_non_2xx_json_body_app(400, b'{"jsonrpc":"2.0","id":1,"result":{}}')
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INTERNAL_ERROR
async def test_client_falls_back_to_session_terminated_when_404_body_is_malformed_json() -> None:
"""SDK-defined: an unparseable ``application/json`` body on a 404 response is swallowed
and the status-derived ``INVALID_REQUEST`` (session-terminated) fallback resolves the
pending request — the parse failure never propagates."""
app = _create_non_2xx_json_body_app(404, b"not valid json{{{")
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app)) as client:
async with streamable_http_client("http://localhost/mcp", http_client=client) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session: # pragma: no branch
await session.initialize()
with pytest.raises(MCPError) as exc:
await session.list_tools()
assert exc.value.error.code == types.INVALID_REQUEST

View file

@ -3,7 +3,9 @@ import pytest
from mcp import Client from mcp import Client
from mcp.client import ClientRequestContext from mcp.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from mcp.types import ( from mcp.types import (
INVALID_REQUEST,
CreateMessageRequestParams, CreateMessageRequestParams,
CreateMessageResult, CreateMessageResult,
CreateMessageResultWithTools, CreateMessageResultWithTools,
@ -47,13 +49,13 @@ async def test_sampling_callback():
assert isinstance(result.content[0], TextContent) assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "true" assert result.content[0].text == "true"
# Test without sampling callback # Without a sampling callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server) as client: async with Client(server) as client:
# Make a request to trigger sampling callback with pytest.raises(MCPError) as exc_info:
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"}) await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is True assert exc_info.value.error.code == INVALID_REQUEST
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
@pytest.mark.anyio @pytest.mark.anyio

View file

@ -97,9 +97,9 @@ _TASKS_DEFERRAL = (
) )
_MODERN_NOTIFY_DROP = ( _MODERN_NOTIFY_DROP = (
"SingleExchangeDispatcher.notify() no-ops on the modern streamable-http driver; handler-emitted " "The modern single-exchange dispatch context no-ops notify() on the streamable-http driver; "
"logging/progress notifications never reach the per-request SSE response. Passes once SSE " "handler-emitted logging/progress notifications never reach the per-request SSE response. "
"response mode lands." "Passes once SSE response mode lands."
) )
@ -3046,6 +3046,50 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",), transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.", note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.",
), ),
"hosting:http:modern:discover-response-shape": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior=(
"A 2026-07-28 server/discover response carries supportedVersions, capabilities, and "
"serverInfo, with supportedVersions naming the modern protocol revisions the server accepts."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the raw result body is asserted at the wire.",
),
"hosting:http:modern:removed-method-status-404": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior=(
"A method that exists at earlier protocol revisions but is removed at 2026-07-28 is "
"answered METHOD_NOT_FOUND, and the modern entry maps that error code to HTTP 404."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the HTTP status is the assertion. Kernel-origin "
"METHOD_NOT_FOUND travels through the same status table as classifier-origin errors."
),
),
"hosting:http:modern:envelope-missing-key-status-400": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
behavior=(
"A 2026-07-28 request whose params._meta envelope omits a required reserved key is "
"rejected as INVALID_PARAMS at HTTP 400 before kernel dispatch."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the HTTP status is the assertion.",
),
"hosting:http:modern:handler-error-status-via-table": Requirement(
source="sdk",
behavior=(
"A handler-raised MCPError on the 2026-07-28 entry reaches the wire as a top-level "
"JSON-RPC error with its data preserved, and the HTTP status is the error-code table "
"entry for that code (handler-origin and classifier-origin errors share one table)."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.",
),
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Client transport: streamable HTTP # Client transport: streamable HTTP
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════

Some files were not shown because too many files have changed in this diff Show more