Server-side 2026-07-28 stateless support: classifier, driver split, server/discover (#2928)
This commit is contained in:
parent
44724284b3
commit
2397319a68
12 changed files with 2256 additions and 1088 deletions
|
|
@ -314,16 +314,29 @@ class StreamableHTTPTransport:
|
|||
logger.debug("Received 202 Accepted")
|
||||
return
|
||||
|
||||
if response.status_code == 404:
|
||||
if isinstance(message, JSONRPCRequest):
|
||||
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
|
||||
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
|
||||
await ctx.read_stream_writer.send(session_message)
|
||||
return
|
||||
|
||||
if response.status_code >= 400:
|
||||
if isinstance(message, JSONRPCRequest):
|
||||
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
|
||||
# A spec-correct server may return the JSON-RPC error in the
|
||||
# body at a non-2xx status (e.g. 400 for INVALID_PARAMS, 404
|
||||
# for METHOD_NOT_FOUND). Surface that error rather than the
|
||||
# status-derived stand-in below.
|
||||
if response.headers.get("content-type", "").lower().startswith("application/json"):
|
||||
try:
|
||||
body = await response.aread()
|
||||
parsed = jsonrpc_message_adapter.validate_json(body, by_name=False)
|
||||
if isinstance(parsed, JSONRPCError):
|
||||
# The server may have set `id: null` (request rejected before its
|
||||
# id was parsed); use this request's id so correlation works.
|
||||
reply = JSONRPCError(jsonrpc="2.0", id=message.id, error=parsed.error)
|
||||
await ctx.read_stream_writer.send(SessionMessage(reply))
|
||||
return
|
||||
except (httpx.StreamError, ValidationError):
|
||||
pass
|
||||
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
|
||||
if response.status_code == 404:
|
||||
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
|
||||
else:
|
||||
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
|
||||
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
|
||||
await ctx.read_stream_writer.send(session_message)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -12,33 +12,32 @@ no per-request task group, no `JSONRPCDispatcher`.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
from pydantic import ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp.server.runner import (
|
||||
_EXIT_STACK_CLOSE_TIMEOUT, # type: ignore[reportPrivateUsage]
|
||||
ServerRunner,
|
||||
otel_middleware,
|
||||
)
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.runner import serve_one
|
||||
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
|
||||
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
|
||||
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
||||
from mcp.shared.dispatcher import CallOptions
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
from mcp.shared.inbound import ERROR_CODE_HTTP_STATUS, InboundLadderRejection, classify_inbound_request
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
PARSE_ERROR,
|
||||
ClientCapabilities,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
|
|
@ -50,6 +49,10 @@ if TYPE_CHECKING:
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
||||
|
||||
_OK_STATUS = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SingleExchangeDispatchContext:
|
||||
|
|
@ -64,7 +67,7 @@ class _SingleExchangeDispatchContext:
|
|||
request_id: RequestId
|
||||
message_metadata: MessageMetadata
|
||||
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
|
||||
can_send_request: bool = False
|
||||
can_send_request: bool = field(default=False, init=False)
|
||||
|
||||
async def send_raw_request(
|
||||
self,
|
||||
|
|
@ -75,91 +78,51 @@ class _SingleExchangeDispatchContext:
|
|||
raise NoBackChannelError(method)
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
# TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
|
||||
return None
|
||||
|
||||
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||
# TODO: no progressToken plumbing yet.
|
||||
# TODO(D-005a): no progressToken plumbing yet; ships with the SSE response mode.
|
||||
return None
|
||||
|
||||
|
||||
class SingleExchangeDispatcher:
|
||||
"""Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST.
|
||||
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
|
||||
"""Validate the classifier's raw envelope value into a typed model.
|
||||
|
||||
The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s
|
||||
role). Implements the `Dispatcher` Protocol so `ServerRunner` /
|
||||
`Connection` / `ServerSession` accept it; `run()` is never driven.
|
||||
Rung 1 guarantees the envelope key was present; a ``null`` or mis-shaped
|
||||
value falls through to ``ValidationError`` and is treated as not supplied
|
||||
so the request still routes.
|
||||
"""
|
||||
|
||||
def __init__(self, request: Request) -> None:
|
||||
self._request = request
|
||||
self._tctx = TransportContext(
|
||||
kind="streamable-http",
|
||||
can_send_request=False,
|
||||
headers=request.headers,
|
||||
)
|
||||
|
||||
async def send_raw_request(
|
||||
self,
|
||||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
opts: CallOptions | None = None,
|
||||
*,
|
||||
_related_request_id: RequestId | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raise NoBackChannelError(method)
|
||||
|
||||
async def notify(
|
||||
self,
|
||||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
*,
|
||||
_related_request_id: RequestId | None = None,
|
||||
) -> None:
|
||||
# TODO: buffer and stream as SSE once the response-mode design lands.
|
||||
try:
|
||||
return model.model_validate(raw, by_name=False)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
async def run(
|
||||
self,
|
||||
on_request: OnRequest,
|
||||
on_notify: OnNotify,
|
||||
*,
|
||||
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
||||
) -> None:
|
||||
raise RuntimeError("SingleExchangeDispatcher.run() is never driven; use handle()")
|
||||
|
||||
async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCResponse | JSONRPCError:
|
||||
"""Dispatch one request and map any exception to a `JSONRPCError`."""
|
||||
dctx = _SingleExchangeDispatchContext(
|
||||
transport=self._tctx,
|
||||
request_id=req.id,
|
||||
message_metadata=ServerMessageMetadata(request_context=self._request),
|
||||
)
|
||||
try:
|
||||
result = await on_request(dctx, req.method, req.params)
|
||||
return JSONRPCResponse(jsonrpc="2.0", id=req.id, result=result)
|
||||
except MCPError as e:
|
||||
return JSONRPCError(jsonrpc="2.0", id=req.id, error=e.error)
|
||||
except ValidationError:
|
||||
return JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=req.id,
|
||||
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
|
||||
)
|
||||
# TODO: consolidate the three exception->ErrorData copies once the
|
||||
# code=0 compat pin in JSONRPCDispatcher is lifted.
|
||||
except Exception:
|
||||
logger.exception("handler for %r raised", req.method)
|
||||
return JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=req.id,
|
||||
error=ErrorData(code=INTERNAL_ERROR, message="Internal server error"),
|
||||
)
|
||||
async def _write(
|
||||
msg: JSONRPCResponse | JSONRPCError,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
) -> None:
|
||||
"""Serialise a JSON-RPC reply with the table-mapped HTTP status."""
|
||||
status = ERROR_CODE_HTTP_STATUS.get(msg.error.code, _OK_STATUS) if isinstance(msg, JSONRPCError) else _OK_STATUS
|
||||
body = msg.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
if isinstance(msg, JSONRPCError) and msg.id is None:
|
||||
# JSON-RPC requires `id: null` to appear on the wire when the request
|
||||
# id couldn't be parsed; `exclude_none` would otherwise drop it.
|
||||
body["id"] = None
|
||||
await Response(
|
||||
json.dumps(body, separators=(",", ":")),
|
||||
status_code=status,
|
||||
media_type="application/json",
|
||||
)(scope, receive, send)
|
||||
|
||||
|
||||
async def handle_modern_request(
|
||||
app: Server[Any],
|
||||
security_settings: TransportSecuritySettings | None,
|
||||
protocol_version: str,
|
||||
lifespan_state: Any,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
|
|
@ -167,8 +130,9 @@ async def handle_modern_request(
|
|||
"""ASGI handler for a single stateless-era POST.
|
||||
|
||||
Called from `StreamableHTTPSessionManager.handle_request` when the
|
||||
`MCP-Protocol-Version` header is in `MODERN_PROTOCOL_VERSIONS`; the header
|
||||
value is passed as `protocol_version`. Never sets `Mcp-Session-Id`.
|
||||
`MCP-Protocol-Version` header names a modern revision; the manager enters
|
||||
`app.lifespan` once at startup and passes the state in. Never sets
|
||||
`Mcp-Session-Id`.
|
||||
"""
|
||||
request = Request(scope, receive)
|
||||
|
||||
|
|
@ -178,54 +142,56 @@ async def handle_modern_request(
|
|||
await err(scope, receive, send)
|
||||
return
|
||||
|
||||
# TODO: validate Accept header once the JSON-vs-SSE response-mode design is settled.
|
||||
# TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled.
|
||||
|
||||
if request.method != "POST":
|
||||
# TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder.
|
||||
# HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens
|
||||
# before JSON-RPC parsing, so it doesn't go through `_write`.
|
||||
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
|
||||
return
|
||||
|
||||
body = await request.body()
|
||||
try:
|
||||
req = JSONRPCRequest.model_validate_json(body)
|
||||
decoded = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
|
||||
await _write(rej, scope, receive, send)
|
||||
return
|
||||
try:
|
||||
req = JSONRPCRequest.model_validate(decoded)
|
||||
except ValidationError:
|
||||
msg = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
|
||||
await Response(
|
||||
msg.model_dump_json(by_alias=True),
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)(scope, receive, send)
|
||||
# Well-formed JSON that isn't a single request object. The transport
|
||||
# spec permits notification POSTs and gives the server two responses
|
||||
# (202 accept / 4xx cannot-accept; streamable-http §Sending Messages
|
||||
# item 5). The core protocol defines no client→server notifications
|
||||
# over HTTP at 2026-07-28 (cancellation is SSE-stream close), so this
|
||||
# entry takes the cannot-accept branch. TODO(L57): S4 owns the
|
||||
# strict-vs-lenient choice.
|
||||
rej = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=None,
|
||||
error=ErrorData(code=INVALID_REQUEST, message="Body must be a single JSON-RPC request object"),
|
||||
)
|
||||
await _write(rej, scope, receive, send)
|
||||
return
|
||||
|
||||
dispatcher = SingleExchangeDispatcher(request)
|
||||
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893.
|
||||
async with app.lifespan(app) as lifespan_state:
|
||||
runner = ServerRunner(
|
||||
server=app,
|
||||
dispatcher=dispatcher,
|
||||
lifespan_state=lifespan_state,
|
||||
has_standalone_channel=False,
|
||||
stateless=True,
|
||||
dispatch_middleware=[otel_middleware],
|
||||
verdict = classify_inbound_request(decoded, headers=dict(request.headers))
|
||||
if isinstance(verdict, InboundLadderRejection):
|
||||
rej = JSONRPCError(
|
||||
jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data)
|
||||
)
|
||||
runner.connection.protocol_version = protocol_version
|
||||
try:
|
||||
msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage]
|
||||
finally:
|
||||
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as cancel_scope:
|
||||
try:
|
||||
await runner.connection.exit_stack.aclose()
|
||||
except Exception:
|
||||
logger.exception("connection exit_stack cleanup raised")
|
||||
if cancel_scope.cancelled_caught:
|
||||
logger.warning(
|
||||
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
|
||||
_EXIT_STACK_CLOSE_TIMEOUT,
|
||||
)
|
||||
await _write(rej, scope, receive, send)
|
||||
return
|
||||
|
||||
# TODO: error.code -> HTTP status mapping is a follow-up; 200 for all JSONRPCError bodies for now.
|
||||
await Response(
|
||||
msg.model_dump_json(by_alias=True, exclude_none=True),
|
||||
status_code=200,
|
||||
media_type="application/json",
|
||||
)(scope, receive, send)
|
||||
connection = Connection.from_envelope(
|
||||
verdict.protocol_version,
|
||||
_typed(Implementation, verdict.client_info),
|
||||
_typed(ClientCapabilities, verdict.client_capabilities),
|
||||
)
|
||||
dctx = _SingleExchangeDispatchContext(
|
||||
transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers),
|
||||
request_id=req.id,
|
||||
message_metadata=ServerMessageMetadata(request_context=request),
|
||||
)
|
||||
msg = await serve_one(app, req, connection=connection, dctx=dctx, lifespan_state=lifespan_state)
|
||||
await _write(msg, scope, receive, send)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
"""`Connection` - per-client connection state and the standalone outbound channel.
|
||||
|
||||
Always present on `Context` (never `None`), even in stateless deployments.
|
||||
Holds peer info populated at `initialize` time, per-connection scratch
|
||||
`state` and an `exit_stack` for teardown, and an `Outbound` for the
|
||||
standalone stream (the SSE GET stream in streamable HTTP, or the single duplex
|
||||
stream in stdio).
|
||||
Holds peer info, per-connection scratch `state` and an `exit_stack` for
|
||||
teardown, and an `Outbound` for the standalone stream (the SSE GET stream in
|
||||
streamable HTTP, or the single duplex stream in stdio).
|
||||
|
||||
Construct via the factories: `Connection.from_envelope` for the 2026-era
|
||||
single-exchange path (born ready, no back-channel) and `Connection.for_loop`
|
||||
for the handshake-driven loop path. Both populate `protocol_version` so the
|
||||
kernel reads it as a fact.
|
||||
|
||||
`notify` is best-effort: it never raises. If there's no standalone channel
|
||||
(stateless HTTP) or the stream has been dropped, the notification is
|
||||
debug-logged and silently discarded - server-initiated notifications are
|
||||
inherently advisory. `send_raw_request` *does* raise `NoBackChannelError` when
|
||||
there's no channel; `ping` is the only spec-sanctioned standalone request.
|
||||
or the stream has been dropped, the notification is debug-logged and silently
|
||||
discarded - server-initiated notifications are inherently advisory.
|
||||
`send_raw_request` raises `NoBackChannelError` when there's no channel; `ping`
|
||||
is the only spec-sanctioned standalone request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from contextlib import AsyncExitStack
|
||||
|
|
@ -26,12 +32,14 @@ from mcp.shared.dispatcher import CallOptions, Outbound
|
|||
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
|
||||
from mcp.shared.peer import Meta, dump_params
|
||||
from mcp.types import (
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
EmptyResult,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
|
|
@ -68,32 +76,57 @@ def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> d
|
|||
return out
|
||||
|
||||
|
||||
class _NoChannelOutbound:
|
||||
"""Connection-scoped `Outbound` for the no-back-channel case.
|
||||
|
||||
The structural answer to "this connection cannot push to its peer":
|
||||
`send_raw_request` raises `NoBackChannelError`; `notify` drops with a
|
||||
debug log. `Connection.from_envelope` installs this so the modern
|
||||
single-exchange path never needs a mode flag - the channel itself says no.
|
||||
"""
|
||||
|
||||
async def send_raw_request(
|
||||
self,
|
||||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
opts: CallOptions | None = None,
|
||||
) -> dict[str, Any]:
|
||||
raise NoBackChannelError(method)
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
logger.debug("dropped %s: no standalone channel", method)
|
||||
|
||||
|
||||
_NO_CHANNEL = _NoChannelOutbound()
|
||||
|
||||
|
||||
class Connection:
|
||||
"""Per-client connection state and standalone-stream `Outbound`.
|
||||
|
||||
Constructed by `ServerRunner` once per connection. The peer-info fields
|
||||
are `None` until `initialize` completes; `initialized` is set later, when
|
||||
the client's `notifications/initialized` follow-up arrives. In stateless
|
||||
deployments the runner sets `initialized` immediately and peer-info
|
||||
remains `None` (no handshake reaches a stateless connection).
|
||||
Construct via `from_envelope` (modern single-exchange: born ready, no
|
||||
back-channel) or `for_loop` (handshake-driven: ready once the client's
|
||||
`notifications/initialized` arrives). Either way `protocol_version` is
|
||||
populated at construction.
|
||||
"""
|
||||
|
||||
has_standalone_channel: bool
|
||||
outbound: Outbound
|
||||
"""The connection-scoped channel for server-initiated messages."""
|
||||
|
||||
session_id: str | None
|
||||
|
||||
client_params: InitializeRequestParams | None
|
||||
"""The full `initialize` request params; `None` before initialization."""
|
||||
"""The full `initialize` request params, or the equivalent built from the
|
||||
2026-era envelope. `None` when no client info was supplied."""
|
||||
|
||||
protocol_version: str | None
|
||||
"""The protocol version negotiated during `initialize`; `None` before
|
||||
initialization. Stateless connections don't require the handshake, so this
|
||||
normally stays `None` there (a client that sends `initialize` anyway still
|
||||
commits it). For the per-request value, read `ctx.protocol_version`."""
|
||||
protocol_version: str
|
||||
"""The protocol version this connection speaks. Populated at construction
|
||||
by the factory and overwritten by `_handle_initialize` once the handshake
|
||||
commits on the loop path."""
|
||||
|
||||
initialized: anyio.Event
|
||||
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
|
||||
the point from which the spec permits server-initiated requests beyond
|
||||
ping/logging. Pre-set on stateless connections."""
|
||||
ping/logging. Pre-set on connections built via `from_envelope`."""
|
||||
|
||||
state: dict[str, Any]
|
||||
"""Per-connection scratch state; persists across requests on this connection."""
|
||||
|
|
@ -103,24 +136,83 @@ class Connection:
|
|||
closes. Push cleanup from handlers or middleware; exceptions are logged
|
||||
and swallowed."""
|
||||
|
||||
def __init__(self, outbound: Outbound, *, has_standalone_channel: bool, session_id: str | None = None) -> None:
|
||||
self._outbound = outbound
|
||||
self.has_standalone_channel = has_standalone_channel
|
||||
def __init__(
|
||||
self,
|
||||
outbound: Outbound,
|
||||
*,
|
||||
protocol_version: str,
|
||||
session_id: str | None = None,
|
||||
client_params: InitializeRequestParams | None = None,
|
||||
) -> None:
|
||||
self.outbound = outbound
|
||||
self.protocol_version = protocol_version
|
||||
self.session_id = session_id
|
||||
|
||||
self.client_params = None
|
||||
self.protocol_version = None
|
||||
self.client_params = client_params
|
||||
self.initialized = anyio.Event()
|
||||
|
||||
self.state = {}
|
||||
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
@classmethod
|
||||
def from_envelope(
|
||||
cls,
|
||||
protocol_version: str,
|
||||
client_info: Implementation | None,
|
||||
client_capabilities: ClientCapabilities | None,
|
||||
*,
|
||||
outbound: Outbound = _NO_CHANNEL,
|
||||
) -> Connection:
|
||||
"""A born-ready connection populated from a request's `_meta` envelope.
|
||||
|
||||
`initialized` is set and the envelope's client info/capabilities (when
|
||||
both supplied) are recorded as `client_params` so capability checks
|
||||
work. `outbound` defaults to the no-channel sentinel for the
|
||||
single-exchange HTTP path; duplex modern transports (e.g. stdio) pass
|
||||
the dispatcher so server-initiated messages have a back-channel.
|
||||
"""
|
||||
client_params = None
|
||||
if client_info is not None and client_capabilities is not None:
|
||||
client_params = InitializeRequestParams(
|
||||
protocol_version=protocol_version,
|
||||
capabilities=client_capabilities,
|
||||
client_info=client_info,
|
||||
)
|
||||
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
|
||||
connection.initialized.set()
|
||||
return connection
|
||||
|
||||
@classmethod
|
||||
def for_loop(
|
||||
cls,
|
||||
outbound: Outbound,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
protocol_version_hint: str | None = None,
|
||||
) -> Connection:
|
||||
"""A connection for the handshake-driven loop path.
|
||||
|
||||
Not born-ready: `initialized` is set later by the kernel when
|
||||
`notifications/initialized` arrives. `protocol_version` is seeded from
|
||||
the transport hint (or `LATEST_PROTOCOL_VERSION`) so it's never `None`;
|
||||
the handshake overwrites it once negotiated.
|
||||
"""
|
||||
return cls(
|
||||
outbound,
|
||||
protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_PROTOCOL_VERSION,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def has_standalone_channel(self) -> bool:
|
||||
"""Whether this connection has a real back-channel for server-initiated
|
||||
messages. Derived from `outbound` - the no-channel sentinel is the only
|
||||
case that doesn't."""
|
||||
return self.outbound is not _NO_CHANNEL
|
||||
|
||||
@property
|
||||
def initialize_accepted(self) -> bool:
|
||||
"""True once the inbound request gate is open: `initialize` recorded the
|
||||
peer info, or the handshake completed outright (stateless birth, or a
|
||||
bare `notifications/initialized`). Derived, never stored."""
|
||||
peer info, or the handshake completed outright (born-ready, or a bare
|
||||
`notifications/initialized`). Derived, never stored."""
|
||||
return self.client_params is not None or self.initialized.is_set()
|
||||
|
||||
async def send_raw_request(
|
||||
|
|
@ -140,9 +232,7 @@ class Connection:
|
|||
MCPError: The peer responded with an error.
|
||||
NoBackChannelError: `has_standalone_channel` is `False`.
|
||||
"""
|
||||
if not self.has_standalone_channel:
|
||||
raise NoBackChannelError(method)
|
||||
return await self._outbound.send_raw_request(method, params, opts)
|
||||
return await self.outbound.send_raw_request(method, params, opts)
|
||||
|
||||
@overload
|
||||
async def send_request(
|
||||
|
|
@ -177,11 +267,9 @@ class Connection:
|
|||
KeyError: `result_type` omitted for a non-spec request type.
|
||||
"""
|
||||
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
|
||||
# Literal fallback covers pre-handshake and stateless; matches runner.py.
|
||||
version = self.protocol_version or "2025-11-25"
|
||||
if req.method in _methods.MONOLITH_REQUESTS:
|
||||
try:
|
||||
_methods.validate_client_result(req.method, version, raw)
|
||||
_methods.validate_client_result(req.method, self.protocol_version, raw)
|
||||
except KeyError:
|
||||
pass
|
||||
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
|
||||
|
|
@ -193,11 +281,8 @@ class Connection:
|
|||
Never raises. If there's no standalone channel or the stream is broken,
|
||||
the notification is dropped and debug-logged.
|
||||
"""
|
||||
if not self.has_standalone_channel:
|
||||
logger.debug("dropped %s: no standalone channel", method)
|
||||
return
|
||||
try:
|
||||
await self._outbound.notify(method, params)
|
||||
await self.outbound.notify(method, params)
|
||||
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
||||
logger.debug("dropped %s: standalone stream closed", method)
|
||||
|
||||
|
|
@ -233,9 +318,9 @@ class Connection:
|
|||
def check_capability(self, capability: ClientCapabilities) -> bool:
|
||||
"""Return whether the connected client declared the given capability.
|
||||
|
||||
Returns `False` if `initialize` hasn't completed yet.
|
||||
Returns `False` when no client info has been recorded.
|
||||
"""
|
||||
# TODO: redesign - mirrors v1 ServerSession.check_client_capability
|
||||
# TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
|
||||
# verbatim for parity.
|
||||
if self.client_params is None:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -58,14 +58,13 @@ from mcp.server.auth.routes import build_resource_metadata_url, create_auth_rout
|
|||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext
|
||||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server.runner import ServerRunner, otel_middleware
|
||||
from mcp.server.runner import serve_loop
|
||||
from mcp.server.streamable_http import EventStore
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -117,6 +116,15 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
|
|||
return types.EmptyResult()
|
||||
|
||||
|
||||
def _package_version(package: str) -> str:
|
||||
try:
|
||||
return importlib_version(package)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
return "unknown" # pragma: no cover
|
||||
|
||||
|
||||
class Server(Generic[LifespanResultT]):
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -218,7 +226,7 @@ class Server(Generic[LifespanResultT]):
|
|||
# Context-tier middleware: wraps every inbound request (including
|
||||
# `initialize`, lookup, validation, handler) with
|
||||
# `(ctx, method, params, call_next)`. Applied in `ServerRunner._on_request`.
|
||||
# TODO(maxisbey): provisional - signature and semantics change with the
|
||||
# TODO(L54): provisional - signature and semantics change with the
|
||||
# Context/middleware rework (covariant `Context[L]`, outbound seam) before
|
||||
# v2 final.
|
||||
self.middleware: list[ServerMiddleware[LifespanResultT]] = []
|
||||
|
|
@ -226,6 +234,7 @@ class Server(Generic[LifespanResultT]):
|
|||
|
||||
_spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [
|
||||
("ping", types.RequestParams, on_ping),
|
||||
("server/discover", types.RequestParams, self._handle_discover),
|
||||
("prompts/list", types.PaginatedRequestParams, on_list_prompts),
|
||||
("prompts/get", types.GetPromptRequestParams, on_get_prompt),
|
||||
("resources/list", types.PaginatedRequestParams, on_list_resources),
|
||||
|
|
@ -298,7 +307,7 @@ class Server(Generic[LifespanResultT]):
|
|||
"""Return the registered entry for a notification method, or `None`."""
|
||||
return self._notification_handlers.get(method)
|
||||
|
||||
# TODO: Rethink capabilities API. Currently capabilities are derived from registered
|
||||
# TODO(L53): Rethink capabilities API. Currently capabilities are derived from registered
|
||||
# handlers but require NotificationOptions to be passed externally for list_changed
|
||||
# flags, and experimental_capabilities as a separate dict. Consider deriving capabilities
|
||||
# entirely from server state (e.g. constructor params for list_changed) instead of
|
||||
|
|
@ -309,18 +318,9 @@ class Server(Generic[LifespanResultT]):
|
|||
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
) -> InitializationOptions:
|
||||
"""Create initialization options from this server instance."""
|
||||
|
||||
def pkg_version(package: str) -> str:
|
||||
try:
|
||||
return importlib_version(package)
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
return "unknown" # pragma: no cover
|
||||
|
||||
return InitializationOptions(
|
||||
server_name=self.name,
|
||||
server_version=self.version if self.version else pkg_version("mcp"),
|
||||
server_version=self.version if self.version else _package_version("mcp"),
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
capabilities=self.get_capabilities(
|
||||
|
|
@ -334,10 +334,11 @@ class Server(Generic[LifespanResultT]):
|
|||
|
||||
def get_capabilities(
|
||||
self,
|
||||
notification_options: NotificationOptions,
|
||||
experimental_capabilities: dict[str, dict[str, Any]],
|
||||
notification_options: NotificationOptions | None = None,
|
||||
experimental_capabilities: dict[str, dict[str, Any]] | None = None,
|
||||
) -> types.ServerCapabilities:
|
||||
"""Convert existing handlers to a ServerCapabilities object."""
|
||||
notification_options = notification_options or NotificationOptions()
|
||||
prompts_capability = None
|
||||
resources_capability = None
|
||||
tools_capability = None
|
||||
|
|
@ -377,6 +378,40 @@ class Server(Generic[LifespanResultT]):
|
|||
)
|
||||
return capabilities
|
||||
|
||||
@property
|
||||
def server_info(self) -> types.Implementation:
|
||||
"""The `serverInfo` block describing this implementation.
|
||||
|
||||
Derived from the constructor's identity fields. `version` falls back to
|
||||
the installed `mcp` package version when not supplied explicitly.
|
||||
"""
|
||||
return types.Implementation(
|
||||
name=self.name,
|
||||
version=self.version if self.version else _package_version("mcp"),
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
website_url=self.website_url,
|
||||
icons=self.icons,
|
||||
)
|
||||
|
||||
async def _handle_discover(
|
||||
self, ctx: ServerRequestContext[LifespanResultT], params: types.RequestParams | None
|
||||
) -> types.DiscoverResult:
|
||||
"""Default `server/discover` handler.
|
||||
|
||||
Auto-derived from server state at call time, so capabilities reflect
|
||||
whatever has been registered (constructor `on_*` kwargs and later
|
||||
`add_request_handler` calls). Operators can replace it wholesale via
|
||||
`add_request_handler("server/discover", ...)`. Reachability for legacy
|
||||
peers is decided at the boundary (`types.methods`), not here.
|
||||
"""
|
||||
return types.DiscoverResult(
|
||||
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
|
||||
capabilities=self.get_capabilities(),
|
||||
server_info=self.server_info,
|
||||
instructions=self.instructions,
|
||||
)
|
||||
|
||||
@property
|
||||
def session_manager(self) -> StreamableHTTPSessionManager:
|
||||
"""Get the StreamableHTTP session manager.
|
||||
|
|
@ -401,36 +436,22 @@ class Server(Generic[LifespanResultT]):
|
|||
# but also make tracing exceptions much easier during testing and when using
|
||||
# in-process servers.
|
||||
raise_exceptions: bool = False,
|
||||
# When True, the server is stateless and
|
||||
# clients can perform initialization with any node. The client must still follow
|
||||
# the initialization lifecycle, but can do so with any available node
|
||||
# rather than requiring initialization for each connection.
|
||||
stateless: bool = False,
|
||||
) -> None:
|
||||
"""Serve a single connection over the given streams until the read side closes.
|
||||
|
||||
Thin wrapper over `serve_loop`: enters the server lifespan,
|
||||
then drives the loop. Transports with their own lifespan owner
|
||||
(the streamable-HTTP manager) call `serve_loop` directly instead.
|
||||
"""
|
||||
async with self.lifespan(self) as lifespan_context:
|
||||
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
||||
await serve_loop(
|
||||
self,
|
||||
read_stream,
|
||||
write_stream,
|
||||
raise_handler_exceptions=raise_exceptions,
|
||||
# Handle `initialize` inline so a client that pipelines it with
|
||||
# the next request (spec says SHOULD NOT, not MUST NOT) sees
|
||||
# the initialized state instead of failing the init-gate.
|
||||
inline_methods=frozenset({"initialize"}),
|
||||
)
|
||||
runner = ServerRunner(
|
||||
server=self,
|
||||
dispatcher=dispatcher,
|
||||
lifespan_state=lifespan_context,
|
||||
init_options=initialization_options,
|
||||
# Stateless HTTP has no standalone GET stream, so server-initiated
|
||||
# requests on `runner.connection` must fail fast with
|
||||
# `NoBackChannelError` rather than write to a channel that will
|
||||
# never deliver a response.
|
||||
has_standalone_channel=not stateless,
|
||||
stateless=stateless,
|
||||
dispatch_middleware=[otel_middleware],
|
||||
raise_exceptions=raise_exceptions,
|
||||
)
|
||||
await runner.run()
|
||||
|
||||
def streamable_http_app(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from mcp.server.mcpserver.exceptions import ToolError
|
|||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
|
||||
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
|
||||
from mcp.shared._callable_inspection import is_async_callable
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
|
||||
from mcp.types import Icon, ToolAnnotations
|
||||
|
||||
|
|
@ -111,9 +111,12 @@ class Tool(BaseModel):
|
|||
result = self.fn_metadata.convert_result(result)
|
||||
|
||||
return result
|
||||
except UrlElicitationRequiredError:
|
||||
# Re-raise UrlElicitationRequiredError so it can be properly handled
|
||||
# as an MCP error response with code -32042
|
||||
except MCPError:
|
||||
# `MCPError` (and subclasses such as `UrlElicitationRequiredError`)
|
||||
# carries a JSON-RPC `ErrorData(code, message, data)` and means
|
||||
# "respond with a protocol error" - re-raise so the kernel surfaces
|
||||
# it as a top-level JSON-RPC error rather than wrapping it as a
|
||||
# `CallToolResult(isError=True)` execution failure.
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"""`ServerRunner` - per-connection orchestrator over a `Dispatcher`.
|
||||
"""`ServerRunner` - the per-connection handler kernel.
|
||||
|
||||
`ServerRunner` is the bridge between the dispatcher layer (`on_request` /
|
||||
`on_notify`, untyped dicts) and the user's handler layer (typed `Context`,
|
||||
typed params). One instance per client connection. It:
|
||||
|
||||
* handles the `initialize` handshake and populates `Connection`
|
||||
* gates requests until initialized (`ping` exempt)
|
||||
* looks up the handler in the server's registry, validates params, builds
|
||||
`Context`, runs the middleware chain, returns the result dict
|
||||
* drives `dispatcher.run()` and the per-connection lifespan
|
||||
`ServerRunner` bridges the dispatch layer (`on_request` / `on_notify`, untyped
|
||||
dicts) and the user's handler layer (typed `Context`, typed params). It is a
|
||||
pure kernel: it holds a pre-populated `Connection` and reads
|
||||
`connection.protocol_version` / `connection.outbound` as facts. Driving a
|
||||
dispatcher loop and tearing down the connection live in the free-function
|
||||
drivers (`serve_connection`, `serve_loop`, `serve_one`); the entry constructs
|
||||
the `Connection`, the driver tears it down.
|
||||
|
||||
`ServerRunner` holds a `Server` directly - `Server` is the registry.
|
||||
"""
|
||||
|
|
@ -16,11 +14,12 @@ typed params). One instance per client connection. It:
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial, reduce
|
||||
from collections.abc import Awaitable, Mapping, Sequence
|
||||
from dataclasses import KW_ONLY, dataclass
|
||||
from functools import cached_property, partial, reduce
|
||||
from typing import TYPE_CHECKING, Any, Generic, cast
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
|
@ -31,9 +30,11 @@ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, Server
|
|||
from mcp.server.models import InitializationOptions
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared._otel import extract_trace_context, otel_span
|
||||
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnRequest
|
||||
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
|
||||
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
|
|
@ -41,11 +42,14 @@ from mcp.types import (
|
|||
INVALID_PARAMS,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
InitializeRequestParams,
|
||||
InitializeResult,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
RequestId,
|
||||
RequestParams,
|
||||
RequestParamsMeta,
|
||||
)
|
||||
|
|
@ -54,7 +58,17 @@ from mcp.types import methods as _methods
|
|||
if TYPE_CHECKING:
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
__all__ = ["CallNext", "ServerMiddleware", "ServerRunner", "otel_middleware"]
|
||||
__all__ = [
|
||||
"CallNext",
|
||||
"ServerMiddleware",
|
||||
"ServerRunner",
|
||||
"aclose_shielded",
|
||||
"otel_middleware",
|
||||
"serve_connection",
|
||||
"serve_loop",
|
||||
"serve_one",
|
||||
"to_jsonrpc_response",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -64,8 +78,8 @@ LifespanT = TypeVar("LifespanT", default=Any)
|
|||
_INIT_EXEMPT: frozenset[str] = frozenset({"ping"})
|
||||
|
||||
_EXIT_STACK_CLOSE_TIMEOUT: float = 5
|
||||
"""Bound for the shielded exit-stack unwind in `run()`; a hung cleanup
|
||||
callback must not wedge shutdown."""
|
||||
"""Bound for `aclose_shielded`'s exit-stack unwind; a hung cleanup callback
|
||||
must not wedge shutdown."""
|
||||
|
||||
|
||||
def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
|
||||
|
|
@ -79,30 +93,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
|
|||
return None
|
||||
|
||||
|
||||
def _resolve_protocol_version(
|
||||
negotiated: str | None,
|
||||
meta: RequestParamsMeta | None,
|
||||
md: MessageMetadata,
|
||||
) -> str:
|
||||
"""Resolve the protocol version for this inbound message.
|
||||
|
||||
Handshake-committed value wins; else per-request `_meta`, else the
|
||||
transport hint. Unsupported values fall through so surface validation
|
||||
never sees them.
|
||||
"""
|
||||
if negotiated is not None:
|
||||
return negotiated
|
||||
if meta is not None:
|
||||
v = meta.get(PROTOCOL_VERSION_META_KEY)
|
||||
if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS:
|
||||
return v
|
||||
if isinstance(md, ServerMessageMetadata):
|
||||
hint = md.protocol_version
|
||||
if hint is not None and hint in SUPPORTED_PROTOCOL_VERSIONS:
|
||||
return hint
|
||||
return "2025-11-25"
|
||||
|
||||
|
||||
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
|
||||
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
|
||||
|
||||
|
|
@ -169,73 +159,73 @@ def _dump_result(result: Any) -> dict[str, Any]:
|
|||
raise TypeError(f"handler returned {type(result).__name__}; expected BaseModel, dict, or None")
|
||||
|
||||
|
||||
async def aclose_shielded(connection: Connection) -> None:
|
||||
"""Unwind ``connection.exit_stack`` under a shielded, bounded scope.
|
||||
|
||||
Called from a driver's ``finally``: the shield lets per-connection cleanup
|
||||
callbacks run even when the driver itself is being cancelled, the
|
||||
`_EXIT_STACK_CLOSE_TIMEOUT` bound stops a hung callback wedging shutdown,
|
||||
and a raising callback is logged-and-swallowed so it never masks the
|
||||
driver's own exception.
|
||||
"""
|
||||
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
|
||||
try:
|
||||
await connection.exit_stack.aclose()
|
||||
except Exception:
|
||||
logger.exception("connection exit_stack cleanup raised")
|
||||
if scope.cancelled_caught:
|
||||
logger.warning(
|
||||
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
|
||||
_EXIT_STACK_CLOSE_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
async def to_jsonrpc_response(request_id: RequestId, coro: Awaitable[dict[str, Any]]) -> JSONRPCResponse | JSONRPCError:
|
||||
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
|
||||
|
||||
The exception-to-wire boundary for the request-per-call drivers
|
||||
(`serve_one`, the modern HTTP entry). `MCPError` and `ValidationError`
|
||||
map via the shared `handler_exception_to_error_data` ladder; any other
|
||||
exception is logged and surfaced as `INTERNAL_ERROR` so handler internals
|
||||
never reach the wire.
|
||||
"""
|
||||
try:
|
||||
result = await coro
|
||||
except Exception as exc:
|
||||
error = handler_exception_to_error_data(exc)
|
||||
if error is None:
|
||||
logger.exception("request handler raised")
|
||||
error = ErrorData(code=INTERNAL_ERROR, message="Internal server error")
|
||||
return JSONRPCError(jsonrpc="2.0", id=request_id, error=error)
|
||||
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServerRunner(Generic[LifespanT]):
|
||||
"""Per-connection orchestrator. One instance per client connection."""
|
||||
"""Per-connection handler kernel. One instance per client connection."""
|
||||
|
||||
server: Server[LifespanT]
|
||||
dispatcher: Dispatcher[Any]
|
||||
connection: Connection
|
||||
lifespan_state: LifespanT
|
||||
has_standalone_channel: bool
|
||||
_: KW_ONLY
|
||||
init_options: InitializationOptions | None = None
|
||||
"""`InitializeResult` payload. Defaults to `server.create_initialization_options()`."""
|
||||
session_id: str | None = None
|
||||
stateless: bool = False
|
||||
dispatch_middleware: list[DispatchMiddleware] = field(default_factory=list[DispatchMiddleware])
|
||||
dispatch_middleware: Sequence[DispatchMiddleware] = (otel_middleware,)
|
||||
|
||||
connection: Connection = field(init=False)
|
||||
session: ServerSession = field(init=False)
|
||||
"""Connection-scoped: the same instance reaches every request as `ctx.session`."""
|
||||
@cached_property
|
||||
def on_request(self) -> OnRequest:
|
||||
"""`_on_request` wrapped in `dispatch_middleware`, outermost-first.
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.init_options is None:
|
||||
self.init_options = self.server.create_initialization_options()
|
||||
self.connection = Connection(
|
||||
self.dispatcher, has_standalone_channel=self.has_standalone_channel, session_id=self.session_id
|
||||
)
|
||||
if self.stateless:
|
||||
# No handshake ever arrives on a stateless connection; born ready.
|
||||
self.connection.initialized.set()
|
||||
self.session = ServerSession(self.dispatcher, self.connection, stateless=self.stateless)
|
||||
|
||||
async def run(self, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None:
|
||||
"""Drive the dispatcher until the underlying channel closes.
|
||||
|
||||
Composes `dispatch_middleware` over `_on_request` and hands the result
|
||||
to `dispatcher.run()`. `task_status.started()` is forwarded so callers
|
||||
can `await tg.start(runner.run)` and resume once the dispatcher is
|
||||
ready to accept requests. Once the dispatcher exits,
|
||||
`connection.exit_stack` is unwound (shielded from outer cancellation,
|
||||
bounded by `_EXIT_STACK_CLOSE_TIMEOUT`) so any per-connection cleanup
|
||||
registered by handlers or middleware gets a chance to run without a
|
||||
misbehaving callback hanging shutdown indefinitely.
|
||||
"""
|
||||
try:
|
||||
await self.dispatcher.run(self._compose_on_request(), self._on_notify, task_status=task_status)
|
||||
finally:
|
||||
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as scope:
|
||||
try:
|
||||
await self.connection.exit_stack.aclose()
|
||||
except Exception:
|
||||
# Raising here would mask dispatcher.run()'s exception and
|
||||
# crash stdio servers on normal disconnect.
|
||||
logger.exception("connection exit_stack cleanup raised")
|
||||
if scope.cancelled_caught:
|
||||
logger.warning(
|
||||
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
|
||||
_EXIT_STACK_CLOSE_TIMEOUT,
|
||||
)
|
||||
|
||||
def _compose_on_request(self) -> OnRequest:
|
||||
"""Wrap `_on_request` in `dispatch_middleware`, outermost-first.
|
||||
|
||||
Dispatch-tier middleware sees raw `(dctx, method, params) -> dict`
|
||||
and wraps everything - initialize, METHOD_NOT_FOUND, validation
|
||||
failures included. `run()` calls this once and hands the result to
|
||||
`dispatcher.run()`.
|
||||
Dispatch-tier middleware sees raw `(dctx, method, params) -> dict` and
|
||||
wraps everything - initialize, METHOD_NOT_FOUND, validation failures
|
||||
included.
|
||||
"""
|
||||
return reduce(lambda h, mw: mw(h), reversed(self.dispatch_middleware), self._on_request)
|
||||
|
||||
@cached_property
|
||||
def on_notify(self) -> OnNotify:
|
||||
return self._on_notify
|
||||
|
||||
async def _on_request(
|
||||
self,
|
||||
dctx: DispatchContext[TransportContext],
|
||||
|
|
@ -243,7 +233,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
params: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
meta = _extract_meta(params)
|
||||
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
|
||||
version = self.connection.protocol_version
|
||||
ctx = self._make_context(dctx, meta, version)
|
||||
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
|
||||
|
||||
|
|
@ -257,7 +247,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
_methods.validate_client_request(method, version, params)
|
||||
except KeyError:
|
||||
raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method) from None
|
||||
# TODO(maxisbey): the 2026-07-28 spec drops the handshake; this branch and
|
||||
# TODO(L29): the 2026-07-28 spec drops the handshake; this branch and
|
||||
# the gate become a per-version legacy path then. Initialize runs inline
|
||||
# (read loop parked), so awaiting the peer anywhere on this path deadlocks.
|
||||
if method == "initialize":
|
||||
|
|
@ -284,7 +274,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
|
||||
call = self._compose_server_middleware(ctx, method, params, _inner)
|
||||
result = _dump_result(await call())
|
||||
# TODO: reject resultType values outside {"complete", "input_required"} unless the
|
||||
# TODO(L56): reject resultType values outside {"complete", "input_required"} unless the
|
||||
# corresponding extension is in this request's _meta clientCapabilities.extensions; the
|
||||
# explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively.
|
||||
if is_spec_method:
|
||||
|
|
@ -312,7 +302,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
params: Mapping[str, Any] | None,
|
||||
) -> None:
|
||||
meta = _extract_meta(params)
|
||||
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
|
||||
version = self.connection.protocol_version
|
||||
ctx = self._make_context(dctx, meta, version)
|
||||
|
||||
async def _inner() -> None:
|
||||
|
|
@ -373,7 +363,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
def _make_context(
|
||||
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
|
||||
) -> ServerRequestContext[LifespanT, Any]:
|
||||
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request
|
||||
# TODO(L54): remove for Context rework. Reads the SHTTP per-request
|
||||
# data off the raw `dctx.message_metadata` carrier; replace with the
|
||||
# per-transport context once that lands.
|
||||
md = dctx.message_metadata
|
||||
|
|
@ -383,8 +373,12 @@ class ServerRunner(Generic[LifespanT]):
|
|||
close_standalone_sse_stream = md.close_standalone_sse_stream
|
||||
else:
|
||||
request = close_sse_stream = close_standalone_sse_stream = None
|
||||
# Per-request session: `dctx` is the request-scoped channel (auto-threads
|
||||
# its own request_id on streamable HTTP); the standalone channel is read
|
||||
# off `connection.outbound`. `related_request_id` on the public API selects.
|
||||
session = ServerSession(dctx, self.connection)
|
||||
return ServerRequestContext(
|
||||
session=self.session,
|
||||
session=session,
|
||||
lifespan_context=self.lifespan_state,
|
||||
request_id=dctx.request_id,
|
||||
meta=meta,
|
||||
|
|
@ -405,8 +399,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
|
||||
"""Build the `initialize` result; state commits later in `_on_request`."""
|
||||
_, negotiated = self._negotiate_initialize(params)
|
||||
assert self.init_options is not None
|
||||
opts = self.init_options
|
||||
opts = self.init_options if self.init_options is not None else self.server.create_initialization_options()
|
||||
return InitializeResult(
|
||||
protocol_version=negotiated,
|
||||
capabilities=opts.capabilities,
|
||||
|
|
@ -420,3 +413,82 @@ class ServerRunner(Generic[LifespanT]):
|
|||
),
|
||||
instructions=opts.instructions,
|
||||
)
|
||||
|
||||
|
||||
async def serve_connection(
|
||||
server: Server[LifespanT],
|
||||
dispatcher: Dispatcher[Any],
|
||||
*,
|
||||
connection: Connection,
|
||||
lifespan_state: LifespanT,
|
||||
init_options: InitializationOptions | None = None,
|
||||
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
||||
) -> None:
|
||||
"""Drive ``dispatcher`` until the underlying channel closes.
|
||||
|
||||
The loop-mode driver: builds the kernel, hands `on_request`/`on_notify`
|
||||
to `dispatcher.run()`, and tears down `connection.exit_stack` (shielded)
|
||||
on the way out. The entry constructs the `Connection`; this only consumes
|
||||
it.
|
||||
"""
|
||||
runner = ServerRunner(server, connection, lifespan_state, init_options=init_options)
|
||||
try:
|
||||
await dispatcher.run(runner.on_request, runner.on_notify, task_status=task_status)
|
||||
finally:
|
||||
await aclose_shielded(connection)
|
||||
|
||||
|
||||
async def serve_loop(
|
||||
server: Server[LifespanT],
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
write_stream: WriteStream[SessionMessage],
|
||||
*,
|
||||
lifespan_state: LifespanT,
|
||||
session_id: str | None = None,
|
||||
init_options: InitializationOptions | None = None,
|
||||
raise_exceptions: bool = False,
|
||||
) -> None:
|
||||
"""Drive ``server`` in loop mode over a stream pair until the channel closes.
|
||||
|
||||
Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
|
||||
`serve_connection`, so loop-mode callers share one dispatcher-construction
|
||||
recipe (notably the `inline_methods={"initialize"}` rule). Callers that own
|
||||
a lifespan (the streamable-HTTP manager) pass it in; callers that don't
|
||||
(`Server.run` for stdio/memory) enter the lifespan and then call this.
|
||||
"""
|
||||
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
||||
read_stream,
|
||||
write_stream,
|
||||
raise_handler_exceptions=raise_exceptions,
|
||||
# Handle `initialize` inline so a client that pipelines it with the
|
||||
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
|
||||
# state instead of failing the init-gate.
|
||||
inline_methods=frozenset({"initialize"}),
|
||||
)
|
||||
connection = Connection.for_loop(dispatcher, session_id=session_id)
|
||||
await serve_connection(
|
||||
server, dispatcher, connection=connection, lifespan_state=lifespan_state, init_options=init_options
|
||||
)
|
||||
|
||||
|
||||
async def serve_one(
|
||||
server: Server[LifespanT],
|
||||
request: JSONRPCRequest,
|
||||
*,
|
||||
connection: Connection,
|
||||
dctx: DispatchContext[TransportContext],
|
||||
lifespan_state: LifespanT,
|
||||
) -> JSONRPCResponse | JSONRPCError:
|
||||
"""Handle a single ``request`` and return its JSON-RPC reply.
|
||||
|
||||
The single-exchange driver: builds the kernel, runs `on_request` once for
|
||||
`request` under `dctx`, maps the outcome to a `JSONRPCResponse` /
|
||||
`JSONRPCError` via `to_jsonrpc_response`, and tears down
|
||||
`connection.exit_stack` (shielded) on the way out. The entry constructs
|
||||
the (born-ready) `Connection` and the `dctx`; this only consumes them.
|
||||
"""
|
||||
runner = ServerRunner(server, connection, lifespan_state)
|
||||
try:
|
||||
return await to_jsonrpc_response(request.id, runner.on_request(dctx, request.method, request.params))
|
||||
finally:
|
||||
await aclose_shielded(connection)
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
"""`ServerSession`: server-to-client requests and notifications.
|
||||
|
||||
A thin proxy over `JSONRPCDispatcher` and `Connection`. One instance per
|
||||
client connection (built by `ServerRunner`). Handlers reach it as
|
||||
`ctx.session` and use the typed helpers (`create_message`, `elicit_form`,
|
||||
A per-request proxy built by the kernel for each inbound request. Exposes the
|
||||
request-scoped outbound channel and the connection's standalone channel.
|
||||
Handlers reach it as `ctx.session` and use the typed helpers (`elicit_form`,
|
||||
`send_log_message`, ...) to call back to the client.
|
||||
|
||||
The receive-loop, initialize handling, and per-request task isolation that
|
||||
used to live here are now owned by `JSONRPCDispatcher` and `ServerRunner`.
|
||||
"""
|
||||
|
||||
from typing import Any, TypeVar, cast, overload
|
||||
from typing import Any, TypeVar, overload
|
||||
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
from typing_extensions import deprecated
|
||||
|
|
@ -17,8 +14,8 @@ from typing_extensions import deprecated
|
|||
from mcp import types
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
|
||||
from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError, StatelessModeNotSupported
|
||||
from mcp.shared.dispatcher import CallOptions, Outbound, ProgressFnT
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning
|
||||
from mcp.shared.message import ServerMessageMetadata
|
||||
from mcp.types import methods as _methods
|
||||
|
||||
|
|
@ -28,35 +25,32 @@ ResultT = TypeVar("ResultT", bound=BaseModel)
|
|||
|
||||
|
||||
class ServerSession:
|
||||
"""Connection-scoped proxy for server-to-client requests and notifications.
|
||||
"""Per-request proxy for server-to-client requests and notifications.
|
||||
|
||||
`send_request` / `send_notification` model-dump their argument and forward
|
||||
to the dispatcher; the typed helpers below are unchanged from the previous
|
||||
implementation and only call those two methods.
|
||||
Built once per inbound request by the kernel's `_make_context`. Holds two
|
||||
`Outbound` channels: the request-scoped one (the per-request
|
||||
`DispatchContext`, which on streamable HTTP routes onto the originating
|
||||
POST's response stream) and the connection's standalone channel
|
||||
(`connection.outbound`). `related_request_id` on the public methods is the
|
||||
selector — present means request-scoped, absent means standalone — and
|
||||
never crosses the `Outbound` Protocol.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dispatcher: Dispatcher[Any],
|
||||
connection: Connection,
|
||||
*,
|
||||
stateless: bool = False,
|
||||
) -> None:
|
||||
self._dispatcher = dispatcher
|
||||
def __init__(self, request_outbound: Outbound, connection: Connection) -> None:
|
||||
self._request_outbound = request_outbound
|
||||
self._connection = connection
|
||||
self._stateless = stateless
|
||||
|
||||
@property
|
||||
def client_params(self) -> types.InitializeRequestParams | None:
|
||||
"""The client's `initialize` request params; `None` before initialization."""
|
||||
"""The client's `initialize` request params; `None` when no client info was supplied."""
|
||||
return self._connection.client_params
|
||||
|
||||
@property
|
||||
def protocol_version(self) -> str | None:
|
||||
"""The protocol version negotiated during `initialize`.
|
||||
def protocol_version(self) -> str:
|
||||
"""The protocol version this connection speaks.
|
||||
|
||||
`None` before initialization, and normally `None` on stateless
|
||||
connections. For the per-request value, read `ctx.protocol_version`.
|
||||
Populated at `Connection` construction and overwritten once the
|
||||
handshake commits on the loop path; never `None`.
|
||||
"""
|
||||
return self._connection.protocol_version
|
||||
|
||||
|
|
@ -70,46 +64,23 @@ class ServerSession:
|
|||
) -> ResultT:
|
||||
"""Send a typed server-to-client request and validate the result.
|
||||
|
||||
`metadata.related_request_id` (when supplied) routes the outgoing
|
||||
message onto the originating request's response stream over
|
||||
streamable HTTP; it is the only metadata field honored here.
|
||||
|
||||
Raises:
|
||||
MCPError: The peer responded with an error.
|
||||
NoBackChannelError: If there is no related request to ride on and
|
||||
the connection has no standalone channel (stateless HTTP), so
|
||||
a response could never arrive.
|
||||
NoBackChannelError: The connection has no back-channel for
|
||||
server-initiated requests (raised by the held `Outbound`).
|
||||
pydantic.ValidationError: The peer's result does not match `result_type`.
|
||||
"""
|
||||
related = metadata.related_request_id if metadata is not None else None
|
||||
channel = self._request_outbound if related is not None else self._connection.outbound
|
||||
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
opts: CallOptions = {}
|
||||
if request_read_timeout_seconds is not None:
|
||||
opts["timeout"] = request_read_timeout_seconds
|
||||
if progress_callback is not None:
|
||||
opts["on_progress"] = progress_callback
|
||||
related = metadata.related_request_id if metadata is not None else None
|
||||
if related is None and not self._connection.has_standalone_channel:
|
||||
# Fail fast instead of parking forever on a response that cannot
|
||||
# arrive; matches `Connection.send_raw_request`.
|
||||
raise NoBackChannelError(data["method"])
|
||||
# TODO: _related_request_id is not on the Dispatcher Protocol (and must not
|
||||
# be — it's transport-specific). The fix is to give `ctx.session` a per-request
|
||||
# Outbound (the DispatchContext, which threads its own request_id) alongside
|
||||
# the connection-level one, with `related_request_id` as the selector; that
|
||||
# belongs with the ServerSession/Context rework, not here.
|
||||
result = cast(
|
||||
"dict[str, Any]",
|
||||
await self._dispatcher.send_raw_request(
|
||||
data["method"],
|
||||
data.get("params"),
|
||||
opts or None,
|
||||
_related_request_id=related, # type: ignore[call-arg]
|
||||
),
|
||||
)
|
||||
# Literal fallback covers pre-handshake and stateless; matches runner.py.
|
||||
version = self.protocol_version or "2025-11-25"
|
||||
result = await channel.send_raw_request(data["method"], data.get("params"), opts or None)
|
||||
try:
|
||||
_methods.validate_client_result(request.method, version, result)
|
||||
_methods.validate_client_result(request.method, self.protocol_version, result)
|
||||
except KeyError:
|
||||
pass
|
||||
return result_type.model_validate(result, by_name=False)
|
||||
|
|
@ -120,8 +91,9 @@ class ServerSession:
|
|||
related_request_id: types.RequestId | None = None,
|
||||
) -> None:
|
||||
"""Send a typed server-to-client notification."""
|
||||
channel = self._request_outbound if related_request_id is not None else self._connection.outbound
|
||||
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg]
|
||||
await channel.notify(data["method"], data.get("params"))
|
||||
|
||||
def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
|
||||
"""Check if the client supports a specific capability."""
|
||||
|
|
@ -236,10 +208,9 @@ class ServerSession:
|
|||
Raises:
|
||||
MCPError: If tools are provided but client doesn't support them.
|
||||
ValueError: If tool_use or tool_result message structure is invalid.
|
||||
StatelessModeNotSupported: If called in stateless HTTP mode.
|
||||
NoBackChannelError: The connection has no back-channel for
|
||||
server-initiated requests.
|
||||
"""
|
||||
if self._stateless:
|
||||
raise StatelessModeNotSupported(method="sampling")
|
||||
client_caps = self.client_params.capabilities if self.client_params else None
|
||||
validate_sampling_tools(client_caps, tools, tool_choice)
|
||||
validate_tool_use_result_messages(messages)
|
||||
|
|
@ -274,9 +245,12 @@ class ServerSession:
|
|||
|
||||
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
||||
async def list_roots(self) -> types.ListRootsResult:
|
||||
"""Send a roots/list request."""
|
||||
if self._stateless:
|
||||
raise StatelessModeNotSupported(method="list_roots")
|
||||
"""Send a roots/list request.
|
||||
|
||||
Raises:
|
||||
NoBackChannelError: The connection has no back-channel for
|
||||
server-initiated requests.
|
||||
"""
|
||||
return await self.send_request(
|
||||
types.ListRootsRequest(),
|
||||
types.ListRootsResult,
|
||||
|
|
@ -321,10 +295,9 @@ class ServerSession:
|
|||
The client's response with form data.
|
||||
|
||||
Raises:
|
||||
StatelessModeNotSupported: If called in stateless HTTP mode.
|
||||
NoBackChannelError: The connection has no back-channel for
|
||||
server-initiated requests.
|
||||
"""
|
||||
if self._stateless:
|
||||
raise StatelessModeNotSupported(method="elicitation")
|
||||
return await self.send_request(
|
||||
types.ElicitRequest(
|
||||
params=types.ElicitRequestFormParams(
|
||||
|
|
@ -358,10 +331,9 @@ class ServerSession:
|
|||
The client's response indicating acceptance, decline, or cancellation.
|
||||
|
||||
Raises:
|
||||
StatelessModeNotSupported: If called in stateless HTTP mode.
|
||||
NoBackChannelError: The connection has no back-channel for
|
||||
server-initiated requests.
|
||||
"""
|
||||
if self._stateless:
|
||||
raise StatelessModeNotSupported(method="elicitation")
|
||||
return await self.send_request(
|
||||
types.ElicitRequest(
|
||||
params=types.ElicitRequestURLParams(
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
|
|||
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
||||
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
|
||||
from mcp.shared.version import is_version_at_least
|
||||
from mcp.types import (
|
||||
DEFAULT_NEGOTIATED_VERSION,
|
||||
INTERNAL_ERROR,
|
||||
|
|
@ -248,12 +248,11 @@ class StreamableHTTPServerTransport:
|
|||
|
||||
metadata = ServerMessageMetadata(
|
||||
request_context=request,
|
||||
protocol_version=protocol_version,
|
||||
close_sse_stream=close_stream_callback,
|
||||
close_standalone_sse_stream=close_standalone_stream_callback,
|
||||
)
|
||||
else:
|
||||
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
|
||||
metadata = ServerMessageMetadata(request_context=request)
|
||||
|
||||
return SessionMessage(message, metadata=metadata)
|
||||
|
||||
|
|
@ -507,10 +506,7 @@ class StreamableHTTPServerTransport:
|
|||
await response(scope, receive, send)
|
||||
|
||||
# Process the message after sending the response
|
||||
metadata = ServerMessageMetadata(
|
||||
request_context=request,
|
||||
protocol_version=request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION),
|
||||
)
|
||||
metadata = ServerMessageMetadata(request_context=request)
|
||||
session_message = SessionMessage(message, metadata=metadata)
|
||||
await writer.send(session_message)
|
||||
|
||||
|
|
@ -533,7 +529,7 @@ class StreamableHTTPServerTransport:
|
|||
|
||||
if self.is_json_response_enabled:
|
||||
# Process the message
|
||||
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
|
||||
metadata = ServerMessageMetadata(request_context=request)
|
||||
session_message = SessionMessage(message, metadata=metadata)
|
||||
await writer.send(session_message)
|
||||
try:
|
||||
|
|
@ -818,11 +814,10 @@ class StreamableHTTPServerTransport:
|
|||
await response(request.scope, request.receive, send)
|
||||
|
||||
async def _validate_request_headers(self, request: Request, send: Send) -> bool:
|
||||
if not await self._validate_session(request, send):
|
||||
return False
|
||||
if not await self._validate_protocol_version(request, send):
|
||||
return False
|
||||
return True
|
||||
# Protocol-version validation lives in the manager's era-routing: only
|
||||
# values in `SUPPORTED_PROTOCOL_VERSIONS` (or no header at all) reach
|
||||
# this transport, so the legacy version-gate is gone.
|
||||
return await self._validate_session(request, send)
|
||||
|
||||
async def _validate_session(self, request: Request, send: Send) -> bool:
|
||||
"""Validate the session ID in the request."""
|
||||
|
|
@ -853,28 +848,6 @@ class StreamableHTTPServerTransport:
|
|||
|
||||
return True
|
||||
|
||||
async def _validate_protocol_version(self, request: Request, send: Send) -> bool:
|
||||
"""Validate the protocol version header in the request."""
|
||||
# Get the protocol version from the request headers
|
||||
protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
|
||||
|
||||
# If no protocol version provided, assume default version
|
||||
if protocol_version is None:
|
||||
protocol_version = DEFAULT_NEGOTIATED_VERSION
|
||||
|
||||
# Check if the protocol version is supported
|
||||
if protocol_version not in SUPPORTED_PROTOCOL_VERSIONS:
|
||||
supported_versions = ", ".join(SUPPORTED_PROTOCOL_VERSIONS)
|
||||
response = self._create_error_response(
|
||||
f"Bad Request: Unsupported protocol version: {protocol_version}. "
|
||||
+ f"Supported versions: {supported_versions}",
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
await response(request.scope, request.receive, send)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _replay_events(self, last_event_id: str, request: Request, send: Send) -> None:
|
||||
"""Replays events that would have been sent after the specified event ID.
|
||||
|
||||
|
|
@ -894,7 +867,7 @@ class StreamableHTTPServerTransport:
|
|||
if self.mcp_session_id: # pragma: no branch
|
||||
headers[MCP_SESSION_ID_HEADER] = self.mcp_session_id
|
||||
|
||||
# Get protocol version from header (already validated in _validate_protocol_version)
|
||||
# The manager only routes supported (or absent) header values to this transport
|
||||
replay_protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION)
|
||||
|
||||
# Create SSE stream for replay
|
||||
|
|
|
|||
|
|
@ -16,15 +16,20 @@ from starlette.types import Receive, Scope, Send
|
|||
|
||||
from mcp.server._streamable_http_modern import handle_modern_request
|
||||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
|
||||
from mcp.server.connection import Connection
|
||||
from mcp.server.runner import serve_connection, serve_loop
|
||||
from mcp.server.streamable_http import (
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
MCP_SESSION_ID_HEADER,
|
||||
EventStore,
|
||||
StreamableHTTPServerTransport,
|
||||
)
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
|
@ -96,8 +101,9 @@ class StreamableHTTPSessionManager:
|
|||
# session must present the same credential.
|
||||
self._session_owners: dict[str, AuthorizationContext] = {}
|
||||
|
||||
# The task group will be set during lifespan
|
||||
# The task group and lifespan state are set during run()
|
||||
self._task_group = None
|
||||
self._lifespan_state: Any = None
|
||||
# Thread-safe tracking of run() calls
|
||||
self._run_lock = anyio.Lock()
|
||||
self._has_started = False
|
||||
|
|
@ -128,8 +134,11 @@ class StreamableHTTPSessionManager:
|
|||
)
|
||||
self._has_started = True
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Store the task group for later use
|
||||
async with self.app.lifespan(self.app) as lifespan_state, anyio.create_task_group() as tg:
|
||||
# Store for handle_request: lifespan is entered once for the
|
||||
# manager's lifetime, not per request (per-connection cleanup
|
||||
# belongs on `connection.exit_stack`).
|
||||
self._lifespan_state = lifespan_state
|
||||
self._task_group = tg
|
||||
logger.info("StreamableHTTP session manager started")
|
||||
try:
|
||||
|
|
@ -139,6 +148,7 @@ class StreamableHTTPSessionManager:
|
|||
# Cancel task group to stop all spawned tasks
|
||||
tg.cancel_scope.cancel()
|
||||
self._task_group = None
|
||||
self._lifespan_state = None
|
||||
# Clear any remaining server instances
|
||||
self._server_instances.clear()
|
||||
self._session_owners.clear()
|
||||
|
|
@ -152,20 +162,26 @@ class StreamableHTTPSessionManager:
|
|||
if self._task_group is None:
|
||||
raise RuntimeError("Task group is not initialized. Make sure to use run().")
|
||||
|
||||
# TODO: header-only routing for now; body-primary classification
|
||||
# (per SEP-2575) is a follow-up. 2025 paths below remain unchanged.
|
||||
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == b"mcp-protocol-version"), None)
|
||||
if pv in MODERN_PROTOCOL_VERSIONS:
|
||||
await handle_modern_request(self.app, self.security_settings, pv, scope, receive, send)
|
||||
# TODO(L49): header-only era-routing for now; body-primary classification
|
||||
# is a follow-up. The legacy paths below own only the known
|
||||
# initialize-handshake versions; anything else (including unknown
|
||||
# values) goes to the modern entry so the classifier can validate it
|
||||
# and return a structured rejection. 2025 paths below remain unchanged.
|
||||
header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii")
|
||||
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None)
|
||||
if pv is not None and pv not in SUPPORTED_PROTOCOL_VERSIONS:
|
||||
await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send)
|
||||
return
|
||||
|
||||
# Dispatch to the appropriate handler
|
||||
if self.stateless:
|
||||
await self._handle_stateless_request(scope, receive, send)
|
||||
await self._handle_stateless_request(pv, scope, receive, send)
|
||||
else:
|
||||
await self._handle_stateful_request(scope, receive, send)
|
||||
|
||||
async def _handle_stateless_request(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
async def _handle_stateless_request(
|
||||
self, protocol_version_hint: str | None, scope: Scope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
"""Process request in stateless mode - creating a new transport for each request."""
|
||||
logger.debug("Stateless mode: Creating new transport for this request")
|
||||
# No session ID needed in stateless mode
|
||||
|
|
@ -181,12 +197,29 @@ class StreamableHTTPSessionManager:
|
|||
async with http_transport.connect() as streams:
|
||||
read_stream, write_stream = streams
|
||||
task_status.started()
|
||||
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
|
||||
read_stream,
|
||||
write_stream,
|
||||
inline_methods=frozenset({"initialize"}),
|
||||
# No session ID means a server-to-client request can be
|
||||
# written to this POST's response stream, but the client's
|
||||
# reply has nowhere to land — `can_send_request=False`
|
||||
# makes the per-request channel raise `NoBackChannelError`
|
||||
# for requests while still allowing notifications.
|
||||
transport_builder=lambda _md: TransportContext(kind="streamable-http", can_send_request=False),
|
||||
)
|
||||
# Born-ready, no standalone channel: the legacy stateless path
|
||||
# never opens a GET stream and need not see `initialize`. The
|
||||
# header (or the spec's default-absent value) seeds
|
||||
# `ctx.protocol_version`.
|
||||
connection = Connection.from_envelope(
|
||||
protocol_version_hint if protocol_version_hint is not None else DEFAULT_NEGOTIATED_VERSION,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
try:
|
||||
await self.app.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.app.create_initialization_options(),
|
||||
stateless=True,
|
||||
await serve_connection(
|
||||
self.app, dispatcher, connection=connection, lifespan_state=self._lifespan_state
|
||||
)
|
||||
except Exception: # pragma: lax no cover
|
||||
logger.exception("Stateless session crashed")
|
||||
|
|
@ -263,7 +296,7 @@ class StreamableHTTPSessionManager:
|
|||
task_status.started()
|
||||
try:
|
||||
# Use a cancel scope for idle timeout — when the
|
||||
# deadline passes the scope cancels app.run() and
|
||||
# deadline passes the scope cancels the loop and
|
||||
# execution continues after the ``with`` block.
|
||||
# Incoming requests push the deadline forward.
|
||||
idle_scope = anyio.CancelScope()
|
||||
|
|
@ -272,11 +305,15 @@ class StreamableHTTPSessionManager:
|
|||
http_transport.idle_scope = idle_scope
|
||||
|
||||
with idle_scope:
|
||||
await self.app.run(
|
||||
# Drive via `serve_loop` (not `Server.run()`) so the
|
||||
# manager's already-entered lifespan is reused
|
||||
# rather than re-entered per session.
|
||||
await serve_loop(
|
||||
self.app,
|
||||
read_stream,
|
||||
write_stream,
|
||||
self.app.create_initialization_options(),
|
||||
stateless=False,
|
||||
lifespan_state=self._lifespan_state,
|
||||
session_id=http_transport.mcp_session_id,
|
||||
)
|
||||
|
||||
if idle_scope.cancelled_caught:
|
||||
|
|
@ -309,7 +346,7 @@ class StreamableHTTPSessionManager:
|
|||
await http_transport.handle_request(scope, receive, send)
|
||||
else:
|
||||
# Unknown or expired session ID - return 404 per MCP spec
|
||||
# TODO: Align error code once spec clarifies
|
||||
# TODO(L62): Align error code once spec clarifies
|
||||
# See: https://github.com/modelcontextprotocol/python-sdk/issues/1821
|
||||
logger.info(f"Rejected request with unknown or expired session ID: {request_mcp_session_id[:64]}")
|
||||
body = JSONRPCError(
|
||||
|
|
|
|||
|
|
@ -71,23 +71,6 @@ class NoBackChannelError(MCPError):
|
|||
self.method = method
|
||||
|
||||
|
||||
class StatelessModeNotSupported(RuntimeError):
|
||||
"""Raised when attempting to use a method that is not supported in stateless mode.
|
||||
|
||||
Server-to-client requests (sampling, elicitation, list_roots) are not
|
||||
supported in stateless HTTP mode because there is no persistent connection
|
||||
for bidirectional communication.
|
||||
"""
|
||||
|
||||
def __init__(self, method: str):
|
||||
super().__init__(
|
||||
f"Cannot use {method} in stateless HTTP mode. "
|
||||
"Stateless mode does not support server-to-client requests. "
|
||||
"Use stateful mode (stateless_http=False) to enable this feature."
|
||||
)
|
||||
self.method = method
|
||||
|
||||
|
||||
class UrlElicitationRequiredError(MCPError):
|
||||
"""Specialized error for when a tool requires URL mode elicitation(s) before proceeding.
|
||||
|
||||
|
|
|
|||
153
src/mcp/shared/inbound.py
Normal file
153
src/mcp/shared/inbound.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Inbound request classification for the modern per-request-envelope path.
|
||||
|
||||
Pure module: no I/O, no transport, no ``mcp.server`` imports. Runs the
|
||||
validation ladder against a decoded JSON-RPC body and returns either an
|
||||
:class:`InboundModernRoute` (every rung passed) or an
|
||||
:class:`InboundLadderRejection` (the first rung that failed). Callers map a
|
||||
rejection's ``code`` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP
|
||||
status.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
UnsupportedProtocolVersionErrorData,
|
||||
)
|
||||
from mcp.types.jsonrpc import (
|
||||
HEADER_MISMATCH,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
PARSE_ERROR,
|
||||
UNSUPPORTED_PROTOCOL_VERSION,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ERROR_CODE_HTTP_STATUS",
|
||||
"InboundLadderRejection",
|
||||
"InboundModernRoute",
|
||||
"MCP_PROTOCOL_VERSION_HEADER",
|
||||
"classify_inbound_request",
|
||||
]
|
||||
|
||||
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
|
||||
"""Canonical lowercase name of the HTTP header carrying the MCP protocol version."""
|
||||
|
||||
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
|
||||
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
|
||||
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(
|
||||
{
|
||||
PARSE_ERROR: 400,
|
||||
INVALID_REQUEST: 400,
|
||||
INVALID_PARAMS: 400,
|
||||
HEADER_MISMATCH: 400,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY: 400,
|
||||
UNSUPPORTED_PROTOCOL_VERSION: 400,
|
||||
METHOD_NOT_FOUND: 404,
|
||||
}
|
||||
)
|
||||
"""HTTP status to send for a JSON-RPC ``error.code``.
|
||||
|
||||
Consulted for classifier-origin *and* handler-origin errors, so one table
|
||||
decides the wire status regardless of where the error was produced. Unmapped
|
||||
codes fall back to the caller's default (typically 200).
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboundModernRoute:
|
||||
"""A modern-protocol request whose envelope passed every ladder rung.
|
||||
|
||||
``client_info`` and ``client_capabilities`` are the raw envelope values;
|
||||
the classifier checks presence only, not shape. Method existence is not a
|
||||
ladder rung — kernel dispatch is the single source of truth for that.
|
||||
"""
|
||||
|
||||
protocol_version: str
|
||||
client_info: Any
|
||||
client_capabilities: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InboundLadderRejection:
|
||||
"""The first ladder rung that failed, as JSON-RPC error fields."""
|
||||
|
||||
code: int
|
||||
message: str
|
||||
data: Any = None
|
||||
|
||||
|
||||
def classify_inbound_request(
|
||||
body: Mapping[str, Any],
|
||||
*,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
supported_modern_versions: Sequence[str] = MODERN_PROTOCOL_VERSIONS,
|
||||
) -> InboundModernRoute | InboundLadderRejection:
|
||||
"""Run the modern-protocol validation ladder over a decoded JSON-RPC body.
|
||||
|
||||
Rungs, in order — first failure wins:
|
||||
|
||||
1. ``params._meta`` is a mapping carrying every reserved envelope key
|
||||
(protocol version, client info, client capabilities) → else
|
||||
:data:`~mcp.types.jsonrpc.INVALID_PARAMS`.
|
||||
2. When ``headers`` is given, its ``MCP-Protocol-Version`` entry equals
|
||||
the envelope's protocol version → else
|
||||
:data:`~mcp.types.jsonrpc.HEADER_MISMATCH`. Runs before the
|
||||
supported-version rung so a client that disagrees with itself is told
|
||||
so, rather than told the body's version is unsupported.
|
||||
3. The envelope's protocol version is in ``supported_modern_versions`` →
|
||||
else :data:`~mcp.types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with
|
||||
``data = {"supported": [...], "requested": <value>}``.
|
||||
|
||||
Method existence is *not* a rung: kernel dispatch owns that decision so
|
||||
custom-registered methods route and the answer lives in one place.
|
||||
|
||||
Args:
|
||||
body: The decoded JSON-RPC request mapping. Envelope shape
|
||||
(``jsonrpc`` / ``id``) is not checked here.
|
||||
headers: Transport headers keyed by lowercase name, or ``None`` to
|
||||
skip the header rung (non-HTTP callers).
|
||||
supported_modern_versions: Modern protocol revisions this server
|
||||
accepts on the per-request-envelope path.
|
||||
"""
|
||||
try:
|
||||
meta = body["params"]["_meta"]
|
||||
protocol_version = meta[PROTOCOL_VERSION_META_KEY]
|
||||
client_info = meta[CLIENT_INFO_META_KEY]
|
||||
client_capabilities = meta[CLIENT_CAPABILITIES_META_KEY]
|
||||
except (KeyError, TypeError):
|
||||
return InboundLadderRejection(
|
||||
code=INVALID_PARAMS,
|
||||
message="params._meta must carry the reserved protocol-version, client-info and "
|
||||
"client-capabilities envelope keys",
|
||||
)
|
||||
|
||||
# TODO(L59): also validate Mcp-Method / Mcp-Name per SEP-2243 §Server Validation
|
||||
if headers is not None and headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:
|
||||
return InboundLadderRejection(
|
||||
code=HEADER_MISMATCH,
|
||||
message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version",
|
||||
)
|
||||
|
||||
if protocol_version not in supported_modern_versions:
|
||||
return InboundLadderRejection(
|
||||
code=UNSUPPORTED_PROTOCOL_VERSION,
|
||||
message="Unsupported protocol version",
|
||||
data=UnsupportedProtocolVersionErrorData(
|
||||
supported=list(supported_modern_versions), requested=protocol_version
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
return InboundModernRoute(
|
||||
protocol_version=protocol_version,
|
||||
client_info=client_info,
|
||||
client_capabilities=client_capabilities,
|
||||
)
|
||||
|
|
@ -49,7 +49,7 @@ from mcp.types import (
|
|||
RequestId,
|
||||
)
|
||||
|
||||
__all__ = ["JSONRPCDispatcher"]
|
||||
__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -67,6 +67,23 @@ PeerCancelMode = Literal["interrupt", "signal"]
|
|||
the handler's scope; `"signal"` only sets `ctx.cancel_requested`."""
|
||||
|
||||
|
||||
def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
|
||||
"""Map a handler-raised exception to its wire `ErrorData`.
|
||||
|
||||
The two rungs every dispatcher shares: an `MCPError` carries its own
|
||||
`ErrorData`; a pydantic `ValidationError` is the spec's INVALID_PARAMS
|
||||
with empty ``data`` (no pydantic text on the wire). Returns ``None`` for
|
||||
any other exception so each caller applies its own catch-all -
|
||||
`JSONRPCDispatcher` currently pins ``code=0`` for v1 compat,
|
||||
`to_jsonrpc_response` uses `INTERNAL_ERROR`.
|
||||
"""
|
||||
if isinstance(exc, MCPError):
|
||||
return exc.error
|
||||
if isinstance(exc, ValidationError):
|
||||
return ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_id(request_id: RequestId) -> RequestId:
|
||||
"""Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK)."""
|
||||
if isinstance(request_id, str):
|
||||
|
|
@ -667,7 +684,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
|||
# anyio absorbs the scope's own cancel at __exit__, and
|
||||
# `cancelled_caught` (unlike `cancel_called`) guarantees the
|
||||
# result write above did not happen - no double response.
|
||||
# TODO(maxisbey): spec says SHOULD NOT respond after cancel;
|
||||
# TODO(L38): spec says SHOULD NOT respond after cancel;
|
||||
# the existing server always has, so match that for now.
|
||||
answer_write_started = True
|
||||
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
|
||||
|
|
@ -684,21 +701,17 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
|||
describe=f"shutdown error response for request {req.id!r}",
|
||||
)
|
||||
raise
|
||||
except MCPError as e:
|
||||
await self._write_error(req.id, e.error)
|
||||
except ValidationError:
|
||||
# TODO(maxisbey): data="" pins existing-server compat (no pydantic
|
||||
# text on the wire); revisit per the suite's divergence entry.
|
||||
await self._write_error(
|
||||
req.id, ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("handler for %r raised", req.method)
|
||||
# TODO(maxisbey): code=0 pins existing-server compat; JSON-RPC says
|
||||
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
|
||||
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
|
||||
if self._raise_handler_exceptions:
|
||||
raise
|
||||
error = handler_exception_to_error_data(e)
|
||||
if error is not None:
|
||||
await self._write_error(req.id, error)
|
||||
else:
|
||||
logger.exception("handler for %r raised", req.method)
|
||||
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
|
||||
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
|
||||
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
|
||||
if self._raise_handler_exceptions:
|
||||
raise
|
||||
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
|
||||
|
||||
def _allocate_id(self) -> int:
|
||||
|
|
|
|||
Loading…
Reference in a new issue