[v2] Dispatcher/ServerRunner receive-path swap — replaces BaseSession (#2710)

This commit is contained in:
Max 2026-06-09 12:58:47 +01:00 • committed by GitHub
commit 5d826490b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 6594 additions and 1701 deletions

View file

@ -14,6 +14,9 @@ from mcp.server import Server
from mcp.server.mcpserver import MCPServer
from mcp.shared.memory import create_client_server_memory_streams
SERVER_SHUTDOWN_GRACE = 2.0
"""Seconds to wait for the in-process server to exit on EOF before cancelling."""
class InMemoryTransport:
"""In-memory transport for testing MCP servers without network overhead.
@ -48,21 +51,49 @@ class InMemoryTransport:
client_read, client_write = client_streams
server_read, server_write = server_streams
async with anyio.create_task_group() as tg:
# Start server in background
tg.start_soon(
lambda: actual_server.run(
server_done = anyio.Event()
async def _run_server() -> None:
try:
await actual_server.run(
server_read,
server_write,
actual_server.create_initialization_options(),
raise_exceptions=self._raise_exceptions,
)
)
finally:
server_done.set()
async with anyio.create_task_group() as tg:
tg.start_soon(_run_server)
try:
yield client_read, client_write
finally:
tg.cancel_scope.cancel()
# EOF the server (and our own read side) instead of
# cancelling outright. The dispatcher's run() cancels its
# own in-flight handlers on read-stream EOF, so for a
# well-behaved server the task exits naturally and the
# task-group join below is immediate. Cancelling here
# unconditionally would `coro.throw()` into this task,
# which on CPython 3.11 (gh-106749) drops `'call'` trace
# events for the outer await chain and desyncs coverage's
# CTracer past the test frame.
await client_write.aclose()
await server_write.aclose()
# Backstop: the dispatcher exits on EOF, but the server's
# own teardown (lifespan __aexit__, connection.exit_stack
# callbacks) runs after that and is user code. If it never
# completes the join would hang forever, so bound the wait
# and fall back to cancelling. The healthy path returns
# from wait() without the timeout firing, so the cancel is
# never reached and gh-106749 stays avoided. If the cancel
# does fire, the checkpoint at the end of
# `create_client_server_memory_streams` resyncs the tracer.
with anyio.move_on_after(SERVER_SHUTDOWN_GRACE):
await server_done.wait()
if not server_done.is_set():
tg.cancel_scope.cancel()
async def __aenter__(self) -> TransportStreams:
"""Connect to the server and return streams for communication."""

View file

@ -10,6 +10,7 @@ from anyio.abc import TaskStatus
from httpx_sse import SSEError, aconnect_sse
from mcp import types
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import create_context_streams
from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client
from mcp.shared.message import SessionMessage
@ -157,3 +158,4 @@ async def sse_client(
yield read_stream, write_stream
tg.cancel_scope.cancel()
await resync_tracer()

View file

@ -15,6 +15,7 @@ from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
from pydantic import ValidationError
from mcp.client._transport import TransportStreams
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.message import ClientMessageMetadata, SessionMessage
@ -586,3 +587,4 @@ async def streamable_http_client(
if transport.session_id and terminate_on_close:
await transport.terminate_session(client)
tg.cancel_scope.cancel()
await resync_tracer()

View file

@ -1,14 +1,11 @@
import importlib.metadata
import logging
import sys
import warnings
import anyio
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.server.lowlevel.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import ServerCapabilities
if not sys.warnoptions:
warnings.simplefilter("ignore")
@ -17,32 +14,10 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("server")
async def receive_loop(session: ServerSession):
logger.info("Starting receive loop")
async for message in session.incoming_messages:
if isinstance(message, Exception):
logger.error("Error: %s", message)
continue
logger.info("Received message from client: %s", message)
async def main():
version = importlib.metadata.version("mcp")
async def main() -> None:
server: Server[dict[str, object]] = Server("mcp")
async with stdio_server() as (read_stream, write_stream):
async with (
ServerSession(
read_stream,
write_stream,
InitializationOptions(
server_name="mcp",
server_version=version,
capabilities=ServerCapabilities(),
),
) as session,
write_stream,
):
await receive_loop(session)
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":

View file

@ -0,0 +1,249 @@
"""`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).
`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.
"""
import logging
from collections.abc import Mapping
from contextlib import AsyncExitStack
from typing import Any, TypeVar, overload
import anyio
from pydantic import BaseModel
from mcp.shared.dispatcher import CallOptions, Outbound
from mcp.shared.exceptions import NoBackChannelError
from mcp.shared.peer import Meta, dump_params
from mcp.types import (
ClientCapabilities,
CreateMessageRequest,
CreateMessageResult,
ElicitRequest,
ElicitResult,
EmptyResult,
InitializeRequestParams,
ListRootsRequest,
ListRootsResult,
LoggingLevel,
PingRequest,
Request,
)
__all__ = ["Connection"]
logger = logging.getLogger(__name__)
ResultT = TypeVar("ResultT", bound=BaseModel)
# Result types for the spec's server-to-client request set, used by
# `Connection.send_request` to infer the result type. If the spec's request
# set grows substantially, consider declaring the result mapping on the
# request types themselves (a `__mcp_result__` ClassVar read via a structural
# protocol) so this table and the overload ladder don't need maintaining.
_RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = {
CreateMessageRequest: CreateMessageResult,
ElicitRequest: ElicitResult,
ListRootsRequest: ListRootsResult,
PingRequest: EmptyResult,
}
def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None:
if not meta:
return payload
out = dict(payload or {})
out["_meta"] = meta
return out
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).
"""
has_standalone_channel: bool
session_id: str | None
client_params: InitializeRequestParams | None
"""The full `initialize` request params; `None` before initialization."""
protocol_version: str | None
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."""
state: dict[str, Any]
"""Per-connection scratch state; persists across requests on this connection."""
exit_stack: AsyncExitStack
"""Per-connection teardown, unwound LIFO (shielded) when the 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
self.session_id = session_id
self.client_params = None
self.protocol_version = None
self.initialized = anyio.Event()
self.state = {}
self.exit_stack = AsyncExitStack()
@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."""
return self.client_params is not None or self.initialized.is_set()
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
"""Send a raw request on the standalone stream.
Low-level `Outbound` channel. Prefer the typed `send_request` or the
convenience methods below; use this directly only for off-spec
messages. `opts` carries per-call `timeout` / `on_progress` /
resumption hints; see `CallOptions`.
Raises:
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)
@overload
async def send_request(
self, req: CreateMessageRequest, *, opts: CallOptions | None = None
) -> CreateMessageResult: ...
@overload
async def send_request(self, req: ElicitRequest, *, opts: CallOptions | None = None) -> ElicitResult: ...
@overload
async def send_request(self, req: ListRootsRequest, *, opts: CallOptions | None = None) -> ListRootsResult: ...
@overload
async def send_request(self, req: PingRequest, *, opts: CallOptions | None = None) -> EmptyResult: ...
@overload
async def send_request(
self, req: Request[Any, Any], *, result_type: type[ResultT], opts: CallOptions | None = None
) -> ResultT: ...
async def send_request(
self,
req: Request[Any, Any],
*,
result_type: type[BaseModel] | None = None,
opts: CallOptions | None = None,
) -> BaseModel:
"""Send a typed server-to-client request and return its typed result.
For spec request types the result type is inferred. For custom requests
pass `result_type=` explicitly.
Raises:
MCPError: The peer responded with an error.
NoBackChannelError: No back-channel for server-initiated requests.
pydantic.ValidationError: The peer's result does not match the expected result type.
KeyError: `result_type` omitted for a non-spec request type.
"""
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
return cls.model_validate(raw, by_name=False)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
"""Send a best-effort notification on the standalone stream.
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)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped %s: standalone stream closed", method)
async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = None) -> None:
"""Send a `ping` request on the standalone stream.
Raises:
MCPError: The peer responded with an error.
NoBackChannelError: `has_standalone_channel` is `False`.
"""
await self.send_raw_request("ping", dump_params(None, meta), opts)
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
"""Send a `notifications/message` log entry on the standalone stream. Best-effort."""
params: dict[str, Any] = {"level": level, "data": data}
if logger is not None:
params["logger"] = logger
await self.notify("notifications/message", _notification_params(params, meta))
async def send_tool_list_changed(self, *, meta: Meta | None = None) -> None:
await self.notify("notifications/tools/list_changed", _notification_params(None, meta))
async def send_prompt_list_changed(self, *, meta: Meta | None = None) -> None:
await self.notify("notifications/prompts/list_changed", _notification_params(None, meta))
async def send_resource_list_changed(self, *, meta: Meta | None = None) -> None:
await self.notify("notifications/resources/list_changed", _notification_params(None, meta))
async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) -> None:
await self.notify("notifications/resources/updated", _notification_params({"uri": uri}, meta))
def check_capability(self, capability: ClientCapabilities) -> bool:
"""Return whether the connected client declared the given capability.
Returns `False` if `initialize` hasn't completed yet.
"""
# TODO: redesign - mirrors v1 ServerSession.check_client_capability
# verbatim for parity.
if self.client_params is None:
return False
have = self.client_params.capabilities
if capability.roots is not None:
if have.roots is None:
return False
if capability.roots.list_changed and not have.roots.list_changed:
return False
if capability.sampling is not None:
if have.sampling is None:
return False
if capability.sampling.context is not None and have.sampling.context is None:
return False
if capability.sampling.tools is not None and have.sampling.tools is None:
return False
if capability.elicitation is not None and have.elicitation is None:
return False
if capability.experimental is not None:
if have.experimental is None:
return False
for k, v in capability.experimental.items():
if k not in have.experimental or have.experimental[k] != v:
return False
return True

View file

@ -1,21 +1,165 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any, Generic
from typing import Any, Generic, Protocol
from pydantic import BaseModel
from typing_extensions import TypeVar
from mcp.server.connection import Connection
from mcp.server.session import ServerSession
from mcp.shared._context import RequestContext
from mcp.shared.context import BaseContext
from mcp.shared.dispatcher import DispatchContext
from mcp.shared.message import CloseSSEStreamCallback
from mcp.shared.peer import Meta
from mcp.shared.transport_context import TransportContext
from mcp.types import LoggingLevel, RequestId, RequestParamsMeta
# Invariant: parameterizes a mutable dataclass field; dict default matches the default lifespan.
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
RequestT = TypeVar("RequestT", default=Any)
@dataclass(kw_only=True)
class ServerRequestContext(RequestContext[ServerSession], Generic[LifespanContextT, RequestT]):
class ServerRequestContext(Generic[LifespanContextT, RequestT]):
"""Per-request context handed to lowlevel request and notification handlers.
Built by `ServerRunner._make_context` for each inbound message. Carries the
connection-scoped `ServerSession` (server-to-client requests and
notifications), per-request metadata, and any per-message data the
transport attached (the HTTP request, SSE stream-close callbacks).
"""
session: ServerSession
lifespan_context: LifespanContextT
request_id: RequestId | None = None
meta: RequestParamsMeta | None = None
request: RequestT | None = None
close_sse_stream: CloseSSEStreamCallback | None = None
close_standalone_sse_stream: CloseSSEStreamCallback | None = None
# Covariant: `lifespan` is exposed read-only, so a `Context[AppState]` passes as `Context[object]`.
LifespanT_co = TypeVar("LifespanT_co", default=Any, covariant=True)
class Context(BaseContext[TransportContext], Generic[LifespanT_co]):
"""Server-side per-request context.
Extends `BaseContext` (transport metadata, the raw back-channel, progress
reporting) with `lifespan`, `connection`, and request-scoped `log`.
Not currently constructed by `ServerRunner`, which hands handlers a
`ServerRequestContext` instead.
"""
def __init__(
self,
dctx: DispatchContext[TransportContext],
*,
lifespan: LifespanT_co,
connection: Connection,
meta: RequestParamsMeta | None = None,
) -> None:
super().__init__(dctx, meta=meta)
self._lifespan = lifespan
self._connection = connection
@property
def lifespan(self) -> LifespanT_co:
"""The server-wide lifespan output (what `Server(..., lifespan=...)` yielded)."""
return self._lifespan
@property
def connection(self) -> Connection:
"""The per-client `Connection` for this request's connection."""
return self._connection
@property
def session_id(self) -> str | None:
"""The transport's session id for this connection, when one exists.
Convenience for `ctx.connection.session_id`. `None` on stdio and
stateless HTTP.
"""
return self._connection.session_id
@property
def headers(self) -> Mapping[str, str] | None:
"""Request headers carried by this message, when the transport has them.
Convenience for `ctx.transport.headers`. `None` on stdio.
"""
return self.transport.headers
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
"""Send a request-scoped `notifications/message` log entry.
Uses this request's back-channel (so the entry rides the request's SSE
stream in streamable HTTP), not the standalone stream - use
`ctx.connection.log(...)` for that.
"""
params: dict[str, Any] = {"level": level, "data": data}
if logger is not None:
params["logger"] = logger
if meta:
params["_meta"] = meta
await self.notify("notifications/message", params)
HandlerResult = BaseModel | dict[str, Any] | None
"""What a request handler (or middleware) may return. `ServerRunner` serializes
all three to a result dict."""
CallNext = Callable[[], Awaitable[HandlerResult]]
_MwLifespanT = TypeVar("_MwLifespanT")
class ServerMiddleware(Protocol[_MwLifespanT]):
"""Context-tier middleware: `(ctx, method, params, call_next) -> result`.
Runs at the top of `ServerRunner._on_request` / `_on_notify` after `ctx`
is built but before any validation, lookup, or handshake. Wraps every
inbound request and notification: `initialize`, the pre-init gate,
`METHOD_NOT_FOUND`, params validation, the handler call, and
`notifications/initialized` all run inside `call_next()`.
`notifications/cancelled` is observed too; the dispatcher applies the
cancellation itself, then forwards the notification. A request-side
failure reaches the middleware as a raised `MCPError` (or
`ValidationError` for malformed params) so observation/logging middleware
can record it. Listed outermost-first on `Server.middleware`.
`ctx.request_id is None` distinguishes a notification from a request. For
notifications `call_next()` returns `None` (a dropped or unhandled
notification also returns `None`) and the middleware's own return value is
discarded.
`params` is the raw inbound mapping (no model validation has happened
yet). For typed inspection, validate against the model the middleware
expects.
Warning: `initialize` is handled inline - the dispatcher does not read
further inbound messages until the middleware chain returns. Awaiting a
server-to-client request (`ctx.session.send_request`, `send_ping`, ...)
while handling `initialize` therefore deadlocks the connection: the
response can never be dequeued. Send-and-forget notifications are safe.
`Server[L].middleware` holds `ServerMiddleware[L]`, so an app-specific
middleware sees `ctx.lifespan_context: L`. While the context is the
mutable `ServerRequestContext` dataclass it is invariant in `L`, so a
reusable middleware should be typed `ServerMiddleware[Any]` to register on
any `Server[L]`.
"""
# TODO(maxisbey): once `_make_context` returns the (covariant) `Context[L]`
# again, restore `_MwLifespanT` to `contravariant=True` and retype `ctx`
# below to `Context[_MwLifespanT]` so reusable middleware can be
# `ServerMiddleware[object]` instead of `ServerMiddleware[Any]`.
async def __call__(
self,
ctx: ServerRequestContext[_MwLifespanT, Any],
method: str,
params: Mapping[str, Any] | None,
call_next: CallNext,
) -> HandlerResult: ...

