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
# --- 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.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
@ -83,14 +80,12 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
- input-required-result-validate-input
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# against the request body is not implemented.
- http-header-validation
# --- WARNING-only entries ---
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
# the expected-failures evaluator counts WARNINGs as failures. Same entries
# as the draft suite in expected-failures.yml.
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# expected-failures evaluator counts WARNINGs as failures (the summary line
# 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-validate-input

View file

@ -34,12 +34,7 @@ client:
server:
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---
# 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;
# most scenarios currently fail early with "Missing session ID" because
# mcp-everything-server only runs in stateful mode.
# SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented.
- input-required-result-basic-elicitation
- input-required-result-basic-sampling
- input-required-result-basic-list-roots
@ -50,17 +45,12 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
# SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check
# against the request body is not implemented.
- http-header-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
# WARNING-only entries: these scenarios emit no FAILURE checks but the
# expected-failures evaluator counts WARNINGs as failures (the summary line
# only shows passed/failed, not warnings, so a local re-probe can mis-read
# these as stale).
- 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

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
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
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.
### 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()` 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)
# After (v2)
session = ServerSession(dispatcher, connection, stateless=False)
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection
session = ServerSession(request_outbound, 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.

View file

@ -14,6 +14,7 @@ from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
from mcp.shared.exceptions import MCPError
from mcp.types import (
AudioContent,
Completion,
@ -32,6 +33,7 @@ from mcp.types import (
TextResourceContents,
UnsubscribeRequestParams,
)
from mcp.types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@ -311,6 +313,26 @@ def test_error_handling() -> str:
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()
async def test_reconnection(ctx: Context) -> str:
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""

View file

@ -314,16 +314,29 @@ class StreamableHTTPTransport:
logger.debug("Received 202 Accepted")
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 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))
await ctx.read_stream_writer.send(session_message)
return

View file

