873 lines
39 KiB
Python
873 lines
39 KiB
Python
"""`ServerRunner` - the per-connection handler kernel.
|
|
|
|
`ServerRunner` bridges the dispatch layer (`on_request` / `on_notify`, untyped
|
|
dicts) and the user's handler layer (typed `Context`, typed params). It is a
|
|
pure kernel: it holds a pre-populated `Connection` and reads
|
|
`connection.protocol_version` / `connection.outbound` as facts. Driving a
|
|
dispatcher loop and tearing down the connection live in the free-function
|
|
drivers (`serve_connection`, `serve_loop`, `serve_dual_era_loop`, `serve_one`);
|
|
the entry constructs the `Connection`, the driver tears it down.
|
|
|
|
`ServerRunner` holds a `Server` directly - `Server` is the registry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import logging
|
|
from collections.abc import AsyncIterator, Awaitable, Mapping
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import KW_ONLY, dataclass, replace
|
|
from functools import cached_property, partial
|
|
from typing import TYPE_CHECKING, Any, Generic, cast
|
|
|
|
import anyio
|
|
import anyio.abc
|
|
from mcp_types import (
|
|
CLIENT_CAPABILITIES_META_KEY,
|
|
CLIENT_INFO_META_KEY,
|
|
CORE_RESULT_TYPES,
|
|
INTERNAL_ERROR,
|
|
INVALID_PARAMS,
|
|
INVALID_REQUEST,
|
|
METHOD_NOT_FOUND,
|
|
PROTOCOL_VERSION_META_KEY,
|
|
SERVER_INFO_META_KEY,
|
|
UNSUPPORTED_PROTOCOL_VERSION,
|
|
CacheableResult,
|
|
ErrorData,
|
|
Implementation,
|
|
InitializeRequestParams,
|
|
InitializeResult,
|
|
JSONRPCRequest,
|
|
RequestId,
|
|
RequestParams,
|
|
RequestParamsMeta,
|
|
UnsupportedProtocolVersionErrorData,
|
|
)
|
|
from mcp_types import methods as _methods
|
|
from mcp_types.version import (
|
|
HANDSHAKE_PROTOCOL_VERSIONS,
|
|
LATEST_HANDSHAKE_VERSION,
|
|
LATEST_MODERN_VERSION,
|
|
MODERN_PROTOCOL_VERSIONS,
|
|
)
|
|
from pydantic import BaseModel, ValidationError
|
|
from typing_extensions import TypeVar
|
|
|
|
from mcp.server.caching import apply_cache_hint
|
|
from mcp.server.connection import Connection, NotifyOnlyOutbound
|
|
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
|
|
from mcp.server.models import InitializationOptions
|
|
from mcp.server.session import ServerSession
|
|
from mcp.shared._context_streams import ContextReceiveStream
|
|
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
|
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
|
|
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
|
from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request
|
|
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
|
|
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
|
|
from mcp.shared.transport_context import TransportContext
|
|
|
|
if TYPE_CHECKING:
|
|
from mcp.server.lowlevel.server import Server
|
|
|
|
__all__ = [
|
|
"CallNext",
|
|
"ServerMiddleware",
|
|
"ServerRunner",
|
|
"aclose_shielded",
|
|
"modern_on_request",
|
|
"serve_connection",
|
|
"serve_dual_era_loop",
|
|
"serve_loop",
|
|
"serve_one",
|
|
]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LifespanT = TypeVar("LifespanT", default=Any)
|
|
|
|
|
|
_INIT_EXEMPT: frozenset[str] = frozenset({"ping"})
|
|
|
|
_EXIT_STACK_CLOSE_TIMEOUT: float = 5
|
|
"""Bound for `aclose_shielded`'s exit-stack unwind; a hung cleanup callback
|
|
must not wedge shutdown."""
|
|
|
|
|
|
def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
|
|
"""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
|
|
|
|
|
|
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):
|
|
# Copied so callers own the returned dict: handlers and middleware may
|
|
# retain the object they returned, and the outbound pipeline shapes the
|
|
# wire form without reaching into anything the handler still holds.
|
|
return dict(cast(dict[str, Any], result))
|
|
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
|
|
|
|
|
|
async def aclose_shielded(connection: Connection) -> None:
|
|
"""Unwind ``connection.exit_stack`` under a shielded, bounded scope.
|
|
|
|
Called from a driver's ``finally``: the shield lets per-connection cleanup
|
|
callbacks run even when the driver itself is being cancelled, the
|
|
`_EXIT_STACK_CLOSE_TIMEOUT` bound stops a hung callback wedging shutdown,
|
|
and a raising callback is logged-and-swallowed so it never masks the
|
|
driver's own exception.
|
|
"""
|
|
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
|
|
try:
|
|
await connection.exit_stack.aclose()
|
|
except Exception:
|
|
logger.exception("connection exit_stack cleanup raised")
|
|
if scope.cancelled_caught:
|
|
logger.warning(
|
|
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
|
|
_EXIT_STACK_CLOSE_TIMEOUT,
|
|
)
|
|
|
|
|
|
def _apply_middleware(
|
|
middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any]
|
|
) -> Awaitable[HandlerResult]:
|
|
"""Adapt one middleware to the `CallNext` shape: bind `call_next`, take
|
|
`ctx` at call time so a rewritten context flows down the chain."""
|
|
return middleware(ctx, call_next)
|
|
|
|
|
|
@dataclass
|
|
class ServerRunner(Generic[LifespanT]):
|
|
"""Per-connection handler kernel. One instance per client connection."""
|
|
|
|
server: Server[LifespanT]
|
|
connection: Connection
|
|
lifespan_state: LifespanT
|
|
_: KW_ONLY
|
|
init_options: InitializationOptions | None = None
|
|
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""
|
|
|
|
@cached_property
|
|
def on_request(self) -> OnRequest:
|
|
return self._on_request
|
|
|
|
@cached_property
|
|
def on_notify(self) -> OnNotify:
|
|
return self._on_notify
|
|
|
|
async def _on_request(
|
|
self,
|
|
dctx: DispatchContext[TransportContext],
|
|
method: str,
|
|
params: Mapping[str, Any] | None,
|
|
) -> dict[str, Any]:
|
|
meta = _extract_meta(params)
|
|
version = self.connection.protocol_version
|
|
ctx = self._make_context(dctx, method, params, meta, version)
|
|
|
|
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
|
|
# Read method/params off `ctx` so a middleware that rewrote them via
|
|
# `call_next(replace(ctx, ...))` reaches lookup and the handler.
|
|
method, params = ctx.method, ctx.params
|
|
# Pinned compat: spec methods are surface-validated before lookup,
|
|
# so malformed params are INVALID_PARAMS even with no handler
|
|
# registered. Custom methods miss the monolith map and fall through
|
|
# to `entry.params_type` exactly as before.
|
|
if method in _methods.SPEC_CLIENT_METHODS:
|
|
try:
|
|
_methods.validate_client_request(method, version, params)
|
|
except KeyError:
|
|
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None
|
|
# TODO(L29): the 2026-07-28 spec drops the handshake; this branch and
|
|
# the gate become a per-version legacy path then. Initialize runs inline
|
|
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
|
|
if method == "initialize":
|
|
return self._serialize(method, version, self._handle_initialize(params))
|
|
# Methods without a handler are METHOD_NOT_FOUND regardless of
|
|
# initialization state: JSON-RPC 2.0 reserves -32601 for "not
|
|
# available on this server", and clients probing a server before
|
|
# the handshake key off that code. The init gate below therefore
|
|
# only ever applies to methods the server actually serves.
|
|
entry = self.server.get_request_handler(method)
|
|
if entry is None:
|
|
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method)
|
|
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="")
|
|
# 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)
|
|
# Shape for the wire inside the chain so the OpenTelemetry span (the
|
|
# outermost middleware) records a failing handler return shape too.
|
|
return self._serialize(method, version, result)
|
|
|
|
call = self._compose_server_middleware(_inner)
|
|
# `_inner` already produced the wire dict; a middleware that short-circuited
|
|
# without `call_next` is trusted to return its own well-formed result -
|
|
# including its response envelope. The pipeline never patches it up after
|
|
# the fact.
|
|
result = _dump_result(await call(ctx))
|
|
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.
|
|
# TODO: this re-reads the wire `params`, so a middleware that rewrote
|
|
# `ctx.params` (or `ctx.method`, or short-circuited without `call_next`)
|
|
# can leave `connection.protocol_version` out of step with the
|
|
# `InitializeResult` `_inner` produced. Resolve when `initialize` becomes
|
|
# a built-in handler so commit and result derive from one negotiation.
|
|
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:
|
|
meta = _extract_meta(params)
|
|
version = self.connection.protocol_version
|
|
ctx = self._make_context(dctx, method, params, meta, version)
|
|
|
|
async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> None:
|
|
method, params = ctx.method, ctx.params
|
|
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
|
|
try:
|
|
_methods.validate_client_notification(method, version, params)
|
|
except KeyError:
|
|
logger.debug("dropped %r: not defined at %s", method, version)
|
|
return
|
|
except ValidationError:
|
|
logger.warning("dropped %r: malformed params", method)
|
|
return
|
|
if method == "notifications/initialized":
|
|
# Surface validation above already rejected a malformed body, so
|
|
# commit; fall through so a registered handler observes an
|
|
# initialized connection.
|
|
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(_inner)
|
|
try:
|
|
await call(ctx)
|
|
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, 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. The composed callable takes the `ctx`
|
|
at call time, so a middleware can rewrite it for the rest of the chain.
|
|
"""
|
|
call = inner
|
|
for middleware in reversed(self.server.middleware):
|
|
call = partial(_apply_middleware, middleware, call)
|
|
return call
|
|
|
|
def _make_context(
|
|
self,
|
|
dctx: DispatchContext[TransportContext],
|
|
method: str,
|
|
params: Mapping[str, Any] | None,
|
|
meta: RequestParamsMeta | None,
|
|
protocol_version: str,
|
|
) -> ServerRequestContext[LifespanT, Any]:
|
|
# TODO(L54): remove for Context rework. Reads the SHTTP per-request
|
|
# data off the raw `dctx.message_metadata` carrier; replace with the
|
|
# per-transport context once that lands.
|
|
md = dctx.message_metadata
|
|
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
|
|
# Per-request session: `dctx` is the request-scoped channel (auto-threads
|
|
# its own request_id on streamable HTTP); the standalone channel is read
|
|
# off `connection.outbound`. `related_request_id` on the public API selects.
|
|
# `meta` carries a request's log-level opt-in for the session's log gate. A
|
|
# notification has no request to opt in (and no response stream to carry
|
|
# the log entry), so its `_meta` never opens the gate.
|
|
session = ServerSession(dctx, self.connection, request_meta=meta if dctx.request_id is not None else None)
|
|
return ServerRequestContext(
|
|
session=session,
|
|
lifespan_context=self.lifespan_state,
|
|
method=method,
|
|
params=params,
|
|
request_id=dctx.request_id,
|
|
meta=meta,
|
|
protocol_version=protocol_version,
|
|
request=request,
|
|
close_sse_stream=close_sse_stream,
|
|
close_standalone_sse_stream=close_standalone_sse_stream,
|
|
)
|
|
|
|
def _serialize(self, method: str, version: str, result: HandlerResult) -> dict[str, Any]:
|
|
"""Shape a handler result into its wire form: the outbound counterpart
|
|
of the inbound classification ladder.
|
|
|
|
One pass owns the whole response envelope, in order: cache hints fill
|
|
`ttlMs`/`cacheScope` the handler left unset, core-vocabulary spec-method
|
|
results are validated and sieved by the per-version surface (a claimed
|
|
extension `resultType` shape is the extension's to own), and 2026-era
|
|
results get the `serverInfo` `_meta` stamp (spec #3002). Runs inside the
|
|
middleware chain so the OpenTelemetry span observes a failing return
|
|
shape (unsupported type, malformed spec result) as an error rather
|
|
than closing on a request that the client sees fail - and so a
|
|
middleware that short-circuits without `call_next` owns its result,
|
|
envelope included.
|
|
"""
|
|
# MRTR carve-out: `input_required` interim results, typed or mapping, never get hints.
|
|
if (hint := self.server.cache_hints.get(method)) is not None:
|
|
if isinstance(result, CacheableResult):
|
|
result = apply_cache_hint(result, hint)
|
|
elif isinstance(result, Mapping) and not _methods.is_input_required(result):
|
|
# Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence.
|
|
result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result}
|
|
dumped = _dump_result(result)
|
|
# A modern-era extension `resultType` (outside the core vocabulary) marks
|
|
# a claimed shape owned by the extension that defined it: the per-version
|
|
# surface doesn't describe it, so the sieve applies to core results only.
|
|
# Legacy connections sieve everything - claimed shapes are 2026-era
|
|
# vocabulary and cannot be delivered on a legacy wire (mirrors the
|
|
# client-side ResultClaim rule).
|
|
# TODO(L56): reject extension resultType values unless the corresponding
|
|
# extension is in this request's _meta clientCapabilities.extensions; the
|
|
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
|
|
result_type = dumped.get("resultType")
|
|
core_shape = (
|
|
version not in MODERN_PROTOCOL_VERSIONS
|
|
or not isinstance(result_type, str)
|
|
or result_type in CORE_RESULT_TYPES
|
|
)
|
|
if method in _methods.SPEC_CLIENT_METHODS and core_shape:
|
|
try:
|
|
dumped = _methods.serialize_server_result(method, version, dumped)
|
|
except ValidationError:
|
|
# Server bug, not client fault. Detail stays in the server log:
|
|
# pydantic messages echo the result body.
|
|
logger.exception("handler for %r returned an invalid result", method)
|
|
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
|
|
if version in MODERN_PROTOCOL_VERSIONS and dumped.get("resultType") is None:
|
|
# Spec 2026-07-28: `Result.resultType` is required - servers MUST
|
|
# include it (the absent-means-complete bridge is for clients of
|
|
# older servers only). The sieve guarantees it for core methods;
|
|
# this covers everything else: custom methods, extension methods,
|
|
# and empty results.
|
|
dumped["resultType"] = "complete"
|
|
return self._stamp_server_info(version, dumped)
|
|
|
|
def _stamp_server_info(self, version: str, result: dict[str, Any]) -> dict[str, Any]:
|
|
"""Fill the `serverInfo` `_meta` stamp on a 2026-era result (spec #3002).
|
|
|
|
A handler-authored value wins; an explicit `null` reads as absent and
|
|
is stamped over, mirroring the request-side `clientInfo` posture (a
|
|
`null` is not a valid `Implementation`, so presence means a value). A
|
|
non-mapping `_meta` is the handler's to own, and handshake-era results
|
|
are never stamped. `result` is
|
|
pipeline-owned (`_dump_result` copies dicts; the spec-method sieve
|
|
re-dumps), but `_meta` may still be the handler's object, so the stamp
|
|
replaces it rather than writing into it. `server_info_stamp` is a
|
|
fresh dict per access, so the response never aliases server state.
|
|
"""
|
|
if version not in MODERN_PROTOCOL_VERSIONS:
|
|
return result
|
|
raw_meta = result.get("_meta")
|
|
if raw_meta is None:
|
|
result["_meta"] = {SERVER_INFO_META_KEY: self.server.server_info_stamp}
|
|
elif isinstance(raw_meta, dict):
|
|
meta = cast("dict[str, Any]", raw_meta)
|
|
if meta.get(SERVER_INFO_META_KEY) is None:
|
|
result["_meta"] = {**meta, SERVER_INFO_META_KEY: self.server.server_info_stamp}
|
|
return result
|
|
|
|
@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 HANDSHAKE_PROTOCOL_VERSIONS else LATEST_HANDSHAKE_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)
|
|
opts = self.init_options if self.init_options is not None else self.server.create_initialization_options()
|
|
return InitializeResult(
|
|
protocol_version=negotiated,
|
|
capabilities=opts.capabilities,
|
|
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,
|
|
)
|
|
|
|
|
|
async def serve_connection(
|
|
server: Server[LifespanT],
|
|
dispatcher: Dispatcher[Any],
|
|
*,
|
|
connection: Connection,
|
|
lifespan_state: LifespanT,
|
|
init_options: InitializationOptions | None = None,
|
|
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
|
) -> None:
|
|
"""Drive ``dispatcher`` until the underlying channel closes.
|
|
|
|
The loop-mode driver: builds the kernel, hands `on_request`/`on_notify`
|
|
to `dispatcher.run()`, and tears down `connection.exit_stack` (shielded)
|
|
on the way out. The entry constructs the `Connection`; this only consumes
|
|
it.
|
|
"""
|
|
runner = ServerRunner(server, connection, lifespan_state, init_options=init_options)
|
|
try:
|
|
await dispatcher.run(runner.on_request, runner.on_notify, task_status=task_status)
|
|
finally:
|
|
await aclose_shielded(connection)
|
|
|
|
|
|
async def serve_loop(
|
|
server: Server[LifespanT],
|
|
read_stream: ReadStream[SessionMessage | Exception],
|
|
write_stream: WriteStream[SessionMessage],
|
|
*,
|
|
lifespan_state: LifespanT,
|
|
session_id: str | None = None,
|
|
init_options: InitializationOptions | None = None,
|
|
raise_exceptions: bool = False,
|
|
) -> None:
|
|
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
|
|
|
|
Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
|
|
`serve_connection`. The streamable-HTTP manager (which owns its lifespan
|
|
and serves the modern era on the single-exchange entry instead) calls
|
|
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
|
|
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
|
|
era routing.
|
|
"""
|
|
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
|
read_stream,
|
|
write_stream,
|
|
raise_handler_exceptions=raise_exceptions,
|
|
# Handle `initialize` inline so a client that pipelines it with the
|
|
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
|
|
# state instead of failing the init-gate.
|
|
inline_methods=frozenset({"initialize"}),
|
|
)
|
|
connection = Connection.for_loop(dispatcher, session_id=session_id)
|
|
await serve_connection(
|
|
server, dispatcher, connection=connection, lifespan_state=lifespan_state, init_options=init_options
|
|
)
|
|
|
|
|
|
def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool:
|
|
"""Whether `params._meta` carries the reserved protocol-version key.
|
|
|
|
The `io.modelcontextprotocol/protocolVersion` key exists only in
|
|
2026-07-28+ envelopes and its prefix is spec-reserved, so legacy traffic
|
|
never mints it (a bare `_meta` is not evidence - legacy requests carry
|
|
`progressToken` there). The version key alone is the signal, not the full
|
|
required pair, so a half-built envelope still routes modern and gets the
|
|
classifier's INVALID_PARAMS naming the missing key.
|
|
"""
|
|
if not params:
|
|
return False
|
|
meta = params.get("_meta")
|
|
return isinstance(meta, Mapping) and PROTOCOL_VERSION_META_KEY in meta
|
|
|
|
|
|
def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
"""Error data for an `initialize` arriving on a modern-locked connection.
|
|
|
|
The typed -32022 payload when the client's proposed version is parseable;
|
|
otherwise just the supported list (the point is naming what we serve).
|
|
"""
|
|
requested = (params or {}).get("protocolVersion")
|
|
if isinstance(requested, str):
|
|
return UnsupportedProtocolVersionErrorData(
|
|
supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested
|
|
).model_dump(mode="json")
|
|
return {"supported": list(MODERN_PROTOCOL_VERSIONS)}
|
|
|
|
|
|
def modern_error_data(exc: Exception) -> ErrorData:
|
|
"""Map a modern request's handler exception to its wire `ErrorData`.
|
|
|
|
The exception-to-wire fact shared by the modern entries (the
|
|
single-exchange HTTP path and the dual-era stream loop), so an identical
|
|
modern request fails identically on every transport: `MCPError` and
|
|
`ValidationError` map via the shared `handler_exception_to_error_data`
|
|
ladder; anything else is logged server-side and surfaced as a generic
|
|
INTERNAL_ERROR so handler internals never reach the wire.
|
|
"""
|
|
error = handler_exception_to_error_data(exc)
|
|
if error is not None:
|
|
return error
|
|
logger.exception("modern request handler raised")
|
|
return ErrorData(code=INTERNAL_ERROR, message="Internal server error")
|
|
|
|
|
|
@dataclass
|
|
class _NoServerRequestsDispatchContext:
|
|
"""Delegating `DispatchContext` that refuses server-initiated requests.
|
|
|
|
Wraps the loop dispatcher's per-message context for modern-era dispatch:
|
|
the modern protocol forbids server-initiated JSON-RPC requests, so
|
|
`send_raw_request` refuses while notifications and progress still ride
|
|
the duplex pipe.
|
|
"""
|
|
|
|
_inner: DispatchContext[TransportContext]
|
|
|
|
@property
|
|
def transport(self) -> TransportContext:
|
|
# Mask the per-message flag so the transport metadata agrees with this
|
|
# wrapper's denial: the modern HTTP entry builds its context with
|
|
# can_send_request=False, while the loop's default builder says True.
|
|
transport = self._inner.transport
|
|
return replace(transport, can_send_request=False) if transport.can_send_request else transport
|
|
|
|
@property
|
|
def can_send_request(self) -> bool:
|
|
return False
|
|
|
|
@property
|
|
def request_id(self) -> RequestId | None:
|
|
return self._inner.request_id
|
|
|
|
@property
|
|
def message_metadata(self) -> MessageMetadata:
|
|
return self._inner.message_metadata
|
|
|
|
@property
|
|
def cancel_requested(self) -> anyio.Event:
|
|
return self._inner.cancel_requested
|
|
|
|
async def send_raw_request(
|
|
self,
|
|
method: str,
|
|
params: Mapping[str, Any] | None,
|
|
opts: CallOptions | None = None,
|
|
) -> dict[str, Any]:
|
|
raise NoBackChannelError(method)
|
|
|
|
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
|
await self._inner.notify(method, params, opts)
|
|
|
|
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
|
await self._inner.progress(progress, total, message)
|
|
|
|
|
|
async def serve_dual_era_loop(
|
|
server: Server[LifespanT],
|
|
read_stream: ReadStream[SessionMessage | Exception],
|
|
write_stream: WriteStream[SessionMessage],
|
|
*,
|
|
lifespan_state: LifespanT,
|
|
session_id: str | None = None,
|
|
init_options: InitializationOptions | None = None,
|
|
raise_exceptions: bool = False,
|
|
) -> None:
|
|
"""Drive `server` over a duplex stream pair, in the era the client opens with.
|
|
|
|
The client's first request decides the connection's protocol era, once:
|
|
a request carrying the 2026-07-28 per-request `_meta` envelope opens a
|
|
modern connection, and anything else - the `initialize` handshake, which
|
|
does not exist at 2026 versions even when a client stamps the envelope on
|
|
it - opens a legacy one. The deciding frame is replayed into the chosen
|
|
serving loop along with everything the client sent before it. A later
|
|
claim from the other era is refused: `initialize` on a modern connection
|
|
gets UNSUPPORTED_PROTOCOL_VERSION naming the served versions, and an
|
|
enveloped request on a legacy connection gets INVALID_REQUEST.
|
|
"""
|
|
# This loop owns both streams from the moment it is called, so the write
|
|
# stream is closed even if the client leaves before sending any request.
|
|
try:
|
|
async with _replay_from_opening_request(read_stream) as (opening, replayed):
|
|
opens_modern = (
|
|
opening is not None and opening.method != "initialize" and _has_modern_envelope(opening.params)
|
|
)
|
|
if opens_modern:
|
|
await _serve_modern_stream(
|
|
server, replayed, write_stream, lifespan_state=lifespan_state, raise_exceptions=raise_exceptions
|
|
)
|
|
else:
|
|
await _serve_legacy_stream(
|
|
server,
|
|
replayed,
|
|
write_stream,
|
|
lifespan_state=lifespan_state,
|
|
session_id=session_id,
|
|
init_options=init_options,
|
|
raise_exceptions=raise_exceptions,
|
|
)
|
|
finally:
|
|
await write_stream.aclose()
|
|
|
|
|
|
_PRE_REQUEST_REPLAY_LIMIT: int = 8
|
|
"""How many frames arriving ahead of the client's first request are kept
|
|
for the chosen era's loop (a bare `notifications/initialized` is the one that
|
|
matters); further ones are dropped and never decide the era."""
|
|
|
|
|
|
def _sender_context(stream: ReadStream[Any]) -> contextvars.Context:
|
|
"""The per-message sender context a context-aware stream carries, else the current one."""
|
|
ctx = getattr(stream, "last_context", None)
|
|
return ctx if ctx is not None else contextvars.copy_context()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _replay_from_opening_request(
|
|
read_stream: ReadStream[SessionMessage | Exception],
|
|
) -> AsyncIterator[tuple[JSONRPCRequest | None, ReadStream[SessionMessage | Exception]]]:
|
|
"""Peek at the client's first request without consuming it.
|
|
|
|
Yields that request together with a stream that replays it - preceded by
|
|
up to `_PRE_REQUEST_REPLAY_LIMIT` earlier frames - and relays the rest of
|
|
`read_stream` behind it, sender contexts included. The request is `None`
|
|
if the channel closes before one arrives.
|
|
"""
|
|
lead: list[tuple[contextvars.Context, SessionMessage | Exception]] = []
|
|
opening_request: JSONRPCRequest | None = None
|
|
replay_send, replay_receive = anyio.create_memory_object_stream[
|
|
tuple[contextvars.Context, SessionMessage | Exception]
|
|
]()
|
|
replayed = ContextReceiveStream(replay_receive)
|
|
|
|
async def replay_then_relay() -> None:
|
|
async with replay_send:
|
|
for envelope in lead:
|
|
await replay_send.send(envelope)
|
|
try:
|
|
async for item in read_stream:
|
|
await replay_send.send((_sender_context(read_stream), item))
|
|
except anyio.ClosedResourceError:
|
|
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
|
|
logger.debug("read stream closed by transport; treating as EOF")
|
|
|
|
# This helper takes ownership of `read_stream` from the serving loop, so
|
|
# every exit - including cancellation while awaiting the first request -
|
|
# closes it and the replay channel.
|
|
try:
|
|
try:
|
|
async for item in read_stream:
|
|
if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest):
|
|
opening_request = item.message
|
|
elif len(lead) >= _PRE_REQUEST_REPLAY_LIMIT:
|
|
logger.debug("dropped a frame received before the first request: %r", item)
|
|
continue
|
|
lead.append((_sender_context(read_stream), item))
|
|
if opening_request is not None:
|
|
break
|
|
except anyio.ClosedResourceError:
|
|
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
|
|
logger.debug("read stream closed by transport; treating as EOF")
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(replay_then_relay)
|
|
yield opening_request, replayed
|
|
tg.cancel_scope.cancel()
|
|
finally:
|
|
await read_stream.aclose()
|
|
replay_send.close()
|
|
replay_receive.close()
|
|
|
|
|
|
async def _serve_legacy_stream(
|
|
server: Server[LifespanT],
|
|
read_stream: ReadStream[SessionMessage | Exception],
|
|
write_stream: WriteStream[SessionMessage],
|
|
*,
|
|
lifespan_state: LifespanT,
|
|
session_id: str | None,
|
|
init_options: InitializationOptions | None,
|
|
raise_exceptions: bool,
|
|
) -> None:
|
|
"""Serve a 2025 handshake connection; enveloped requests are refused."""
|
|
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
|
read_stream,
|
|
write_stream,
|
|
raise_handler_exceptions=raise_exceptions,
|
|
# `initialize` inline for the same pipelining reason as `serve_loop`.
|
|
inline_methods=frozenset({"initialize"}),
|
|
)
|
|
connection = Connection.for_loop(dispatcher, session_id=session_id)
|
|
runner = ServerRunner(server, connection, lifespan_state, init_options=init_options)
|
|
|
|
async def on_request(
|
|
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
if method != "initialize" and _has_modern_envelope(params):
|
|
raise MCPError(
|
|
code=INVALID_REQUEST,
|
|
message="this connection serves the handshake protocol era; "
|
|
"requests carrying the 2026-07-28 envelope are not accepted on it",
|
|
)
|
|
return await runner.on_request(dctx, method, params)
|
|
|
|
try:
|
|
await dispatcher.run(on_request, runner.on_notify)
|
|
finally:
|
|
await aclose_shielded(connection)
|
|
|
|
|
|
async def _serve_modern_stream(
|
|
server: Server[LifespanT],
|
|
read_stream: ReadStream[SessionMessage | Exception],
|
|
write_stream: WriteStream[SessionMessage],
|
|
*,
|
|
lifespan_state: LifespanT,
|
|
raise_exceptions: bool,
|
|
) -> None:
|
|
"""Serve a 2026-07-28 connection: every request carries its own envelope."""
|
|
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
|
read_stream, write_stream, raise_handler_exceptions=raise_exceptions
|
|
)
|
|
outbound = NotifyOnlyOutbound(dispatcher)
|
|
|
|
async def on_request(
|
|
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
if method == "initialize":
|
|
raise MCPError(
|
|
code=UNSUPPORTED_PROTOCOL_VERSION,
|
|
message="connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted",
|
|
data=_initialize_after_modern_data(params),
|
|
)
|
|
route = classify_inbound_request({"method": method, "params": params})
|
|
if isinstance(route, InboundLadderRejection):
|
|
raise MCPError(code=route.code, message=route.message, data=route.data)
|
|
connection = Connection.from_envelope(
|
|
route.protocol_version, route.client_info, route.client_capabilities, outbound=outbound
|
|
)
|
|
try:
|
|
return await serve_one(
|
|
server,
|
|
_NoServerRequestsDispatchContext(dctx),
|
|
method,
|
|
params,
|
|
connection=connection,
|
|
lifespan_state=lifespan_state,
|
|
)
|
|
except (MCPError, ValidationError):
|
|
# The dispatcher's shared ladder maps these to the wire error.
|
|
raise
|
|
except Exception as exc:
|
|
if raise_exceptions:
|
|
raise
|
|
error = modern_error_data(exc)
|
|
raise MCPError(code=error.code, message=error.message, data=error.data) from exc
|
|
|
|
async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
|
|
# The envelope is request-only, so a notification runs at the latest
|
|
# served version; the modern protocol has nothing version-specific here.
|
|
connection = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=outbound)
|
|
notify_runner = ServerRunner(server, connection, lifespan_state)
|
|
try:
|
|
await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params)
|
|
finally:
|
|
await aclose_shielded(connection)
|
|
|
|
await dispatcher.run(on_request, on_notify)
|
|
|
|
|
|
async def serve_one(
|
|
server: Server[LifespanT],
|
|
dctx: DispatchContext[TransportContext],
|
|
method: str,
|
|
params: Mapping[str, Any] | None,
|
|
*,
|
|
connection: Connection,
|
|
lifespan_state: LifespanT,
|
|
) -> dict[str, Any]:
|
|
"""Handle a single request ``(method, params)`` and return its result dict.
|
|
|
|
The single-exchange driver: builds the kernel, runs `on_request` once under
|
|
`dctx`, and tears down `connection.exit_stack` (shielded) on the way out.
|
|
The entry constructs the (born-ready) `Connection` and the `dctx`; this
|
|
only consumes them.
|
|
|
|
Raises whatever the handler chain raises (`MCPError` / `ValidationError` /
|
|
unmapped); callers own the exception-to-wire mapping.
|
|
"""
|
|
runner = ServerRunner(server, connection, lifespan_state)
|
|
try:
|
|
return await runner.on_request(dctx, method, params)
|
|
finally:
|
|
await aclose_shielded(connection)
|
|
|
|
|
|
def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> OnRequest:
|
|
"""Return an `OnRequest` callback that serves each call via `serve_one` with a fresh per-request `Connection`.
|
|
|
|
Wire this into the server side of a `DirectDispatcher` peer-pair to drive an
|
|
in-process server on the modern per-request-envelope path (each request
|
|
carries protocol version, client info, and capabilities in `params._meta`;
|
|
no `initialize` handshake). The dispatch context is wrapped in the
|
|
server-requests denial, so the modern prohibition on server-initiated
|
|
JSON-RPC requests holds on this entry like on the others. Like `serve_one`,
|
|
this raises whatever the handler chain raises - the dispatcher owns the
|
|
exception-to-error mapping.
|
|
"""
|
|
|
|
async def handle(
|
|
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
|
) -> dict[str, Any]:
|
|
meta = (params or {}).get("_meta", {})
|
|
connection = Connection.from_envelope(
|
|
meta.get(PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION),
|
|
meta.get(CLIENT_INFO_META_KEY),
|
|
meta.get(CLIENT_CAPABILITIES_META_KEY),
|
|
)
|
|
return await serve_one(
|
|
server,
|
|
_NoServerRequestsDispatchContext(dctx),
|
|
method,
|
|
params,
|
|
connection=connection,
|
|
lifespan_state=lifespan_state,
|
|
)
|
|
|
|
return handle
|