View file

@ -36,16 +36,14 @@ handler callables by method string.
from __future__ import annotations
import contextvars
import logging
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from dataclasses import dataclass
from importlib.metadata import version as importlib_version
from typing import Any, Generic, cast
from typing import Any, Generic
import anyio
from opentelemetry.trace import SpanKind, StatusCode
from pydantic import BaseModel
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
@ -58,22 +56,45 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAut
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier
from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerRequestContext
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession
from mcp.server.runner import ServerRunner, otel_middleware
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._otel import extract_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import MCPError
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import SessionMessage
from mcp.shared.transport_context import TransportContext
logger = logging.getLogger(__name__)
LifespanResultT = TypeVar("LifespanResultT", default=Any)
_ParamsT = TypeVar("_ParamsT", bound=BaseModel, default=BaseModel)
RequestHandler = Callable[[ServerRequestContext[LifespanResultT], _ParamsT], Awaitable[HandlerResult]]
"""A registered request handler: `(ctx, params) -> result`."""
NotificationHandler = Callable[[ServerRequestContext[LifespanResultT], _ParamsT], Awaitable[None]]
"""A registered notification handler: `(ctx, params) -> None`."""
@dataclass(frozen=True, slots=True)
class HandlerEntry(Generic[LifespanResultT]):
"""A registered handler and the params model to validate incoming params against.
Stored in `Server._request_handlers` / `_notification_handlers` and consumed
by `ServerRunner` to validate, build `Context`, and invoke. The handler's
second-argument type is erased to `Any` in storage (each entry has a
different concrete params type and `Callable` parameters are contravariant);
the precise type is recoverable via `params_type`. The correlation is
enforced at registration time by `Server.add_request_handler`.
"""
params_type: type[BaseModel]
handler: RequestHandler[LifespanResultT, Any]
class NotificationOptions:
def __init__(self, prompts_changed: bool = False, resources_changed: bool = False, tools_changed: bool = False):
@ -83,7 +104,7 @@ class NotificationOptions:
@asynccontextmanager
async def lifespan(_: Server[LifespanResultT]) -> AsyncIterator[dict[str, Any]]:
async def lifespan(_: Server[Any]) -> AsyncIterator[dict[str, Any]]:
"""Default lifespan context manager that does nothing.
Returns:
@ -191,53 +212,91 @@ class Server(Generic[LifespanResultT]):
self.website_url = website_url
self.icons = icons
self.lifespan = lifespan
self._request_handlers: dict[str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]]] = {}
self._notification_handlers: dict[
str, Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[None]]
] = {}
self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {}
self._session_manager: StreamableHTTPSessionManager | None = None
# 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
# Context/middleware rework (covariant `Context[L]`, outbound seam) before
# v2 final.
self.middleware: list[ServerMiddleware[LifespanResultT]] = []
logger.debug("Initializing server %r", name)
# Populate internal handler dicts from on_* kwargs
self._request_handlers.update(
{
method: handler
for method, handler in {
"ping": on_ping,
"prompts/list": on_list_prompts,
"prompts/get": on_get_prompt,
"resources/list": on_list_resources,
"resources/templates/list": on_list_resource_templates,
"resources/read": on_read_resource,
"resources/subscribe": on_subscribe_resource,
"resources/unsubscribe": on_unsubscribe_resource,
"tools/list": on_list_tools,
"tools/call": on_call_tool,
"logging/setLevel": on_set_logging_level,
"completion/complete": on_completion,
}.items()
if handler is not None
}
)
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
("ping", types.RequestParams, on_ping),
("prompts/list", types.PaginatedRequestParams, on_list_prompts),
("prompts/get", types.GetPromptRequestParams, on_get_prompt),
("resources/list", types.PaginatedRequestParams, on_list_resources),
("resources/templates/list", types.PaginatedRequestParams, on_list_resource_templates),
("resources/read", types.ReadResourceRequestParams, on_read_resource),
("resources/subscribe", types.SubscribeRequestParams, on_subscribe_resource),
("resources/unsubscribe", types.UnsubscribeRequestParams, on_unsubscribe_resource),
("tools/list", types.PaginatedRequestParams, on_list_tools),
("tools/call", types.CallToolRequestParams, on_call_tool),
("logging/setLevel", types.SetLevelRequestParams, on_set_logging_level),
("completion/complete", types.CompleteRequestParams, on_completion),
]
self._request_handlers.update({m: HandlerEntry(pt, h) for m, pt, h in _spec_requests if h is not None})
_spec_notifications: list[tuple[str, type[BaseModel], NotificationHandler[LifespanResultT, Any] | None]] = [
("notifications/roots/list_changed", types.NotificationParams, on_roots_list_changed),
("notifications/progress", types.ProgressNotificationParams, on_progress),
]
self._notification_handlers.update(
{
method: handler
for method, handler in {
"notifications/roots/list_changed": on_roots_list_changed,
"notifications/progress": on_progress,
}.items()
if handler is not None
}
{m: HandlerEntry(pt, h) for m, pt, h in _spec_notifications if h is not None}
)
def _add_request_handler(
def add_request_handler(
self,
method: str,
handler: Callable[[ServerRequestContext[LifespanResultT], Any], Awaitable[Any]],
params_type: type[_ParamsT],
handler: RequestHandler[LifespanResultT, _ParamsT],
) -> None:
"""Add a request handler, silently replacing any existing handler for the same method."""
self._request_handlers[method] = handler
"""Register a request handler for `method`.
`params_type` is the model incoming params are validated against
before the handler is invoked. It should subclass `RequestParams` so
`_meta` parses uniformly. A message with no `params` member validates
`{}` against `params_type`: models with required fields reject it as
INVALID_PARAMS, all-optional models reach the handler with their
defaults - the handler never receives `None`. Replaces any existing
handler for the same method, except `initialize`, which is reserved:
the runner owns the handshake, so registering it raises `ValueError`.
Use `Server.middleware` to observe or wrap initialization.
"""
if method == "initialize":
raise ValueError(
"'initialize' is handled by the server runner and cannot be overridden; "
"use Server.middleware to observe or wrap initialization"
)
self._request_handlers[method] = HandlerEntry(params_type, handler)
def add_notification_handler(
self,
method: str,
params_type: type[_ParamsT],
handler: NotificationHandler[LifespanResultT, _ParamsT],
) -> None:
"""Register a notification handler for `method`.
`params_type` should subclass `NotificationParams` so `_meta`
parses uniformly. Absent params follow the same contract as requests:
`{}` is validated, so the handler receives the model with its defaults,
never `None`. Replaces any existing handler. A handler for
`notifications/initialized` runs after the runner has marked the
connection initialized.
"""
self._notification_handlers[method] = HandlerEntry(params_type, handler)
def get_request_handler(self, method: str) -> HandlerEntry[LifespanResultT] | None:
"""Return the registered entry for a request method, or `None`."""
return self._request_handlers.get(method)
def get_notification_handler(self, method: str) -> HandlerEntry[LifespanResultT] | None:
"""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
# handlers but require NotificationOptions to be passed externally for list_changed
@ -347,167 +406,31 @@ class Server(Generic[LifespanResultT]):
# the initialization lifecycle, but can do so with any available node
# rather than requiring initialization for each connection.
stateless: bool = False,
):
async with AsyncExitStack() as stack:
lifespan_context = await stack.enter_async_context(self.lifespan(self))
session = await stack.enter_async_context(
ServerSession(
read_stream,
write_stream,
initialization_options,
stateless=stateless,
)
)
async with anyio.create_task_group() as tg:
try:
async for message in session.incoming_messages:
logger.debug("Received message: %s", message)
if isinstance(message, RequestResponder) and message.context is not None:
context = message.context
else:
context = contextvars.copy_context()
context.run(
tg.start_soon,
self._handle_message,
message,
session,
lifespan_context,
raise_exceptions,
)
finally:
# Transport closed: cancel in-flight handlers. Without this the
# TG join waits for them, and when they eventually try to
# respond they hit a closed write stream (the session's
# _receive_loop closed it when the read stream ended).
tg.cancel_scope.cancel()
async def _handle_message(
self,
message: RequestResponder[types.ClientRequest, types.ServerResult] | types.ClientNotification | Exception,
session: ServerSession,
lifespan_context: LifespanResultT,
raise_exceptions: bool = False,
):
with warnings.catch_warnings(record=True) as w:
match message:
case RequestResponder() as responder:
with responder:
await self._handle_request(
message, responder.request, session, lifespan_context, raise_exceptions
)
case Exception():
logger.error(f"Received exception from stream: {message}")
if raise_exceptions:
raise message
case _:
await self._handle_notification(message, session, lifespan_context)
for warning in w: # pragma: lax no cover
logger.info("Warning: %s: %s", warning.category.__name__, warning.message)
async def _handle_request(
self,
message: RequestResponder[types.ClientRequest, types.ServerResult],
req: types.ClientRequest,
session: ServerSession,
lifespan_context: LifespanResultT,
raise_exceptions: bool,
):
logger.info("Processing request of type %s", type(req).__name__)
target = getattr(req.params, "name", None) if req.params else None
span_name = f"MCP handle {req.method} {target}" if target else f"MCP handle {req.method}"
# Extract W3C trace context from _meta (SEP-414).
meta = cast(dict[str, Any] | None, getattr(req.params, "meta", None)) if req.params else None
parent_context = extract_trace_context(meta) if meta is not None else None
with otel_span(
span_name,
kind=SpanKind.SERVER,
attributes={"mcp.method.name": req.method, "jsonrpc.request.id": message.request_id},
context=parent_context,
) as span:
if handler := self._request_handlers.get(req.method):
logger.debug("Dispatching request of type %s", type(req).__name__)
try:
# Extract request context and close_sse_stream from message metadata
request_data = None
close_sse_stream_cb = None
close_standalone_sse_stream_cb = None
if message.message_metadata is not None and isinstance(
message.message_metadata, ServerMessageMetadata
):
request_data = message.message_metadata.request_context
close_sse_stream_cb = message.message_metadata.close_sse_stream
close_standalone_sse_stream_cb = message.message_metadata.close_standalone_sse_stream
ctx = ServerRequestContext(
request_id=message.request_id,
meta=message.request_meta,
session=session,
lifespan_context=lifespan_context,
request=request_data,
close_sse_stream=close_sse_stream_cb,
close_standalone_sse_stream=close_standalone_sse_stream_cb,
)
response = await handler(ctx, req.params)
except MCPError as err:
response = err.error
except anyio.get_cancelled_exc_class():
if message.cancelled:
# Client sent CancelledNotification; responder.cancel() already
# sent an error response, so skip the duplicate.
logger.info("Request %s cancelled - duplicate response suppressed", message.request_id)
return
# Transport-close cancellation from the TG in run(); re-raise so the
# TG swallows its own cancellation.
raise
except Exception as err:
if raise_exceptions: # pragma: no cover
raise err
response = types.ErrorData(code=0, message=str(err))
else:
response = types.ErrorData(code=types.METHOD_NOT_FOUND, message="Method not found")
if isinstance(response, types.ErrorData) and span is not None:
span.set_status(StatusCode.ERROR, response.message)
try:
await message.respond(response)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
# Transport closed between handler unblocking and respond. Happens
# when _receive_loop's finally wakes a handler blocked on
# send_request: the handler runs to respond() before run()'s TG
# cancel fires, but after the write stream closed. Closed if our
# end closed (_receive_loop's async-with exit); Broken if the peer
# end closed first (streamable_http terminate()).
logger.debug("Response for %s dropped - transport closed", message.request_id)
return
logger.debug("Response sent")
async def _handle_notification(
self,
notify: types.ClientNotification,
session: ServerSession,
lifespan_context: LifespanResultT,
) -> None:
if handler := self._notification_handlers.get(notify.method):
logger.debug("Dispatching notification of type %s", type(notify).__name__)
try:
ctx = ServerRequestContext(
session=session,
lifespan_context=lifespan_context,
)
await handler(ctx, notify.params)
except Exception: # pragma: no cover
logger.exception("Uncaught exception in notification handler")
async with self.lifespan(self) as lifespan_context:
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 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],
)
await runner.run()
def streamable_http_app(
self,

View file

@ -607,11 +607,7 @@ class MCPServer(Generic[LifespanResultT]):
completion=result if result is not None else Completion(values=[], total=None, has_more=None),
)
# TODO(maxisbey): remove private access — completion needs post-construction
# handler registration, find a better pattern for this
self._lowlevel_server._add_request_handler( # pyright: ignore[reportPrivateUsage]
"completion/complete", handler
)
self._lowlevel_server.add_request_handler("completion/complete", CompleteRequestParams, handler)
return func
return decorator

