[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
54 changed files with 6594 additions and 1701 deletions

View file

@ -5,7 +5,6 @@ on:
branches: ["main", "v1.x"]
tags: ["v*.*.*"]
pull_request:
branches: ["main", "v1.x"]
permissions:
contents: read

View file

@ -472,7 +472,7 @@ async def my_tool(x: int, ctx: Context) -> str:
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
### Registering lowlevel handlers on `MCPServer` (workaround)
### Registering lowlevel handlers from `MCPServer`
`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods:
@ -486,7 +486,7 @@ async def handle_set_logging_level(level: str) -> None:
mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage]
```
In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `_add_request_handler`:
In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, access it via `_lowlevel_server`:
**After (v2):**
@ -505,11 +505,11 @@ async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestPa
return EmptyResult()
mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage]
```
This is a private API and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly.
`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly.
### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed
@ -664,6 +664,8 @@ ctx: ClientRequestContext
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
```
`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`), so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`.
The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient:
**Before (v1):**
@ -857,6 +859,54 @@ server = Server("my-server", on_list_tools=handle_list_tools)
If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API.
### Lowlevel `Server`: `add_request_handler` is now public and takes `params_type`
The private `_add_request_handler(method, handler)` escape hatch is now the public `add_request_handler(method, params_type, handler)`, alongside a matching `add_notification_handler`. Each takes a `params_type` model that incoming params are validated against before the handler runs. A message with no `params` member validates `{}` against the model, so handlers never receive `None`: all-optional models arrive with their defaults, and models with required fields reject the message as `INVALID_PARAMS` before the handler runs (matching the Go SDK).
```python
# Before (v1 / earlier v2 prereleases)
server._add_request_handler("custom/method", my_handler)
# After (v2)
server.add_request_handler("custom/method", MyParams, my_handler)
server.add_notification_handler("notifications/custom", MyNotifyParams, my_notify_handler)
```
### Lowlevel `Server`: private `_handle_*` dispatch methods removed
`Server._handle_message`, `_handle_request`, and `_handle_notification` have been removed. The receive loop and per-message dispatch now live in `JSONRPCDispatcher` and `ServerRunner`, which `Server.run()` drives internally.
These were private, but some users subclassed `Server` and overrode them to intercept requests. Use middleware instead:
```python
from collections.abc import Mapping
from typing import Any
from mcp.server import Server, ServerRequestContext
from mcp.server.context import CallNext, HandlerResult
async def logging_middleware(
ctx: ServerRequestContext[Any, Any], method: str, params: Mapping[str, Any] | None, call_next: CallNext
) -> HandlerResult:
print(f"handling {method}")
result = await call_next()
print(f"done {method}")
return result
server = Server("my-server", on_call_tool=...)
server.middleware.append(logging_middleware)
```
Middleware runs before params validation, so `params` is the raw inbound mapping (or `None`), and it also wraps unknown methods.
### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised
`raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag.
Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens.
### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params
The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor.
@ -1083,6 +1133,39 @@ from mcp.server import ServerRequestContext
# but None in notification handlers
```
### `ServerSession` is now a thin proxy (no longer a `BaseSession`)
`ServerSession` no longer subclasses `BaseSession`. It is now a small connection-scoped proxy that exposes `send_request`, `send_notification`, the typed convenience helpers (`create_message`, `elicit_form`, `send_log_message`, `send_tool_list_changed`, ...), `client_params`, and `check_client_capability`. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.
`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so most servers are unaffected. If you were constructing or subclassing it directly:
**Constructor change:**
```python
# Before (v1)
session = ServerSession(read_stream, write_stream, init_options, stateless=False)
# After (v2)
session = ServerSession(dispatcher, connection, stateless=False)
# where `dispatcher` is a JSONRPCDispatcher and `connection` is a Connection
```
In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up.
**Removed from `mcp.server.session`:**
- `InitializationState` enum and `ServerSession._initialization_state` — initialization tracking is now on `Connection` (`connection.initialized` is an `anyio.Event`, `connection.client_params` holds the init params).
- `ServerRequestResponder` type alias.
- `ServerSession.incoming_messages` stream — there is no longer a public stream of inbound messages to iterate. Register handlers via the `on_*` constructor params (or `add_request_handler`) and use `Server.middleware` to observe every inbound request and notification (`initialize`, unknown methods, validation failures, and `notifications/initialized` included).
- `ServerSession.__aenter__` / `__aexit__` — `ServerSession` is no longer an async context manager.
- The private `_receive_loop`, `_received_request`, `_received_notification`, and `_handle_incoming` overrides — there is nothing to override on `ServerSession` anymore. To intercept inbound messages, use `Server.middleware` (see the `_handle_*` removal section above).
### `BaseSession` / `RequestResponder`: server-side cancellation tracking removed
`BaseSession._in_flight` and the `RequestResponder` members that supported it (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument, and the internal `CancelScope`) have been removed. These existed to let `ServerSession` cancel a handler when a `CancelledNotification` arrived; `ServerSession` no longer drives a receive loop, so they were dead code. Inbound-cancellation handling for the server now lives in `JSONRPCDispatcher`.
`BaseSession` is still used by `ClientSession`, which never relied on these members. `RequestResponder.respond()` is unchanged.
### Experimental Tasks support removed
Tasks (SEP-1686) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with all `Task*` types, the `tasks` capability fields, `Tool.execution`, and the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`.

View file

@ -417,9 +417,15 @@ async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeReque
return EmptyResult()
mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
"logging/setLevel", SetLevelRequestParams, handle_set_logging_level
)
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
"resources/subscribe", SubscribeRequestParams, handle_subscribe
)
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
"resources/unsubscribe", UnsubscribeRequestParams, handle_unsubscribe
)
@mcp.completion()

View file

@ -25,7 +25,11 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"anyio>=4.9",
# anyio < 4.10 triggers a compile-time SyntaxWarning on Python 3.14 (PEP 765,
# "'return' in a 'finally' block"); for stdio servers it lands on the child's
# stderr (agronholm/anyio#816, fixed in 4.10).
"anyio>=4.10; python_version >= '3.14'",
"anyio>=4.9; python_version < '3.14'",
"httpx>=0.27.1,<1.0.0",
"httpx-sse>=0.4",
"pydantic>=2.12.0",
@ -90,6 +94,7 @@ dev = [
"pillow>=12.0",
"strict-no-cover",
"logfire>=3.0.0",
"opentelemetry-sdk>=1.39.1",
]
docs = [
"mkdocs>=1.6.1",

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

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