@ -12,33 +12,32 @@ no per-request task group, no `JSONRPCDispatcher`.
from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypeVar
import anyio
import anyio.abc
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from mcp.server.runner import (
_EXIT_STACK_CLOSE_TIMEOUT, # type: ignore[reportPrivateUsage]
ServerRunner,
otel_middleware,
)
from mcp.server.connection import Connection
from mcp.server.runner import serve_one
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.dispatcher import CallOptions
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.transport_context import TransportContext
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
PARSE_ERROR,
ClientCapabilities,
ErrorData,
Implementation,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
@ -50,6 +49,10 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_ModelT = TypeVar("_ModelT", bound=BaseModel)
_OK_STATUS = 200
@dataclass
class _SingleExchangeDispatchContext:
@ -64,7 +67,7 @@ class _SingleExchangeDispatchContext:
request_id: RequestId
message_metadata: MessageMetadata
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(
self,
@ -75,91 +78,51 @@ class _SingleExchangeDispatchContext:
raise NoBackChannelError(method)
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
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
class SingleExchangeDispatcher:
"""Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST.
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
"""Validate the classifier's raw envelope value into a typed model.
The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s
role). Implements the `Dispatcher` Protocol so `ServerRunner` /
`Connection` / `ServerSession` accept it; `run()` is never driven.
Rung 1 guarantees the envelope key was present; a ``null`` or mis-shaped
value falls through to ``ValidationError`` and is treated as not supplied
so the request still routes.
"""
def __init__(self, request: Request) -> None:
self._request = request
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.
try:
return model.model_validate(raw, by_name=False)
except ValidationError:
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:
"""Dispatch one request and map any exception to a `JSONRPCError`."""
dctx = _SingleExchangeDispatchContext(
transport=self._tctx,
request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=self._request),
)
try:
result = await on_request(dctx, req.method, req.params)
return JSONRPCResponse(jsonrpc="2.0", id=req.id, result=result)
except MCPError as e:
return JSONRPCError(jsonrpc="2.0", id=req.id, error=e.error)
except ValidationError:
return JSONRPCError(
jsonrpc="2.0",
id=req.id,
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
)
# 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 _write(
msg: JSONRPCResponse | JSONRPCError,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""Serialise a JSON-RPC reply with the table-mapped HTTP status."""
status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
if isinstance(msg, JSONRPCError) and msg.id is None:
# JSON-RPC requires `id: null` to appear on the wire when the request
# id couldn't be parsed; `exclude_none` would otherwise drop it.
body["id"] = None
await Response(
json.dumps(body, separators=(",", ":")),
status_code=status,
media_type="application/json",
)(scope, receive, send)
async def handle_modern_request(
app: Server[Any],
security_settings: TransportSecuritySettings | None,
protocol_version: str,
lifespan_state: Any,
scope: Scope,
receive: Receive,
send: Send,
@ -167,8 +130,9 @@ async def handle_modern_request(
"""ASGI handler for a single stateless-era POST.
Called from `StreamableHTTPSessionManager.handle_request` when the
`MCP-Protocol-Version` header is in `MODERN_PROTOCOL_VERSIONS`; the header
value is passed as `protocol_version`. Never sets `Mcp-Session-Id`.
`MCP-Protocol-Version` header names a modern revision; the manager enters
`app.lifespan` once at startup and passes the state in. Never sets
`Mcp-Session-Id`.
"""
request = Request(scope, receive)
@ -178,54 +142,56 @@ async def handle_modern_request(
await err(scope, receive, send)
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":
# 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)
return
body = await request.body()
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:
msg = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
await Response(
msg.model_dump_json(by_alias=True),
status_code=400,
media_type="application/json",
)(scope, receive, send)
# Well-formed JSON that isn't a single request object. The transport
# spec permits notification POSTs and gives the server two responses
# (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
# item 5). The core protocol defines no client→server notifications
# over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
# 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
dispatcher = SingleExchangeDispatcher(request)
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893.
async with app.lifespan(app) as lifespan_state:
runner = ServerRunner(
server=app,
dispatcher=dispatcher,
lifespan_state=lifespan_state,
has_standalone_channel=False,
stateless=True,
dispatch_middleware=[otel_middleware],
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
if isinstance(verdict, InboundLadderRejection):
rej = JSONRPCError(
jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data)
)
runner.connection.protocol_version = protocol_version
try:
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,
)
await _write(rej, scope, receive, send)
return
# TODO: error.code -> HTTP status mapping is a follow-up; 200 for all JSONRPCError bodies for now.
await Response(
msg.model_dump_json(by_alias=True, exclude_none=True),
status_code=200,
media_type="application/json",
)(scope, receive, send)
connection = Connection.from_envelope(
verdict.protocol_version,
_typed(Implementation, verdict.client_info),
_typed(ClientCapabilities, verdict.client_capabilities),
)
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.
Always present on `Context` (never `None`), even in stateless deployments.
Holds peer info populated at `initialize` time, per-connection scratch
`state` and an `exit_stack` for teardown, and an `Outbound` for the
standalone stream (the SSE GET stream in streamable HTTP, or the single duplex
stream in stdio).
Holds peer info, per-connection scratch `state` and an `exit_stack` for
teardown, and an `Outbound` for the standalone stream (the SSE GET stream in
streamable HTTP, or the single duplex 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
(stateless HTTP) or the stream has been dropped, the notification is
debug-logged and silently discarded - server-initiated notifications are
inherently advisory. `send_raw_request` *does* raise `NoBackChannelError` when
there's no channel; `ping` is the only spec-sanctioned standalone request.
or the stream has been dropped, the notification is debug-logged and silently
discarded - server-initiated notifications are inherently advisory.
`send_raw_request` raises `NoBackChannelError` when there's no channel; `ping`
is the only spec-sanctioned standalone request.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
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.peer import Meta, dump_params
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
CreateMessageRequest,
CreateMessageResult,
ElicitRequest,
ElicitResult,
EmptyResult,
Implementation,
InitializeRequestParams,
ListRootsRequest,
ListRootsResult,
@ -68,32 +76,57 @@ def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> d
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:
"""Per-client connection state and standalone-stream `Outbound`.
Constructed by `ServerRunner` once per connection. The peer-info fields
are `None` until `initialize` completes; `initialized` is set later, when
the client's `notifications/initialized` follow-up arrives. In stateless
deployments the runner sets `initialized` immediately and peer-info
remains `None` (no handshake reaches a stateless connection).
Construct via `from_envelope` (modern single-exchange: born ready, no
back-channel) or `for_loop` (handshake-driven: ready once the client's
`notifications/initialized` arrives). Either way `protocol_version` is
populated at construction.
"""
has_standalone_channel: bool
outbound: Outbound
"""The connection-scoped channel for server-initiated messages."""
session_id: str | 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
"""The protocol version negotiated during `initialize`; `None` before
initialization. Stateless connections don't require the handshake, so this
normally stays `None` there (a client that sends `initialize` anyway still
commits it). For the per-request value, read `ctx.protocol_version`."""
protocol_version: str
"""The protocol version this connection speaks. Populated at construction
by the factory and overwritten by `_handle_initialize` once the handshake
commits on the loop path."""
initialized: anyio.Event
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
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]
"""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
and swallowed."""
def __init__(self, outbound: Outbound, *, has_standalone_channel: bool, session_id: str | None = None) -> None:
self._outbound = outbound
self.has_standalone_channel = has_standalone_channel
def __init__(
self,
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.client_params = None
self.protocol_version = None
self.client_params = client_params
self.initialized = anyio.Event()
self.state = {}
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
def initialize_accepted(self) -> bool:
"""True once the inbound request gate is open: `initialize` recorded the
peer info, or the handshake completed outright (stateless birth, or a
bare `notifications/initialized`). Derived, never stored."""
peer info, or the handshake completed outright (born-ready, or a bare
`notifications/initialized`). Derived, never stored."""
return self.client_params is not None or self.initialized.is_set()
async def send_raw_request(
@ -140,9 +232,7 @@ class Connection:
MCPError: The peer responded with an error.
NoBackChannelError: `has_standalone_channel` is `False`.
"""
if not self.has_standalone_channel:
raise NoBackChannelError(method)
return await self._outbound.send_raw_request(method, params, opts)
return await self.outbound.send_raw_request(method, params, opts)
@overload
async def send_request(
@ -177,11 +267,9 @@ class Connection:
KeyError: `result_type` omitted for a non-spec request type.
"""
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:
try:
_methods.validate_client_result(req.method, version, raw)
_methods.validate_client_result(req.method, self.protocol_version, raw)
except KeyError:
pass
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,
the notification is dropped and debug-logged.
"""
if not self.has_standalone_channel:
logger.debug("dropped %s: no standalone channel", method)
return
try:
await self._outbound.notify(method, params)
await self.outbound.notify(method, params)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped %s: standalone stream closed", method)
@ -233,9 +318,9 @@ class Connection:
def check_capability(self, capability: ClientCapabilities) -> bool:
"""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.
if self.client_params is None:
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.context import HandlerResult, ServerMiddleware, ServerRequestContext
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_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
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__)
@ -117,6 +116,15 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
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]):
def __init__(
self,
@ -218,7 +226,7 @@ class Server(Generic[LifespanResultT]):
# Context-tier middleware: wraps every inbound request (including
# `initialize`, lookup, validation, handler) with
# `(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
# v2 final.
self.middleware: list[ServerMiddleware[LifespanResultT]] = []
@ -226,6 +234,7 @@ class Server(Generic[LifespanResultT]):
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
("ping", types.RequestParams, on_ping),
("server/discover", types.RequestParams, self._handle_discover),
("prompts/list", types.PaginatedRequestParams, on_list_prompts),
("prompts/get", types.GetPromptRequestParams, on_get_prompt),
("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 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
# flags, and experimental_capabilities as a separate dict. Consider deriving capabilities
# 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,
) -> InitializationOptions:
"""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(
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,
description=self.description,
capabilities=self.get_capabilities(
@ -334,10 +334,11 @@ class Server(Generic[LifespanResultT]):
def get_capabilities(
self,
notification_options: NotificationOptions,
experimental_capabilities: dict[str, dict[str, Any]],
notification_options: NotificationOptions | None = None,
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
) -> types.ServerCapabilities:
"""Convert existing handlers to a ServerCapabilities object."""
notification_options = notification_options or NotificationOptions()
prompts_capability = None
resources_capability = None
tools_capability = None
@ -377,6 +378,40 @@ class Server(Generic[LifespanResultT]):
)
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
def session_manager(self) -> StreamableHTTPSessionManager:
"""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
# in-process servers.
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:
"""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:
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
await serve_loop(
self,
read_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,
init_options=initialization_options,
# Stateless HTTP has no standalone GET stream, so server-initiated
# 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],
raise_exceptions=raise_exceptions,
)
await runner.run()
def streamable_http_app(
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.func_metadata import FuncMetadata, func_metadata
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.types import Icon, ToolAnnotations
@ -111,9 +111,12 @@ class Tool(BaseModel):
result = self.fn_metadata.convert_result(result)
return result
except UrlElicitationRequiredError:
# Re-raise UrlElicitationRequiredError so it can be properly handled
# as an MCP error response with code -32042
except MCPError:
# `MCPError` (and subclasses such as `UrlElicitationRequiredError`)
# 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
except Exception as 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` /
`on_notify`, untyped dicts) and the user's handler layer (typed `Context`,
typed params). One instance per client connection. It:
* handles the `initialize` handshake and populates `Connection`
* gates requests until initialized (`ping` exempt)
* looks up the handler in the server's registry, validates params, builds
`Context`, runs the middleware chain, returns the result dict
* drives `dispatcher.run()` and the per-connection lifespan
`ServerRunner` bridges the dispatch layer (`on_request` / `on_notify`, untyped
dicts) and the user's handler layer (typed `Context`, typed params). It is a
pure kernel: it holds a pre-populated `Connection` and reads
`connection.protocol_version` / `connection.outbound` as facts. Driving a
dispatcher loop and tearing down the connection live in the free-function
drivers (`serve_connection`, `serve_loop`, `serve_one`); the entry constructs
the `Connection`, the driver tears it down.
`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
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from functools import partial, reduce
from collections.abc import Awaitable, Mapping, Sequence
from dataclasses import KW_ONLY, dataclass
from functools import cached_property, partial, reduce
from typing import TYPE_CHECKING, Any, Generic, cast
import anyio
import anyio.abc
from opentelemetry.trace import SpanKind, StatusCode
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.session import ServerSession
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.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.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
@ -41,11 +42,14 @@ from mcp.types import (
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
ErrorData,
Implementation,
InitializeRequestParams,
InitializeResult,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
RequestParams,
RequestParamsMeta,
)
@ -54,7 +58,17 @@ from mcp.types import methods as _methods
if TYPE_CHECKING:
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__)
@ -64,8 +78,8 @@ LifespanT = TypeVar("LifespanT", default=Any)
_INIT_EXEMPT: frozenset[str] = frozenset({"ping"})
_EXIT_STACK_CLOSE_TIMEOUT: float = 5
"""Bound for the shielded exit-stack unwind in `run()`; a hung cleanup
callback must not wedge shutdown."""
"""Bound for `aclose_shielded`'s exit-stack unwind; a hung cleanup callback
must not wedge shutdown."""
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
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:
"""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")
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
class ServerRunner(Generic[LifespanT]):
"""Per-connection orchestrator. One instance per client connection."""
"""Per-connection handler kernel. One instance per client connection."""
server: Server[LifespanT]
dispatcher: Dispatcher[Any]
connection: Connection
lifespan_state: LifespanT
has_standalone_channel: bool
_: KW_ONLY
init_options: InitializationOptions | None = None
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""
session_id: str | None = None
stateless: bool = False
dispatch_middleware: list[DispatchMiddleware] = field(default_factory=list[DispatchMiddleware])
dispatch_middleware: Sequence[DispatchMiddleware] = (otel_middleware,)
connection: Connection = field(init=False)
session: ServerSession = field(init=False)
"""Connection-scoped: the same instance reaches every request as `ctx.session`."""
@cached_property
def on_request(self) -> OnRequest:
"""`_on_request` wrapped in `dispatch_middleware`, outermost-first.
def __post_init__(self) -> None:
if self.init_options is None:
self.init_options = self.server.create_initialization_options()
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()`.
Dispatch-tier middleware sees raw `(dctx, method, params) -> dict` and
wraps everything - initialize, METHOD_NOT_FOUND, validation failures
included.
"""
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(
self,
dctx: DispatchContext[TransportContext],
@ -243,7 +233,7 @@ class ServerRunner(Generic[LifespanT]):
params: Mapping[str, Any] | None,
) -> dict[str, Any]:
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)
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
@ -257,7 +247,7 @@ class ServerRunner(Generic[LifespanT]):
_methods.validate_client_request(method, version, params)
except KeyError:
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
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
if method == "initialize":
@ -284,7 +274,7 @@ class ServerRunner(Generic[LifespanT]):
call = self._compose_server_middleware(ctx, method, params, _inner)
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
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
if is_spec_method:
@ -312,7 +302,7 @@ class ServerRunner(Generic[LifespanT]):
params: Mapping[str, Any] | None,
) -> None:
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)
async def _inner() -> None:
@ -373,7 +363,7 @@ class ServerRunner(Generic[LifespanT]):
def _make_context(
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
) -> 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
# per-transport context once that lands.
md = dctx.message_metadata
@ -383,8 +373,12 @@ class ServerRunner(Generic[LifespanT]):
close_standalone_sse_stream = md.close_standalone_sse_stream
else:
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(
session=self.session,
session=session,
lifespan_context=self.lifespan_state,
request_id=dctx.request_id,
meta=meta,
@ -405,8 +399,7 @@ class ServerRunner(Generic[LifespanT]):
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
"""Build the `initialize` result; state commits later in `_on_request`."""
_, negotiated = self._negotiate_initialize(params)
assert self.init_options is not None
opts = self.init_options
opts = self.init_options if self.init_options is not None else self.server.create_initialization_options()
return InitializeResult(
protocol_version=negotiated,
capabilities=opts.capabilities,
@ -420,3 +413,82 @@ class ServerRunner(Generic[LifespanT]):
),
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.
A thin proxy over `JSONRPCDispatcher` and `Connection`. One instance per
client connection (built by `ServerRunner`). Handlers reach it as
`ctx.session` and use the typed helpers (`create_message`, `elicit_form`,
A per-request proxy built by the kernel for each inbound request. Exposes the
request-scoped outbound channel and the connection's standalone channel.
Handlers reach it as `ctx.session` and use the typed helpers (`elicit_form`,
`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 typing_extensions import deprecated
@ -17,8 +14,8 @@ from typing_extensions import deprecated
from mcp import types
from mcp.server.connection import Connection
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError, StatelessModeNotSupported
from mcp.shared.dispatcher import CallOptions, Outbound, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods
@ -28,35 +25,32 @@ ResultT = TypeVar("ResultT", bound=BaseModel)
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
to the dispatcher; the typed helpers below are unchanged from the previous
implementation and only call those two methods.
Built once per inbound request by the kernel's `_make_context`. Holds two
`Outbound` channels: the request-scoped one (the per-request
`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__(
self,
dispatcher: Dispatcher[Any],
connection: Connection,
*,
stateless: bool = False,
) -> None:
self._dispatcher = dispatcher
def __init__(self, request_outbound: Outbound, connection: Connection) -> None:
self._request_outbound = request_outbound
self._connection = connection
self._stateless = stateless
@property
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
@property
def protocol_version(self) -> str | None:
"""The protocol version negotiated during `initialize`.
def protocol_version(self) -> str:
"""The protocol version this connection speaks.
`None` before initialization, and normally `None` on stateless
connections. For the per-request value, read `ctx.protocol_version`.
Populated at `Connection` construction and overwritten once the
handshake commits on the loop path; never `None`.
"""
return self._connection.protocol_version
@ -70,46 +64,23 @@ class ServerSession:
) -> ResultT:
"""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:
MCPError: The peer responded with an error.
NoBackChannelError: If there is no related request to ride on and
the connection has no standalone channel (stateless HTTP), so
a response could never arrive.
NoBackChannelError: The connection has no back-channel for
server-initiated requests (raised by the held `Outbound`).
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)
opts: CallOptions = {}
if request_read_timeout_seconds is not None:
opts["timeout"] = request_read_timeout_seconds
if progress_callback is not None:
opts["on_progress"] = progress_callback
related = metadata.related_request_id if metadata is not None else 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"
result = await channel.send_raw_request(data["method"], data.get("params"), opts or None)
try:
_methods.validate_client_result(request.method, version, result)
_methods.validate_client_result(request.method, self.protocol_version, result)
except KeyError:
pass
return result_type.model_validate(result, by_name=False)
@ -120,8 +91,9 @@ class ServerSession:
related_request_id: types.RequestId | None = None,
) -> None:
"""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)
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:
"""Check if the client supports a specific capability."""
@ -236,10 +208,9 @@ class ServerSession:
Raises:
MCPError: If tools are provided but client doesn't support them.
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
validate_sampling_tools(client_caps, tools, tool_choice)
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)
async def list_roots(self) -> types.ListRootsResult:
"""Send a roots/list request."""
if self._stateless:
raise StatelessModeNotSupported(method="list_roots")
"""Send a roots/list request.
Raises:
NoBackChannelError: The connection has no back-channel for
server-initiated requests.
"""
return await self.send_request(
types.ListRootsRequest(),
types.ListRootsResult,
@ -321,10 +295,9 @@ class ServerSession:
The client's response with form data.
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(
types.ElicitRequest(
params=types.ElicitRequestFormParams(
@ -358,10 +331,9 @@ class ServerSession:
The client's response indicating acceptance, decline, or cancellation.
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(
types.ElicitRequest(
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._stream_protocols import ReadStream, WriteStream
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 (
DEFAULT_NEGOTIATED_VERSION,
INTERNAL_ERROR,
@ -248,12 +248,11 @@ class StreamableHTTPServerTransport:
metadata = ServerMessageMetadata(
request_context=request,
protocol_version=protocol_version,
close_sse_stream=close_stream_callback,
close_standalone_sse_stream=close_standalone_stream_callback,
)
else:
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
metadata = ServerMessageMetadata(request_context=request)
return SessionMessage(message, metadata=metadata)
@ -507,10 +506,7 @@ class StreamableHTTPServerTransport:
await response(scope, receive, send)
# Process the message after sending the response
metadata = ServerMessageMetadata(
request_context=request,
protocol_version=request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION),
)
metadata = ServerMessageMetadata(request_context=request)
session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message)
@ -533,7 +529,7 @@ class StreamableHTTPServerTransport:
if self.is_json_response_enabled:
# Process the message
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
metadata = ServerMessageMetadata(request_context=request)
session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message)
try:
@ -818,11 +814,10 @@ class StreamableHTTPServerTransport:
await response(request.scope, request.receive, send)
async def _validate_request_headers(self, request: Request, send: Send) -> bool:
if not await self._validate_session(request, send):
return False
if not await self._validate_protocol_version(request, send):
return False
return True
# Protocol-version validation lives in the manager's era-routing: only
# values in `SUPPORTED_PROTOCOL_VERSIONS` (or no header at all) reach
# this transport, so the legacy version-gate is gone.
return await self._validate_session(request, send)
async def _validate_session(self, request: Request, send: Send) -> bool:
"""Validate the session ID in the request."""
@ -853,28 +848,6 @@ class StreamableHTTPServerTransport:
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:
"""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
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)
# 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.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 (
MCP_PROTOCOL_VERSION_HEADER,
MCP_SESSION_ID_HEADER,
EventStore,
StreamableHTTPServerTransport,
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
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:
from mcp.server.lowlevel.server import Server
@ -96,8 +101,9 @@ class StreamableHTTPSessionManager:
# session must present the same credential.
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._lifespan_state: Any = None
# Thread-safe tracking of run() calls
self._run_lock = anyio.Lock()
self._has_started = False
@ -128,8 +134,11 @@ class StreamableHTTPSessionManager:
)
self._has_started = True
async with anyio.create_task_group() as tg:
# Store the task group for later use
async with self.app.lifespan(self.app) as lifespan_state, anyio.create_task_group() as tg:
# 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
logger.info("StreamableHTTP session manager started")
try:
@ -139,6 +148,7 @@ class StreamableHTTPSessionManager:
# Cancel task group to stop all spawned tasks
tg.cancel_scope.cancel()
self._task_group = None
self._lifespan_state = None
# Clear any remaining server instances
self._server_instances.clear()
self._session_owners.clear()
@ -152,20 +162,26 @@ class StreamableHTTPSessionManager:
if self._task_group is None:
raise RuntimeError("Task group is not initialized. Make sure to use run().")
# TODO: header-only routing for now; body-primary classification
# (per SEP-2575) is a follow-up. 2025 paths below remain unchanged.
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == b"mcp-protocol-version"), None)
if pv in MODERN_PROTOCOL_VERSIONS:
await handle_modern_request(self.app, self.security_settings, pv, scope, receive, send)
# TODO(L49): header-only era-routing for now; body-primary classification
# is a follow-up. The legacy paths below own only the known
# initialize-handshake versions; anything else (including unknown
# values) goes to the modern entry so the classifier can validate it
# 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
# Dispatch to the appropriate handler
if self.stateless:
await self._handle_stateless_request(scope, receive, send)
await self._handle_stateless_request(pv, scope, receive, send)
else:
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."""
logger.debug("Stateless mode: Creating new transport for this request")
# No session ID needed in stateless mode
@ -181,12 +197,29 @@ class StreamableHTTPSessionManager:
async with http_transport.connect() as streams:
read_stream, write_stream = streams
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:
await self.app.run(
read_stream,
write_stream,
self.app.create_initialization_options(),
stateless=True,
await serve_connection(
self.app, dispatcher, connection=connection, lifespan_state=self._lifespan_state
)
except Exception: # pragma: lax no cover
logger.exception("Stateless session crashed")
@ -263,7 +296,7 @@ class StreamableHTTPSessionManager:
task_status.started()
try:
# 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.
# Incoming requests push the deadline forward.
idle_scope = anyio.CancelScope()
@ -272,11 +305,15 @@ class StreamableHTTPSessionManager:
http_transport.idle_scope = 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,
write_stream,
self.app.create_initialization_options(),
stateless=False,
lifespan_state=self._lifespan_state,
session_id=http_transport.mcp_session_id,
)
if idle_scope.cancelled_caught:
@ -309,7 +346,7 @@ class StreamableHTTPSessionManager:
await http_transport.handle_request(scope, receive, send)
else:
# 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
logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}")
body = JSONRPCError(

View file

@ -71,23 +71,6 @@ class NoBackChannelError(MCPError):
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):
"""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,
)
__all__ = ["JSONRPCDispatcher"]
__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data"]
logger = logging.getLogger(__name__)
@ -67,6 +67,23 @@ PeerCancelMode = Literal["interrupt", "signal"]
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:
"""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):
@ -667,7 +684,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
# anyio absorbs the scope's own cancel at __exit__, and
# `cancelled_caught` (unlike `cancel_called`) guarantees the
# 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.
answer_write_started = True
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}",
)
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:
logger.exception("handler for %r raised", req.method)
# TODO(maxisbey): 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
error = handler_exception_to_error_data(e)
if error is not None:
await self._write_error(req.id, error)
else:
logger.exception("handler for %r raised", req.method)
# 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.
def _allocate_id(self) -> int:

View file

@ -4,7 +4,8 @@ from pydantic import FileUrl
from mcp import Client
from mcp.client import ClientRequestContext
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
@ -37,10 +38,10 @@ async def test_list_roots_callback():
assert isinstance(result.content[0], TextContent)
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:
# Make a request to trigger sampling callback
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_list_roots: List roots not supported"
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_list_roots", {"message": "test message"})
assert exc_info.value.error.code == INVALID_REQUEST

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
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.client import ClientRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from mcp.types import (
INVALID_REQUEST,
CreateMessageRequestParams,
CreateMessageResult,
CreateMessageResultWithTools,
@ -47,13 +49,13 @@ async def test_sampling_callback():
assert isinstance(result.content[0], TextContent)
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:
# Make a request to trigger sampling callback
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is True
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Error executing tool test_sampling: Sampling not supported"
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert exc_info.value.error.code == INVALID_REQUEST
@pytest.mark.anyio

View file

@ -97,9 +97,9 @@ _TASKS_DEFERRAL = (
)
_MODERN_NOTIFY_DROP = (
"SingleExchangeDispatcher.notify() no-ops on the modern streamable-http driver; handler-emitted "
"logging/progress notifications never reach the per-request SSE response. Passes once SSE "
"response mode lands."
"The modern single-exchange dispatch context no-ops notify() on the streamable-http driver; "
"handler-emitted logging/progress notifications never reach the per-request SSE response. "
"Passes once SSE response mode lands."
)
@ -3046,6 +3046,50 @@ REQUIREMENTS: dict[str, Requirement] = {
transports=("streamable-http",),
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
# ═══════════════════════════════════════════════════════════════════════════

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