377
src/mcp/server/runner.py Normal file
View file

@ -0,0 +1,377 @@
"""`ServerRunner` - per-connection orchestrator over a `Dispatcher`.
`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` holds a `Server` directly - `Server` is the registry.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from functools import partial, reduce
from typing import TYPE_CHECKING, Any, Generic, cast, get_args
import anyio.abc
from opentelemetry.trace import SpanKind, StatusCode
from pydantic import BaseModel, ValidationError
from typing_extensions import TypeVar
from mcp.server.connection import Connection
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
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, DispatchMiddleware, OnRequest
from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
ClientRequest,
ErrorData,
Implementation,
InitializeRequestParams,
InitializeResult,
NotificationParams,
RequestParams,
RequestParamsMeta,
client_request_adapter,
)
if TYPE_CHECKING:
from mcp.server.lowlevel.server import Server
__all__ = ["CallNext", "ServerMiddleware", "ServerRunner", "otel_middleware"]
logger = logging.getLogger(__name__)
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."""
def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
"""Lift `_meta` from raw params; `None` when absent or malformed, so
context construction is independent of params validity."""
if not params or "_meta" not in params:
return None
try:
return RequestParams.model_validate(params, by_name=False).meta
except ValidationError:
return None
_SPEC_CLIENT_METHODS: frozenset[str] = frozenset(
cast(type[BaseModel], arm).model_fields["method"].default for arm in get_args(ClientRequest)
)
"""Method names in the spec `ClientRequest` union, derived from the
discriminator literal on each arm. Used to gate upfront validation so custom
methods registered via `add_request_handler` are not rejected."""
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
Mirrors the span shape of the existing `Server._handle_request`: span name
`"MCP handle <method> [<target>]"`, `mcp.method.name` attribute, W3C
trace context extracted from `params._meta` (SEP-414), and an ERROR
status if the handler raises.
"""
async def wrapped(
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
target: str | None
match params:
case {"name": str() as target}:
pass
case _:
target = None
parent: Any | None
match params:
case {"_meta": {**meta}}:
parent = extract_trace_context(meta)
case _:
parent = None
span_name = f"MCP handle {method}{f' {target}' if target else ''}"
# `otel_middleware` wraps `on_request` only, so `request_id` is always set.
attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)}
with otel_span(
span_name,
kind=SpanKind.SERVER,
attributes=attributes,
context=parent,
record_exception=False,
set_status_on_exception=False,
) as span:
try:
return await next_on_request(dctx, method, params)
except MCPError as e:
span.set_status(StatusCode.ERROR, e.error.message)
raise
except ValidationError:
# Mirror the sanitized wire response; pydantic messages carry client input.
span.set_status(StatusCode.ERROR, "Invalid request parameters")
raise
except Exception as e:
span.record_exception(e)
span.set_status(StatusCode.ERROR, str(e))
raise
return wrapped
def _dump_result(result: Any) -> dict[str, Any]:
if result is None:
return {}
if isinstance(result, ErrorData):
# ErrorData is a JSON-RPC error, not a success result. Handler returns
# already raise in `_inner`; this catches middleware returning one.
raise MCPError.from_error_data(result)
if isinstance(result, BaseModel):
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
if isinstance(result, dict):
return cast(dict[str, Any], result)
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
@dataclass
class ServerRunner(Generic[LifespanT]):
"""Per-connection orchestrator. One instance per client connection."""
server: Server[LifespanT]
dispatcher: JSONRPCDispatcher[Any]
lifespan_state: LifespanT
has_standalone_channel: bool
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])
connection: Connection = field(init=False)
session: ServerSession = field(init=False)
"""Connection-scoped: the same instance reaches every request as `ctx.session`."""
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()`.
"""
return reduce(lambda h, mw: mw(h), reversed(self.dispatch_middleware), self._on_request)
async def _on_request(
self,
dctx: DispatchContext[TransportContext],
method: str,
params: Mapping[str, Any] | None,
) -> dict[str, Any]:
ctx = self._make_context(dctx, _extract_meta(params))
async def _inner() -> HandlerResult:
# TODO(maxisbey): pinned compat: spec methods are validated against
# the ClientRequest union before lookup, so malformed params are
# INVALID_PARAMS even with no handler registered.
if method in _SPEC_CLIENT_METHODS:
payload: dict[str, Any] = {"method": method}
if params is not None:
payload["params"] = dict(params)
client_request_adapter.validate_python(payload, by_name=False)
# TODO(maxisbey): 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":
return self._handle_initialize(params)
if not self.connection.initialize_accepted and method not in _INIT_EXEMPT:
# Pinned compat: the same error shape the union validation produced.
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="")
entry = self.server.get_request_handler(method)
if entry is None:
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found")
# Absent params validate as {} (required fields still reject), so
# the handler receives the model with its defaults, never None.
typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)
result = await entry.handler(ctx, typed_params)
if isinstance(result, ErrorData):
# Raise inside the chain so middleware observes the failure.
raise MCPError.from_error_data(result)
return result
call = self._compose_server_middleware(ctx, method, params, _inner)
result = _dump_result(await call())
if method == "initialize":
# Commit only on chain success, so a middleware veto leaves no state.
# Race-free: the read loop is parked until this call returns.
self.connection.client_params, self.connection.protocol_version = self._negotiate_initialize(params)
return result
async def _on_notify(
self,
dctx: DispatchContext[TransportContext],
method: str,
params: Mapping[str, Any] | None,
) -> None:
ctx = self._make_context(dctx, _extract_meta(params))
async def _inner() -> None:
if method == "notifications/initialized":
# Validate before committing so a malformed notification leaves
# state untouched; then fall through so a registered handler
# observes an initialized connection.
if params is not None:
try:
NotificationParams.model_validate(params, by_name=False)
except ValidationError:
logger.warning("dropped %r: malformed params", method)
return
self.connection.initialized.set()
elif not self.connection.initialize_accepted:
logger.debug("dropped %s: received before initialization", method)
return
entry = self.server.get_notification_handler(method)
if entry is None:
logger.debug("no handler for notification %s", method)
return
# Same absent-params contract as requests.
try:
typed_params = entry.params_type.model_validate({} if params is None else params, by_name=False)
except ValidationError:
logger.warning("dropped %r: malformed params", method)
return
await entry.handler(ctx, typed_params)
call = self._compose_server_middleware(ctx, method, params, _inner)
try:
await call()
except Exception:
# A crashing handler must not cancel the dispatcher's task group;
# middleware saw the raise out of call_next() first.
logger.exception("notification handler for %r raised", method)
def _compose_server_middleware(
self,
ctx: ServerRequestContext[LifespanT, Any],
method: str,
params: Mapping[str, Any] | None,
inner: CallNext,
) -> CallNext:
"""Wrap `inner` in `Server.middleware`, outermost-first.
Shared by `_on_request` and `_on_notify` so the same middleware chain
observes every inbound message.
"""
call = inner
for mw in reversed(self.server.middleware):
call = partial(mw, ctx, method, params, call)
return call
def _make_context(
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None
) -> ServerRequestContext[LifespanT, Any]:
# TODO(maxisbey): 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
if isinstance(md, ServerMessageMetadata):
request = md.request_context
close_sse_stream = md.close_sse_stream
close_standalone_sse_stream = md.close_standalone_sse_stream
else:
request = close_sse_stream = close_standalone_sse_stream = None
return ServerRequestContext(
session=self.session,
lifespan_context=self.lifespan_state,
request_id=dctx.request_id,
meta=meta,
request=request,
close_sse_stream=close_sse_stream,
close_standalone_sse_stream=close_standalone_sse_stream,
)
@staticmethod
def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]:
"""Validate `initialize` params and pick the protocol version."""
init = InitializeRequestParams.model_validate(params or {}, by_name=False)
requested = init.protocol_version
negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else LATEST_PROTOCOL_VERSION
return init, negotiated
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
return InitializeResult(
protocol_version=negotiated,
capabilities=opts.capabilities,
server_info=Implementation(
name=opts.server_name,
title=opts.title,
description=opts.description,
version=opts.server_version,
website_url=opts.website_url,
icons=opts.icons,
),
instructions=opts.instructions,
)

View file

@ -1,188 +1,104 @@
"""ServerSession Module
"""`ServerSession`: server-to-client requests and notifications.
This module provides the ServerSession class, which manages communication between the
server and client in the MCP (Model Context Protocol) framework. It is most commonly
used in MCP servers to interact with the client.
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`,
`send_log_message`, ...) to call back to the client.
Common usage pattern:
```
async def handle_call_tool(ctx: RequestContext, params: CallToolRequestParams) -> CallToolResult:
# Check client capabilities before proceeding
if ctx.session.check_client_capability(
types.ClientCapabilities(experimental={"advanced_tools": dict()})
):
result = await perform_advanced_tool_operation(params.arguments)
else:
result = await perform_basic_tool_operation(params.arguments)
return result
async def handle_list_prompts(ctx: RequestContext, params) -> ListPromptsResult:
if ctx.session.client_params:
return ListPromptsResult(prompts=generate_custom_prompts(ctx.session.client_params))
return ListPromptsResult(prompts=default_prompts)
server = Server(name, on_call_tool=handle_call_tool, on_list_prompts=handle_list_prompts)
```
The ServerSession class is typically used internally by the Server class and should not
be instantiated directly by users of the MCP framework.
The receive-loop, initialize handling, and per-request task isolation that
used to live here are now owned by `JSONRPCDispatcher` and `ServerRunner`.
"""
from enum import Enum
from typing import Any, TypeVar, overload
import anyio
import anyio.lowlevel
from anyio.streams.memory import MemoryObjectReceiveStream
from pydantic import AnyUrl, TypeAdapter
from pydantic import AnyUrl, BaseModel
from mcp import types
from mcp.server.models import InitializationOptions
from mcp.server.connection import Connection
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import StatelessModeNotSupported
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.session import (
BaseSession,
RequestResponder,
)
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.dispatcher import CallOptions, ProgressFnT
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata
__all__ = ["ServerSession"]
ResultT = TypeVar("ResultT", bound=BaseModel)
class InitializationState(Enum):
NotInitialized = 1
Initializing = 2
Initialized = 3
class ServerSession:
"""Connection-scoped proxy for server-to-client requests and notifications.
ServerSessionT = TypeVar("ServerSessionT", bound="ServerSession")
ServerRequestResponder = (
RequestResponder[types.ClientRequest, types.ServerResult] | types.ClientNotification | Exception
)
class ServerSession(
BaseSession[
types.ServerRequest,
types.ServerNotification,
types.ServerResult,
types.ClientRequest,
types.ClientNotification,
]
):
_initialized: InitializationState = InitializationState.NotInitialized
_client_params: types.InitializeRequestParams | None = None
`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.
"""
def __init__(
self,
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
init_options: InitializationOptions,
dispatcher: JSONRPCDispatcher[Any],
connection: Connection,
*,
stateless: bool = False,
) -> None:
super().__init__(read_stream, write_stream)
self._dispatcher = dispatcher
self._connection = connection
self._stateless = stateless
self._initialization_state = (
InitializationState.Initialized if stateless else InitializationState.NotInitialized
)
self._init_options = init_options
self._incoming_message_stream_writer, self._incoming_message_stream_reader = anyio.create_memory_object_stream[
ServerRequestResponder
](0)
self._exit_stack.push_async_callback(lambda: self._incoming_message_stream_reader.aclose())
@property
def _receive_request_adapter(self) -> TypeAdapter[types.ClientRequest]:
return types.client_request_adapter
@property
def _receive_notification_adapter(self) -> TypeAdapter[types.ClientNotification]:
return types.client_notification_adapter
@property
def client_params(self) -> types.InitializeRequestParams | None:
return self._client_params
"""The client's `initialize` request params; `None` before initialization."""
return self._connection.client_params
async def send_request(
self,
request: types.ServerRequest,
result_type: type[ResultT],
request_read_timeout_seconds: float | None = None,
metadata: ServerMessageMetadata | None = None,
progress_callback: ProgressFnT | None = None,
) -> 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.
pydantic.ValidationError: The peer's result does not match `result_type`.
"""
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {}
if request_read_timeout_seconds:
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"])
result = await self._dispatcher.send_raw_request(
data["method"], data.get("params"), opts or None, _related_request_id=related
)
return result_type.model_validate(result, by_name=False)
async def send_notification(
self,
notification: types.ServerNotification,
related_request_id: types.RequestId | None = None,
) -> None:
"""Send a typed server-to-client notification."""
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)
def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
"""Check if the client supports a specific capability."""
if self._client_params is None: # pragma: lax no cover
return False
client_caps = self._client_params.capabilities
if capability.roots is not None: # pragma: lax no cover
if client_caps.roots is None:
return False
if capability.roots.list_changed and not client_caps.roots.list_changed:
return False
if capability.sampling is not None: # pragma: lax no cover
if client_caps.sampling is None:
return False
if capability.sampling.context is not None and client_caps.sampling.context is None:
return False
if capability.sampling.tools is not None and client_caps.sampling.tools is None:
return False
if capability.elicitation is not None and client_caps.elicitation is None: # pragma: lax no cover
return False
if capability.experimental is not None: # pragma: lax no cover
if client_caps.experimental is None:
return False
for exp_key, exp_value in capability.experimental.items():
if exp_key not in client_caps.experimental or client_caps.experimental[exp_key] != exp_value:
return False
return True
async def _receive_loop(self) -> None:
async with self._incoming_message_stream_writer:
await super()._receive_loop()
async def _received_request(self, responder: RequestResponder[types.ClientRequest, types.ServerResult]):
match responder.request:
case types.InitializeRequest(params=params):
requested_version = params.protocol_version
self._initialization_state = InitializationState.Initializing
self._client_params = params
with responder:
await responder.respond(
types.InitializeResult(
protocol_version=requested_version
if requested_version in SUPPORTED_PROTOCOL_VERSIONS
else types.LATEST_PROTOCOL_VERSION,
capabilities=self._init_options.capabilities,
server_info=types.Implementation(
name=self._init_options.server_name,
title=self._init_options.title,
description=self._init_options.description,
version=self._init_options.server_version,
website_url=self._init_options.website_url,
icons=self._init_options.icons,
),
instructions=self._init_options.instructions,
)
)
self._initialization_state = InitializationState.Initialized
case types.PingRequest():
# Ping requests are allowed at any time
pass
case _:
if self._initialization_state != InitializationState.Initialized:
raise RuntimeError("Received request before initialization was complete")
async def _received_notification(self, notification: types.ClientNotification) -> None:
# Need this to avoid ASYNC910
await anyio.lowlevel.checkpoint()
match notification:
case types.InitializedNotification():
self._initialization_state = InitializationState.Initialized
case _:
if self._initialization_state != InitializationState.Initialized: # pragma: no cover
raise RuntimeError("Received notification before initialization was complete")
return self._connection.check_capability(capability)
async def send_log_message(
self,
@ -293,7 +209,7 @@ class ServerSession(
"""
if self._stateless:
raise StatelessModeNotSupported(method="sampling")
client_caps = self._client_params.capabilities if self._client_params else None
client_caps = self.client_params.capabilities if self.client_params else None
validate_sampling_tools(client_caps, tools, tool_choice)
validate_tool_use_result_messages(messages)
@ -313,7 +229,6 @@ class ServerSession(
)
metadata_obj = ServerMessageMetadata(related_request_id=related_request_id)
# Use different result types based on whether tools are provided
if tools is not None:
return await self.send_request(
request=request,
@ -488,10 +403,3 @@ class ServerSession(
),
related_request_id,
)
async def _handle_incoming(self, req: ServerRequestResponder) -> None:
await self._incoming_message_stream_writer.send(req)
@property
def incoming_messages(self) -> MemoryObjectReceiveStream[ServerRequestResponder]:
return self._incoming_message_stream_reader

View file

@ -633,7 +633,10 @@ class StreamableHTTPServerTransport:
finally:
await sse_stream_reader.aclose()
except Exception as err:
except Exception as err: # pragma: lax no cover
# Reached only when something raises during POST handling outside
# the per-SSE-stream guard above; whether tests reach this depends
# on client teardown timing.
logger.exception("Error handling POST request")
response = self._create_error_response(
f"Error handling POST request: {err}",
@ -641,7 +644,7 @@ class StreamableHTTPServerTransport:
INTERNAL_ERROR,
)
await response(scope, receive, send)
if writer: # pragma: no cover
if writer:
await writer.send(Exception(err))
return # pragma: no cover

View file

@ -21,6 +21,7 @@ from mcp.server.streamable_http import (
StreamableHTTPServerTransport,
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
if TYPE_CHECKING:
@ -139,6 +140,7 @@ class StreamableHTTPSessionManager:
# Clear any remaining server instances
self._server_instances.clear()
self._session_owners.clear()
await resync_tracer()
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Process ASGI request with proper session handling and transport setup.

19
src/mcp/shared/_compat.py Normal file
View file

@ -0,0 +1,19 @@
"""Workarounds for CPython interpreter bugs the SDK papers over."""
import anyio.lowlevel
__all__ = ["resync_tracer"]
async def resync_tracer() -> None:
"""Resync coverage tracing after a cancelled task-group join.
A cancel delivered at a join resumes the awaiting coroutine chain via
`coro.throw()`; on CPython 3.11 (python/cpython#106749) that drops the
`'call'` trace events for the outer frames and desyncs coverage's CTracer
until the chain next suspends and resumes normally. Yielding once here
resumes via `.send()`, which re-stamps the missing events. Shielded so a
pending outer cancel is not re-delivered at this point; behaviorally a
no-op. Delete this module when Python 3.11 support ends (EOL 2027-10).
"""
await anyio.lowlevel.cancel_shielded_checkpoint()

View file

@ -20,9 +20,18 @@ def otel_span(
kind: SpanKind,
attributes: dict[str, Any] | None = None,
context: Context | None = None,
record_exception: bool = True,
set_status_on_exception: bool = True,
) -> Iterator[Any]:
"""Create an OTel span."""
with _tracer.start_as_current_span(name, kind=kind, attributes=attributes, context=context) as span:
with _tracer.start_as_current_span(
name,
kind=kind,
attributes=attributes,
context=context,
record_exception=record_exception,
set_status_on_exception=set_status_on_exception,
) as span:
yield span
@ -31,6 +40,13 @@ def inject_trace_context(meta: dict[str, Any]) -> None:
inject(meta)
def extract_trace_context(meta: dict[str, Any]) -> Context:
"""Extract W3C trace context from a `_meta` dict."""
return extract(meta)
def extract_trace_context(meta: dict[str, Any]) -> Context | None:
"""Extract W3C trace context from a `_meta` dict.
Returns `None` when the carrier is malformed; telemetry parsing must
never fail the request it annotates.
"""
try:
return extract(meta)
except (TypeError, ValueError):
return None

85
src/mcp/shared/context.py Normal file
View file

@ -0,0 +1,85 @@
"""`BaseContext` - the user-facing per-request context.
Composition over a `DispatchContext`: forwards the transport metadata, the
back-channel (`send_raw_request`/`notify`), progress reporting, and the cancel
event. Adds `meta` (the inbound request's `_meta` field).
Satisfies `Outbound`, so `ClientPeer` can wrap it. Shared between client and
server: the server's `Context` extends this with `lifespan`/`connection`;
`ClientContext` is just an alias.
"""
from collections.abc import Mapping
from typing import Any, Generic
import anyio
from typing_extensions import TypeVar
from mcp.shared.dispatcher import CallOptions, DispatchContext
from mcp.shared.transport_context import TransportContext
from mcp.types import RequestParamsMeta
__all__ = ["BaseContext"]
TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportContext, covariant=True)
class BaseContext(Generic[TransportT]):
"""Per-request context wrapping a `DispatchContext`.
`ServerRunner` constructs one per inbound request and passes it to the
user's handler.
"""
def __init__(self, dctx: DispatchContext[TransportT], meta: RequestParamsMeta | None = None) -> None:
self._dctx = dctx
self._meta = meta
@property
def transport(self) -> TransportT:
"""Transport-specific metadata for this inbound request."""
return self._dctx.transport
@property
def cancel_requested(self) -> anyio.Event:
"""Set when the peer sends `notifications/cancelled` for this request."""
return self._dctx.cancel_requested
@property
def can_send_request(self) -> bool:
"""Whether the back-channel can currently deliver server-initiated requests.
`False` when the transport has no back-channel, or when the underlying
dispatch context has been closed because the inbound request finished.
"""
return self._dctx.can_send_request
@property
def meta(self) -> RequestParamsMeta | None:
"""The inbound request's `_meta` field, if present."""
return self._meta
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
"""Send a request to the peer on the back-channel.
Raises:
MCPError: The peer responded with an error.
NoBackChannelError: `can_send_request` is `False`.
"""
return await self._dctx.send_raw_request(method, params, opts)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
"""Send a notification to the peer on the back-channel."""
await self._dctx.notify(method, params)
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for this request, if the peer supplied a progress token.
A no-op when no token was supplied.
"""
await self._dctx.progress(progress, total, message)

View file

@ -0,0 +1,197 @@
"""In-memory `Dispatcher` that wires two peers together with no transport.
`DirectDispatcher` is the simplest possible `Dispatcher` implementation: a
request on one side directly invokes the other side's `on_request`. There is no
serialization, no JSON-RPC framing, and no streams. It exists to:
* prove the `Dispatcher` Protocol is implementable without JSON-RPC
* provide a fast substrate for testing the layers above the dispatcher
(`ServerRunner`, `Context`, `Connection`) without wire-level moving parts
* embed a server in-process when the JSON-RPC overhead is unnecessary
Unlike `JSONRPCDispatcher`, exceptions raised in a handler propagate directly
to the caller - there is no exception-to-`ErrorData` boundary here.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
import anyio
import anyio.abc
from pydantic import ValidationError
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.message import MessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.types import INTERNAL_ERROR, INVALID_PARAMS, REQUEST_TIMEOUT, RequestId
__all__ = ["DirectDispatcher", "create_direct_dispatcher_pair"]
DIRECT_TRANSPORT_KIND = "direct"
_Request = Callable[[str, Mapping[str, Any] | None, CallOptions | None], Awaitable[dict[str, Any]]]
_Notify = Callable[[str, Mapping[str, Any] | None], Awaitable[None]]
@dataclass
class _DirectDispatchContext:
"""`DispatchContext` for an inbound request on a `DirectDispatcher`.
The back-channel callables target the *originating* side, so a handler's
`send_raw_request` reaches the peer that made the inbound request.
"""
transport: TransportContext
_back_request: _Request
_back_notify: _Notify
request_id: RequestId | None = None
"""Always `None`: direct dispatch has no wire-level request id."""
message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework
"""Always `None`: in-memory dispatch attaches no transport metadata."""
_on_progress: ProgressFnT | None = None
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
@property
def can_send_request(self) -> bool:
return self.transport.can_send_request
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
await self._back_notify(method, params)
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
if not self.can_send_request:
raise NoBackChannelError(method)
return await self._back_request(method, params, opts)
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
if self._on_progress is not None:
await self._on_progress(progress, total, message)
class DirectDispatcher:
"""A `Dispatcher` that calls a peer's handlers directly, in-process.
Two instances are wired together with `create_direct_dispatcher_pair`; each
holds a reference to the other. `send_raw_request` on one awaits the peer's
`on_request`. `run` parks until `close` is called.
"""
def __init__(self, transport_ctx: TransportContext):
self._transport_ctx = transport_ctx
self._peer: DirectDispatcher | None = None
self._on_request: OnRequest | None = None
self._on_notify: OnNotify | None = None
self._ready = anyio.Event()
self._closed = anyio.Event()
def connect_to(self, peer: DirectDispatcher) -> None:
self._peer = peer
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
if self._peer is None:
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")
return await self._peer._dispatch_request(method, params, opts)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
if self._peer is None:
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")
await self._peer._dispatch_notify(method, params)
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
self._on_request = on_request
self._on_notify = on_notify
self._ready.set()
task_status.started()
await self._closed.wait()
def close(self) -> None:
self._closed.set()
def _make_context(self, on_progress: ProgressFnT | None = None) -> _DirectDispatchContext:
assert self._peer is not None
peer = self._peer
return _DirectDispatchContext(
transport=self._transport_ctx,
_back_request=lambda m, p, o: peer._dispatch_request(m, p, o),
_back_notify=lambda m, p: peer._dispatch_notify(m, p),
_on_progress=on_progress,
)
async def _dispatch_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None,
) -> dict[str, Any]:
await self._ready.wait()
assert self._on_request is not None
opts = opts or {}
dctx = self._make_context(on_progress=opts.get("on_progress"))
try:
with anyio.fail_after(opts.get("timeout")):
try:
return await self._on_request(dctx, method, params)
except MCPError:
raise
except ValidationError as e:
# Same shape JSONRPCDispatcher writes, so runner-over-direct
# tests see what runner-over-JSONRPC would.
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") from e
except Exception as e:
raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e
except TimeoutError:
raise MCPError(
code=REQUEST_TIMEOUT,
message=f"Timed out after {opts.get('timeout')}s waiting for {method!r}",
) from None
async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) -> None:
await self._ready.wait()
assert self._on_notify is not None
dctx = self._make_context()
await self._on_notify(dctx, method, params)
def create_direct_dispatcher_pair(
*,
can_send_request: bool = True,
headers: Mapping[str, str] | None = None,
) -> tuple[DirectDispatcher, DirectDispatcher]:
"""Create two `DirectDispatcher` instances wired to each other.
Args:
can_send_request: Sets `TransportContext.can_send_request` on both
sides. Pass `False` to simulate a transport with no back-channel.
headers: Sets `TransportContext.headers` on both sides.
Returns:
A `(client, server)` pair. The wiring is symmetric, so the roles
are conventional only.
"""
ctx = TransportContext(kind=DIRECT_TRANSPORT_KIND, can_send_request=can_send_request, headers=headers)
client = DirectDispatcher(ctx)
server = DirectDispatcher(ctx)
client.connect_to(server)
server.connect_to(client)
return client, server

View file

@ -0,0 +1,209 @@
"""Dispatcher Protocol - the call/return boundary between transports and handlers.
A Dispatcher turns a duplex message channel into two things:
* an outbound API: `send_raw_request(method, params)` and `notify(method, params)`
* an inbound pump: `run(on_request, on_notify)` that drives the receive loop
and invokes the supplied handlers for each incoming request/notification
It is deliberately *not* MCP-aware. Method names are strings, params and
results are `dict[str, Any]`. The MCP type layer (request/result models,
capability negotiation, `Context`) sits above this; the wire encoding
(JSON-RPC, gRPC, in-process direct calls) sits below it.
See `JSONRPCDispatcher` for the production implementation and
`DirectDispatcher` for an in-memory implementation used in tests and for
embedding a server in-process.
"""
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable
import anyio
import anyio.abc
from mcp.shared.message import MessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.types import RequestId
__all__ = [
"CallOptions",
"DispatchContext",
"DispatchMiddleware",
"Dispatcher",
"OnNotify",
"OnRequest",
"Outbound",
"ProgressFnT",
]
TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True)
class ProgressFnT(Protocol):
"""Callback invoked when a progress notification arrives for a pending request."""
async def __call__(self, progress: float, total: float | None, message: str | None) -> None: ...
class CallOptions(TypedDict, total=False):
"""Per-call options for `Outbound.send_raw_request`.
All keys are optional. Dispatchers ignore keys they do not understand.
"""
timeout: float
"""Seconds to wait for a result before raising and sending `notifications/cancelled`."""
on_progress: ProgressFnT
"""Receive `notifications/progress` updates for this request."""
resumption_token: str
"""Opaque token to resume a previously interrupted request.
Client-side, streamable-HTTP only. Ignored by server dispatchers and other
transports, and also ignored (with a debug log) for requests sent from a
`DispatchContext`, where routing onto the inbound request's stream takes
precedence. Supports protocol version 2025-11-25 and earlier; SSE-stream
resumption is removed in the next protocol revision.
"""
on_resumption_token: Callable[[str], Awaitable[None]]
"""Receive a resumption token when the transport issues one for this request.
Client-side, streamable-HTTP only. Ignored by server dispatchers and other
transports, and also ignored (with a debug log) for requests sent from a
`DispatchContext`, where routing onto the inbound request's stream takes
precedence. Supports protocol version 2025-11-25 and earlier; SSE-stream
resumption is removed in the next protocol revision.
"""
@runtime_checkable
class Outbound(Protocol):
"""Anything that can send requests and notifications to the peer.
Both `Dispatcher` (top-level outbound) and `DispatchContext` (back-channel
during an inbound request) extend this. The MCP type layer (`ClientPeer`,
`Connection`) builds typed `send_request` / convenience methods on top of
this raw channel.
"""
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
"""Send a request and await its raw result dict.
`opts` carries per-call `timeout` / `on_progress` / resumption hints;
see `CallOptions`.
Raises:
MCPError: If the peer responded with an error, or the handler
raised. Implementations normalize all handler exceptions to
`MCPError` so callers see a single exception type.
"""
...
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
"""Send a fire-and-forget notification."""
...
class DispatchContext(Outbound, Protocol[TransportT_co]):
"""Per-request context handed to `on_request` / `on_notify`.
Carries the transport metadata for the inbound message and provides the
back-channel for sending requests/notifications to the peer while handling
it. `send_raw_request` raises `NoBackChannelError` if `can_send_request`
is `False`.
"""
@property
def transport(self) -> TransportT_co:
"""Transport-specific metadata for this inbound message."""
...
@property
def can_send_request(self) -> bool:
"""Whether the back-channel can currently deliver server-initiated requests.
`False` when the transport has no back-channel, or when this context has
been closed (the inbound request finished). `send_raw_request` raises
`NoBackChannelError` exactly when this is `False`.
"""
...
@property
def request_id(self) -> RequestId | None:
"""The id of the inbound request, or `None` for a notification.
For JSON-RPC this is the wire `id` field. Handlers thread it through
as `related_request_id` on outbound notifications so HTTP transports
can route them onto the originating request's response stream.
"""
...
@property
def message_metadata(self) -> MessageMetadata:
"""The metadata the transport attached to this inbound message, if any.
This is `SessionMessage.metadata` passed through verbatim: HTTP
transports attach `ServerMessageMetadata` (the HTTP request, SSE
stream-close callbacks); stdio and in-memory dispatch attach nothing.
Tied to the `SessionMessage` wire format - goes away when transports
stop delivering messages that way.
"""
# TODO(maxisbey): remove for context rework
...
@property
def cancel_requested(self) -> anyio.Event:
"""Set when the peer sends `notifications/cancelled` for this request."""
...
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for the inbound request, if the peer supplied a progress token.
A no-op when no token was supplied.
"""
...
OnRequest = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[dict[str, Any]]]
"""Handler for inbound requests: `(ctx, method, params) -> result`. Raise `MCPError` to send an error response."""
OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]]
"""Handler for inbound notifications: `(ctx, method, params)`."""
DispatchMiddleware = Callable[[OnRequest], OnRequest]
"""Wraps an `OnRequest` to produce another `OnRequest`. Applied outermost-first."""
class Dispatcher(Outbound, Protocol[TransportT_co]):
"""A duplex request/notification channel with call-return semantics.
Implementations own correlation of outbound requests to inbound results, the
receive loop, per-request concurrency, and cancellation/progress wiring.
"""
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Drive the receive loop until the underlying channel closes.
Each inbound request is dispatched to `on_request` in its own task;
the returned dict (or raised `MCPError`) is sent back as the response.
Inbound notifications go to `on_notify`.
`task_status.started()` is called once the dispatcher is ready to
accept `send_request`/`notify` calls, so callers can use
`await tg.start(dispatcher.run, on_request, on_notify)`.
"""
...

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any, cast
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError
from mcp.types import INVALID_REQUEST, URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError
class MCPError(Exception):
@ -41,6 +41,25 @@ class MCPError(Exception):
return self.message
class NoBackChannelError(MCPError):
"""Raised when sending a server-initiated request over a transport that cannot deliver it.
Stateless HTTP and JSON-response-mode HTTP have no channel for the server to
push requests (sampling, elicitation, roots/list) to the client. This is
raised by `DispatchContext.send_raw_request` when `can_send_request` is
`False`, and serializes to an `INVALID_REQUEST` error response.
"""
def __init__(self, method: str):
super().__init__(
code=INVALID_REQUEST,
message=(
f"Cannot send {method!r}: this transport context has no back-channel for server-initiated requests."
),
)
self.method = method
class StatelessModeNotSupported(RuntimeError):
"""Raised when attempting to use a method that is not supported in stateless mode.

View file

@ -0,0 +1,742 @@
"""JSON-RPC `Dispatcher` implementation.
Consumes the existing `SessionMessage`-based stream contract that all current
transports (stdio, SSE, streamable HTTP) speak. Owns request-id correlation,
the receive loop, per-request task isolation, cancellation/progress wiring, and
the single exception-to-wire boundary.
The MCP type layer (`ServerRunner`, `Context`, `Client`) sits above this and
sees only `(ctx, method, params) -> dict`. Transports sit below and see only
`SessionMessage` reads/writes.
The dispatcher is *mostly* MCP-agnostic - methods/params are opaque strings and
dicts - but it intercepts `notifications/cancelled` and
`notifications/progress` because request correlation, cancellation and
progress are exactly the wiring this layer exists to provide. Those few wire
shapes are extracted with structural `match` patterns (no casts, no
`mcp.types` model coupling); a malformed payload simply fails to match and
the correlation is skipped.
"""
from __future__ import annotations
import contextvars
import logging
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Generic, Literal, TypeVar, cast, overload
import anyio
import anyio.abc
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from opentelemetry.trace import SpanKind
from pydantic import ValidationError
from mcp.shared._otel import inject_trace_context, otel_span
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.dispatcher import CallOptions, Dispatcher, OnNotify, OnRequest, ProgressFnT
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.message import (
ClientMessageMetadata,
MessageMetadata,
ServerMessageMetadata,
SessionMessage,
)
from mcp.shared.transport_context import TransportContext
from mcp.types import (
CONNECTION_CLOSED,
INTERNAL_ERROR,
INVALID_PARAMS,
REQUEST_CANCELLED,
REQUEST_TIMEOUT,
ErrorData,
JSONRPCError,
JSONRPCMessage,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ProgressToken,
RequestId,
)
__all__ = ["JSONRPCDispatcher"]
logger = logging.getLogger(__name__)
TransportT = TypeVar("TransportT", bound=TransportContext)
PeerCancelMode = Literal["interrupt", "signal"]
"""How inbound `notifications/cancelled` is applied to a running handler.
`"interrupt"` (default) cancels the handler's scope. `"signal"` only sets
`ctx.cancel_requested` and lets the handler observe it cooperatively.
"""
def _coerce_id(request_id: RequestId) -> RequestId:
"""Coerce a string request ID to int when it's a valid int literal.
`_allocate_id` only ever produces `int` keys for `_pending`, but a peer
may echo the ID back as a JSON string. The TypeScript SDK and `BaseSession`
both perform this coercion at lookup time so the response still correlates.
"""
if isinstance(request_id, str):
try:
return int(request_id)
except ValueError:
pass
return request_id
@dataclass(slots=True)
class _Pending:
"""An outbound request awaiting its response."""
send: MemoryObjectSendStream[dict[str, Any] | ErrorData]
receive: MemoryObjectReceiveStream[dict[str, Any] | ErrorData]
on_progress: ProgressFnT | None = None
@dataclass(slots=True)
class _InFlight(Generic[TransportT]):
"""An inbound request currently being handled."""
scope: anyio.CancelScope
dctx: _JSONRPCDispatchContext[TransportT]
@dataclass
class _JSONRPCDispatchContext(Generic[TransportT]):
"""Concrete `DispatchContext` produced for each inbound JSON-RPC message."""
transport: TransportT
_dispatcher: JSONRPCDispatcher[TransportT]
_request_id: RequestId | None
message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework
"""The transport-attached `SessionMessage.metadata` for this inbound message.
Carries `ServerMessageMetadata` (HTTP request, SSE stream-close callbacks)
that the server lifts onto its request context. `None` for transports
that attach nothing.
"""
_progress_token: ProgressToken | None = None
_closed: bool = False
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
@property
def request_id(self) -> RequestId | None:
return self._request_id
@property
def can_send_request(self) -> bool:
return self.transport.can_send_request and not self._closed
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
if self._closed:
logger.debug("dropped %s: dispatch context closed", method)
return
await self._dispatcher.notify(method, params, _related_request_id=self._request_id)
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
if not self.can_send_request:
raise NoBackChannelError(method)
return await self._dispatcher.send_raw_request(method, params, opts, _related_request_id=self._request_id)
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
if self._progress_token is None:
return
params: dict[str, Any] = {"progressToken": self._progress_token, "progress": progress}
if total is not None:
params["total"] = total
if message is not None:
params["message"] = message
await self.notify("notifications/progress", params)
def close(self) -> None:
self._closed = True
def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
return TransportContext(kind="jsonrpc", can_send_request=True)
def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:
"""Wrap a user progress callback so it can't crash the dispatcher.
The callback runs as a bare task in the dispatcher's task group; an
uncaught exception would cancel every sibling (the read loop and all
in-flight requests). Swallow and log instead, matching the previous
receive-loop's behavior.
"""
async def _wrapped(progress: float, total: float | None, message: str | None) -> None:
try:
await fn(progress, total, message)
except Exception:
logger.exception("progress callback raised")
return _wrapped
def _outbound_metadata(related_request_id: RequestId | None, opts: CallOptions | None) -> MessageMetadata:
"""Choose the `SessionMessage.metadata` for an outgoing request/notification.
`ServerMessageMetadata` tags a server-to-client message with the inbound
request it belongs to (so streamable-HTTP can route it onto that request's
SSE stream). `ClientMessageMetadata` carries resumption hints to the
client transport. `None` is the common case.
`SessionMessage.metadata` carries exactly one of these, so when
`related_request_id` is set it takes precedence and any resumption hints
in `opts` are dropped (with a debug log): requests made from a dispatch
context are routed onto the inbound request's stream, not resumed.
"""
if related_request_id is not None:
if opts and (opts.get("resumption_token") is not None or opts.get("on_resumption_token") is not None):
logger.debug(
"dropping resumption hints: related_request_id %r takes precedence on metadata", related_request_id
)
return ServerMessageMetadata(related_request_id=related_request_id)
if opts:
token = opts.get("resumption_token")
on_token = opts.get("on_resumption_token")
if token is not None or on_token is not None:
return ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token)
return None
class JSONRPCDispatcher(Dispatcher[TransportT]):
"""`Dispatcher` over the existing `SessionMessage` stream contract.
Inherits the `Dispatcher` Protocol explicitly so pyright checks
conformance at the class definition rather than at first use.
"""
@overload
def __init__(
self: JSONRPCDispatcher[TransportContext],
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
*,
peer_cancel_mode: PeerCancelMode = "interrupt",
raise_handler_exceptions: bool = False,
inline_methods: frozenset[str] = frozenset(),
) -> None: ...
@overload
def __init__(
self,
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
*,
transport_builder: Callable[[MessageMetadata], TransportT],
peer_cancel_mode: PeerCancelMode = "interrupt",
raise_handler_exceptions: bool = False,
inline_methods: frozenset[str] = frozenset(),
) -> None: ...
def __init__(
self,
read_stream: ReadStream[SessionMessage | Exception],
write_stream: WriteStream[SessionMessage],
*,
transport_builder: Callable[[MessageMetadata], TransportT] | None = None,
peer_cancel_mode: PeerCancelMode = "interrupt",
raise_handler_exceptions: bool = False,
inline_methods: frozenset[str] = frozenset(),
) -> None:
self._read_stream = read_stream
self._write_stream = write_stream
# The overloads guarantee that when `transport_builder` is omitted,
# `TransportT` is `TransportContext`, so the default is type-correct;
# pyright can't see across overloads, hence the cast.
self._transport_builder = cast(
"Callable[[MessageMetadata], TransportT]",
transport_builder or _default_transport_builder,
)
self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode
self._raise_handler_exceptions = raise_handler_exceptions
# Request methods handled inline in the read loop (awaited before the
# next message is dequeued) instead of spawned concurrently. Use for
# methods whose side effects must be observable to the next message,
# e.g. `initialize`, so a pipelined follow-up sees the initialized state.
# Only suitable for handlers that complete quickly, since inline handling
# blocks dequeuing; a handler that awaits the peer (`send_raw_request`)
# while inline will deadlock because the parked read loop cannot dequeue
# the response.
self._inline_methods = inline_methods
self._next_id = 0
self._pending: dict[RequestId, _Pending] = {}
self._in_flight: dict[RequestId, _InFlight[TransportT]] = {}
self._tg: anyio.abc.TaskGroup | None = None
self._running = False
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]:
"""Send a JSON-RPC request and await its response.
`_related_request_id` is set only by `_JSONRPCDispatchContext` when a
handler makes a server-to-client request mid-flight; it routes the
outgoing message onto the correct per-request SSE stream (SHTTP) via
`ServerMessageMetadata`. Top-level callers leave it `None`.
Raises:
MCPError: The peer responded with a JSON-RPC error; or
`REQUEST_TIMEOUT` if `opts["timeout"]` elapsed; or
`CONNECTION_CLOSED` if the dispatcher shut down while
awaiting the response.
RuntimeError: Called before `run()` has started or after it has
finished.
"""
if not self._running:
raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run() / after close")
opts = opts or {}
request_id = self._allocate_id()
out_params = dict(params) if params is not None else {}
out_meta = dict(out_params.get("_meta") or {})
on_progress = opts.get("on_progress")
if on_progress is not None:
# The caller wants progress updates. The spec mechanism is: include
# `_meta.progressToken` on the request; the peer echoes that token on
# any `notifications/progress` it sends. We use the request id as the
# token so the receive loop can find this `_Pending.on_progress` by
# `_pending[token]` without a second lookup table.
out_meta["progressToken"] = request_id
out_params["_meta"] = out_meta
# buffer=1: at most one outcome is ever delivered. A `WouldBlock` from
# `_resolve_pending`/`_fan_out_closed` means the waiter already has an
# outcome and dropping the late/redundant signal is correct. buffer=0
# is unsafe - there's a window between registering `_pending[id]` and
# parking in `receive()` where a close signal would be lost.
send, receive = anyio.create_memory_object_stream[dict[str, Any] | ErrorData](1)
pending = _Pending(send=send, receive=receive, on_progress=on_progress)
self._pending[request_id] = pending
metadata = _outbound_metadata(_related_request_id, opts)
target = out_params.get("name")
span_name = f"MCP send {method}{f' {target}' if isinstance(target, str) else ''}"
# TODO(maxisbey): the otel span + inject below mirror
# BaseSession.send_request for parity. They belong in an outbound
# middleware (symmetric with otel_middleware on the inbound side) once
# that seam exists; the dispatcher should not own otel.
try:
with otel_span(
span_name,
kind=SpanKind.CLIENT,
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
):
# Inject W3C trace context into _meta (SEP-414). With a no-op
# tracer this writes nothing, but `_meta` itself is still
# present on the wire (and the interaction suite pins that).
inject_trace_context(out_meta)
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
await self._write(msg, metadata)
with anyio.fail_after(opts.get("timeout")):
outcome = await receive.receive()
except TimeoutError:
# Spec-recommended courtesy: tell the peer we've given up so it can
# stop work and free resources. v1's BaseSession.send_request does
# NOT do this; it's new behaviour.
await self._cancel_outbound(request_id, f"timed out after {opts.get('timeout')}s", _related_request_id)
raise MCPError(code=REQUEST_TIMEOUT, message=f"Request {method!r} timed out") from None
except anyio.get_cancelled_exc_class():
# Our caller's scope was cancelled. We're already inside a cancelled
# scope, so any bare `await` here re-raises immediately - shield to
# let the courtesy cancel notification go out before we propagate.
with anyio.CancelScope(shield=True):
await self._cancel_outbound(request_id, "caller cancelled", _related_request_id)
raise
finally:
# Always remove the waiter, even on cancel/timeout, so a late
# response from the peer (race) hits a closed stream and is dropped
# in `_dispatch` rather than leaking.
self._pending.pop(request_id, None)
send.close()
receive.close()
if isinstance(outcome, ErrorData):
raise MCPError(code=outcome.code, message=outcome.message, data=outcome.data)
return outcome
async def notify(
self,
method: str,
params: Mapping[str, Any] | None,
*,
_related_request_id: RequestId | None = None,
) -> None:
# Leave `params` unset (not explicitly None) when there are none:
# transports serialize with `exclude_unset=True`, and an explicit None
# would survive as `"params": null`, which JSON-RPC 2.0 forbids and
# strict peers (e.g. the TypeScript SDK's zod schemas) reject.
if params is not None:
msg = JSONRPCNotification(jsonrpc="2.0", method=method, params=dict(params))
else:
msg = JSONRPCNotification(jsonrpc="2.0", method=method)
await self._write(msg, _outbound_metadata(_related_request_id, None))
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Drive the receive loop until the read stream closes.
Each inbound request is handled in its own task in an internal task
group; `task_status.started()` fires once that group is open, so
`await tg.start(dispatcher.run, ...)` resumes when `send_raw_request`
is usable.
"""
try:
async with anyio.create_task_group() as tg:
self._tg = tg
self._running = True
task_status.started()
try:
async with self._read_stream, self._write_stream:
try:
async for item in self._read_stream:
# Duck-typed: `_context_streams.ContextReceiveStream`
# exposes `.last_context` (the sender's contextvars
# snapshot per message). Plain memory streams don't.
sender_ctx: contextvars.Context | None = getattr(
self._read_stream, "last_context", None
)
await self._dispatch(item, on_request, on_notify, sender_ctx)
except anyio.ClosedResourceError:
# The transport closed our receive end and we looped
# back to `__anext__` on the now-closed stream
# (stateless SHTTP teardown). Same as EOF.
logger.debug("read stream closed by transport; treating as EOF")
# Read stream EOF: wake any blocked `send_raw_request` waiters
# (callers outside this task group) with CONNECTION_CLOSED.
self._running = False
self._fan_out_closed()
finally:
# Transport closed: cancel in-flight handlers. Without this
# the task-group join waits for them, and a handler that
# outlives its caller (its request timed out client-side, or
# the client disconnected mid-call) would keep `run()` from
# returning forever. Same behaviour as `Server.run()` before
# the dispatcher rework.
tg.cancel_scope.cancel()
finally:
# Covers the cancel/crash paths where the inline fan-out above is
# never reached. Idempotent.
self._running = False
self._tg = None
self._fan_out_closed()
async def _dispatch(
self,
item: SessionMessage | Exception,
on_request: OnRequest,
on_notify: OnNotify,
sender_ctx: contextvars.Context | None,
) -> None:
"""Route one inbound item.
Everything here is `send_nowait` or `_spawn`; the only `await` is for
`inline_methods` requests, which deliberately block dequeuing until
handled. Any other `await` would let one slow message head-of-line
block the entire read loop.
"""
if isinstance(item, Exception):
logger.debug("transport yielded exception: %r", item)
return
metadata = item.metadata
msg = item.message
match msg:
case JSONRPCRequest():
await self._dispatch_request(msg, metadata, on_request, sender_ctx)
case JSONRPCNotification():
self._dispatch_notification(msg, metadata, on_notify, sender_ctx)
case JSONRPCResponse():
self._resolve_pending(msg.id, msg.result)
case JSONRPCError(): # pragma: no branch
# `id` may be None per JSON-RPC (parse error before id known).
# The match is exhaustive over JSONRPCMessage; the no-match arc
# on this final case is unreachable.
self._resolve_pending(msg.id, msg.error)
async def _dispatch_request(
self,
req: JSONRPCRequest,
metadata: MessageMetadata,
on_request: OnRequest,
sender_ctx: contextvars.Context | None,
) -> None:
progress_token: ProgressToken | None
match req.params:
# The bool guard matters: `int()` patterns match bool (a subclass),
# and `True == 1` would alias dict lookups to request id 1.
case {"_meta": {"progressToken": str() | int() as progress_token}} if not isinstance(progress_token, bool):
pass
case _:
progress_token = None
try:
transport_ctx = self._transport_builder(metadata)
except Exception:
# Containment boundary for the user-supplied builder: a raising
# builder must cost only this message, not the whole connection
# (the exception would otherwise escape into run()'s read loop).
logger.exception("transport_builder raised; rejecting request %r", req.id)
self._spawn(
self._write_error,
req.id,
ErrorData(code=INTERNAL_ERROR, message="transport context unavailable"),
sender_ctx=sender_ctx,
)
return
dctx = _JSONRPCDispatchContext(
transport=transport_ctx,
_dispatcher=self,
_request_id=req.id,
message_metadata=metadata,
_progress_token=progress_token,
)
scope = anyio.CancelScope()
# TODO(maxisbey): the spec puts request-id uniqueness on the sender;
# neither v1 nor the TS SDK guards a duplicate id here, so for now we
# blind-overwrite (parity). Revisit rejecting with INVALID_REQUEST.
# Coerced key so `notifications/cancelled` correlates regardless of
# whether the peer stringifies the id between request and cancel
# (`_dispatch_notification` coerces at lookup; responses still echo
# `req.id` verbatim).
self._in_flight[_coerce_id(req.id)] = _InFlight(scope=scope, dctx=dctx)
if req.method in self._inline_methods:
# Spawn (so `sender_ctx` applies, matching the concurrent path) but
# park the read loop until the handler returns; that's the inline
# ordering guarantee. Because the read loop is parked, a handler
# that awaits the peer here (e.g. `dctx.send_raw_request`) will
# deadlock: the response can never be dequeued.
done = anyio.Event()
async def _run_inline() -> None:
try:
await self._handle_request(req, dctx, scope, on_request)
finally:
done.set()
self._spawn(_run_inline, sender_ctx=sender_ctx)
await done.wait()
else:
self._spawn(self._handle_request, req, dctx, scope, on_request, sender_ctx=sender_ctx)
def _dispatch_notification(
self,
msg: JSONRPCNotification,
metadata: MessageMetadata,
on_notify: OnNotify,
sender_ctx: contextvars.Context | None,
) -> None:
"""Route one inbound notification.
`notifications/cancelled` and `notifications/progress` are intercepted
here because they correlate against JSON-RPC request IDs - the
`_in_flight` / `_pending` tables this layer owns - so no higher layer
can act on them. Both are still teed to `on_notify` afterwards, so
middleware and registered notification handlers observe every inbound
notification. See the module docstring for the design rationale.
"""
if msg.method == "notifications/cancelled":
match msg.params:
# The bool guards here and below matter: `int()` patterns match
# bool (a subclass), and `True == 1` would alias the dict lookup
# to the entry keyed by request id 1.
case {"requestId": str() | int() as rid} if (
not isinstance(rid, bool) and (in_flight := self._in_flight.get(_coerce_id(rid))) is not None
):
in_flight.dctx.cancel_requested.set()
if self._peer_cancel_mode == "interrupt":
in_flight.scope.cancel()
case _:
pass
# fall through: cancelled is also teed to on_notify so middleware
# and registered handlers can observe it (matches DirectDispatcher,
# which forwards every notification).
elif msg.method == "notifications/progress":
match msg.params:
case {"progressToken": str() | int() as token, "progress": int() | float() as progress} if (
not isinstance(token, bool)
and not isinstance(progress, bool)
and (pending := self._pending.get(_coerce_id(token))) is not None
and pending.on_progress is not None
):
total = msg.params.get("total")
message = msg.params.get("message")
self._spawn(
_shielded_progress(pending.on_progress),
float(progress),
float(total) if isinstance(total, int | float) else None,
message if isinstance(message, str) else None,
sender_ctx=sender_ctx,
)
case _:
pass
# fall through: progress is also teed to on_notify
try:
transport_ctx = self._transport_builder(metadata)
except Exception:
# Same containment boundary as `_dispatch_request`: a raising
# builder drops this notification instead of killing the read loop.
logger.exception("transport_builder raised; dropping notification %r", msg.method)
return
dctx = _JSONRPCDispatchContext(
transport=transport_ctx, _dispatcher=self, _request_id=None, message_metadata=metadata
)
self._spawn(on_notify, dctx, msg.method, msg.params, sender_ctx=sender_ctx)
def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None:
pending = self._pending.get(_coerce_id(request_id)) if request_id is not None else None
if pending is None:
logger.debug("dropping response for unknown/late request id %r", request_id)
return
try:
pending.send.send_nowait(outcome)
except (anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("waiter for request id %r already gone", request_id)
def _spawn(
self,
fn: Callable[..., Awaitable[Any]],
*args: object,
sender_ctx: contextvars.Context | None,
) -> None:
"""Schedule `fn(*args)` in the run() task group, propagating the sender's contextvars.
ASGI middleware (auth, OTel) sets contextvars on the request task that
wrote into the read stream. `Context.run(tg.start_soon, ...)` makes
the spawned handler inherit *that* context instead of the receive
loop's, so `auth_context_var` and OTel spans survive.
"""
assert self._tg is not None
if sender_ctx is not None:
sender_ctx.run(self._tg.start_soon, fn, *args)
else:
self._tg.start_soon(fn, *args)
def _fan_out_closed(self) -> None:
"""Wake every pending `send_raw_request` waiter with `CONNECTION_CLOSED`.
Synchronous (uses `send_nowait`) because it's called from `finally`
which may be inside a cancelled scope. Idempotent.
"""
closed = ErrorData(code=CONNECTION_CLOSED, message="connection closed")
for pending in self._pending.values():
try:
pending.send.send_nowait(closed)
except (anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError):
pass
self._pending.clear()
async def _handle_request(
self,
req: JSONRPCRequest,
dctx: _JSONRPCDispatchContext[TransportT],
scope: anyio.CancelScope,
on_request: OnRequest,
) -> None:
"""Run `on_request` for one inbound request and write its response.
This is the single exception-to-wire boundary: handler exceptions are
caught here and serialized to `JSONRPCError`. Nothing above this in
the stack constructs wire errors.
"""
try:
with scope:
try:
result = await on_request(dctx, req.method, req.params)
finally:
# Handler done: close the back-channel (detached work that
# later calls `dctx.send_raw_request()` should see
# `NoBackChannelError`) and drop from `_in_flight` so a
# late `notifications/cancelled` is a no-op rather than
# racing the result write below. No checkpoint between
# handler return and the pop, so the cancel can't
# interleave there.
dctx.close()
self._in_flight.pop(_coerce_id(req.id), None)
await self._write_result(req.id, result)
if scope.cancel_called:
# Peer-cancel: `_dispatch_notification` cancelled this scope
# while the handler was running. anyio swallows a scope's *own*
# cancel at __exit__, so execution lands here rather than the
# `except cancelled` arm below.
# TODO(maxisbey): spec says SHOULD NOT respond after cancel.
# The existing server always has, so match that for now.
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
except anyio.get_cancelled_exc_class():
# Outer-cancel: run()'s task group is shutting down. Any bare
# `await` here re-raises immediately, so shield the courtesy write.
with anyio.CancelScope(shield=True):
await self._write_error(req.id, ErrorData(code=REQUEST_CANCELLED, message="Request cancelled"))
raise
except MCPError as e:
await self._write_error(req.id, e.error)
except ValidationError:
# TODO(maxisbey): data="" is pinned compat with the existing
# server (which never leaked pydantic error text onto the wire).
# Consider putting the validation detail in `data` once the
# interaction suite's divergence entry is resolved.
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 is pinned compat with the existing
# server's `_handle_request`. JSON-RPC says INTERNAL_ERROR
# (-32603); revisit once the suite's divergence entry is resolved.
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
if self._raise_handler_exceptions:
raise
# No outer `_in_flight` pop here: the inner `finally` above already
# removes the entry on every path out of the handler, and a second
# pop after the awaited response writes could evict a newer request
# that reused the id during that window.
def _allocate_id(self) -> int:
self._next_id += 1
return self._next_id
async def _write(self, message: JSONRPCMessage, metadata: MessageMetadata = None) -> None:
await self._write_stream.send(SessionMessage(message=message, metadata=metadata))
async def _write_result(self, request_id: RequestId, result: dict[str, Any]) -> None:
try:
await self._write(JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result))
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped result for %r: write stream closed", request_id)
async def _write_error(self, request_id: RequestId, error: ErrorData) -> None:
try:
await self._write(JSONRPCError(jsonrpc="2.0", id=request_id, error=error))
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped error for %r: write stream closed", request_id)
async def _cancel_outbound(self, request_id: RequestId, reason: str, related_request_id: RequestId | None) -> None:
# Thread `related_request_id` so streamable-HTTP routes the cancel onto
# the same per-request SSE stream as the request it cancels; without it
# the notification falls through to the standalone GET stream and is
# dropped when no GET stream is open.
try:
await self.notify(
"notifications/cancelled",
{"requestId": request_id, "reason": reason},
_related_request_id=related_request_id,
)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
pass

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared.message import SessionMessage
@ -28,3 +29,5 @@ async def create_client_server_memory_streams() -> AsyncGenerator[tuple[MessageS
async with server_to_client_receive, client_to_server_send, client_to_server_receive, server_to_client_send:
yield client_streams, server_streams
# Heals caller-driven cancels; closing memory streams never suspends.
await resync_tracer()

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