494 lines
21 KiB
Python
494 lines
21 KiB
Python
"""`Connection` - per-client connection state and the standalone outbound channel.
|
|
|
|
Always present on `Context` (never `None`), even in stateless deployments.
|
|
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
|
|
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
|
|
from typing import Any, Final, TypeVar, get_args, overload
|
|
|
|
import anyio
|
|
from mcp_types import (
|
|
LOG_LEVEL_META_KEY,
|
|
ClientCapabilities,
|
|
CreateMessageRequest,
|
|
CreateMessageResult,
|
|
ElicitRequest,
|
|
ElicitResult,
|
|
EmptyResult,
|
|
Implementation,
|
|
InitializeRequestParams,
|
|
ListRootsRequest,
|
|
ListRootsResult,
|
|
LoggingLevel,
|
|
PingRequest,
|
|
Request,
|
|
)
|
|
from mcp_types import methods as _methods
|
|
from mcp_types.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
|
|
from pydantic import BaseModel, ValidationError
|
|
from typing_extensions import deprecated
|
|
|
|
from mcp.shared.dispatcher import CallOptions, Outbound
|
|
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
|
|
from mcp.shared.peer import Meta, dump_params
|
|
from mcp.shared.subscriptions import LISTEN_STREAM_METHODS
|
|
|
|
__all__ = ["Connection"]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# `Connection.log`'s `logger` parameter (public API, the spec's logger-name
|
|
# field) shadows the module logger inside that method; this alias keeps the
|
|
# module logger reachable there.
|
|
_logger = logger
|
|
|
|
_LOG_LEVELS: Final[tuple[LoggingLevel, ...]] = get_args(LoggingLevel)
|
|
"""Severity-ascending, from the `LoggingLevel` literal's declaration order (the
|
|
RFC 5424 scale) - the literal is the single source of the ordering."""
|
|
|
|
_ALL_LOG_LEVELS: Final[frozenset[LoggingLevel]] = frozenset(_LOG_LEVELS)
|
|
|
|
|
|
def allowed_log_levels(protocol_version: str, meta: Mapping[str, Any] | None) -> frozenset[LoggingLevel]:
|
|
"""The `notifications/message` levels deliverable for one inbound request.
|
|
|
|
2026-07-28+ makes log delivery a per-request opt-in (server/utilities/
|
|
logging): the client sets the reserved `io.modelcontextprotocol/logLevel`
|
|
`_meta` key, absent means no levels - the server MUST NOT send - and
|
|
present means that level and above. An unrecognized value reads as absent;
|
|
spec methods already reject a malformed value at surface validation
|
|
before any handler runs, so that arm only serves custom methods, where
|
|
dropping is the safe direction. Connection-scoped emitters pass
|
|
`meta=None`: `logging/setLevel` is gone at 2026 and log delivery is
|
|
request-scoped only, so they deliver nothing. Handshake versions keep
|
|
their `logging/setLevel`-era semantics: every level may be sent, filtering
|
|
is the application's `logging/setLevel` handler's job as before.
|
|
"""
|
|
if protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
|
return _ALL_LOG_LEVELS
|
|
requested = (meta or {}).get(LOG_LEVEL_META_KEY)
|
|
if requested not in _LOG_LEVELS:
|
|
return frozenset()
|
|
return frozenset(_LOG_LEVELS[_LOG_LEVELS.index(requested) :])
|
|
|
|
|
|
ResultT = TypeVar("ResultT", bound=BaseModel)
|
|
|
|
# Result types for the spec's server-to-client request set, used by
|
|
# `Connection.send_request` to infer the result type. If the spec's request
|
|
# set grows substantially, consider declaring the result mapping on the
|
|
# request types themselves (a `__mcp_result__` ClassVar read via a structural
|
|
# protocol) so this table and the overload ladder don't need maintaining.
|
|
_RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = {
|
|
CreateMessageRequest: CreateMessageResult,
|
|
ElicitRequest: ElicitResult,
|
|
ListRootsRequest: ListRootsResult,
|
|
PingRequest: EmptyResult,
|
|
}
|
|
|
|
|
|
_ModelT = TypeVar("_ModelT", bound=BaseModel)
|
|
|
|
|
|
def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
|
|
"""Validate a raw envelope value into a typed model.
|
|
|
|
A missing, null or mis-shaped value falls through to `ValidationError`
|
|
and is treated as not supplied so the request still routes. Spec methods
|
|
are separately re-validated by the kernel's per-version params surface,
|
|
which types the reserved `_meta` keys strictly.
|
|
"""
|
|
try:
|
|
return model.model_validate(raw, by_name=False)
|
|
except ValidationError:
|
|
return None
|
|
|
|
|
|
def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None:
|
|
if not meta:
|
|
return payload
|
|
out = dict(payload or {})
|
|
out["_meta"] = meta
|
|
return out
|
|
|
|
|
|
class _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, opts: CallOptions | None = None) -> None:
|
|
logger.debug("dropped %s: no standalone channel", method)
|
|
|
|
|
|
_NO_CHANNEL = _NoChannelOutbound()
|
|
|
|
|
|
class NotifyOnlyOutbound(_NoChannelOutbound):
|
|
"""Connection-scoped `Outbound` that forwards notifications and refuses requests.
|
|
|
|
Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections
|
|
over duplex stream transports: the pipe is real, so server notifications
|
|
ride it, but the modern protocol forbids server-initiated JSON-RPC
|
|
requests, so `send_raw_request` (inherited) refuses by construction.
|
|
|
|
Change notifications (`notifications/*/list_changed`,
|
|
`notifications/resources/updated`) are dropped with a debug log: at this
|
|
era they reach a client only through a `subscriptions/listen` stream it
|
|
opened, so a bare copy on the shared channel would be an unrequested
|
|
notification. Publish them on the server's `SubscriptionBus` instead.
|
|
"""
|
|
|
|
def __init__(self, outbound: Outbound) -> None:
|
|
self._outbound = outbound
|
|
|
|
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
|
# At the 2026-07-28 era these are `subscriptions/listen` stream goods
|
|
# only: the spec forbids sending a change notification a subscription
|
|
# did not request, and listen streams deliver them (stamped, filtered)
|
|
# via the request-scoped outbound, never this connection-scoped channel.
|
|
if method in LISTEN_STREAM_METHODS:
|
|
logger.debug("dropped %s: delivered via subscriptions/listen at this era", method)
|
|
return
|
|
await self._outbound.notify(method, params, opts)
|
|
|
|
|
|
class Connection:
|
|
"""Per-client connection state and standalone-stream `Outbound`.
|
|
|
|
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.
|
|
"""
|
|
|
|
outbound: Outbound
|
|
"""The connection-scoped channel for server-initiated messages."""
|
|
|
|
session_id: str | None
|
|
|
|
client_capabilities: ClientCapabilities | None
|
|
"""The capabilities the peer declared: the handshake's on the loop path,
|
|
the request envelope's on the modern path. `None` when none were declared.
|
|
Kept in lockstep with `client_params` by its setter, and settable on its
|
|
own for the modern envelope, where capabilities are required but client
|
|
info is optional (spec PR #3002) - capability checks must not depend on the
|
|
peer having identified itself."""
|
|
|
|
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 connections built via `from_envelope`."""
|
|
|
|
state: dict[str, Any]
|
|
"""Per-connection scratch state; persists across requests on this connection."""
|
|
|
|
exit_stack: AsyncExitStack
|
|
"""Per-connection teardown, unwound LIFO (shielded) when the connection
|
|
closes. Push cleanup from handlers or middleware; exceptions are logged
|
|
and swallowed."""
|
|
|
|
def __init__(
|
|
self,
|
|
outbound: Outbound,
|
|
*,
|
|
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_capabilities = None
|
|
self.client_params = client_params
|
|
self.initialized = anyio.Event()
|
|
self.state = {}
|
|
self.exit_stack = AsyncExitStack()
|
|
|
|
@property
|
|
def client_params(self) -> InitializeRequestParams | None:
|
|
"""The full `initialize` request params, or the equivalent built from the
|
|
2026-era envelope. `None` when no client info was supplied."""
|
|
return self._client_params
|
|
|
|
@client_params.setter
|
|
def client_params(self, value: InitializeRequestParams | None) -> None:
|
|
# Assignment is the sync point: recording full client params (the
|
|
# handshake commit, or a modern envelope carrying client info) also
|
|
# records the capabilities fact, so the two can never drift. Clearing
|
|
# to `None` leaves `client_capabilities` alone - the modern envelope
|
|
# declares capabilities without client info.
|
|
self._client_params = value
|
|
if value is not None:
|
|
self.client_capabilities = value.capabilities
|
|
|
|
@classmethod
|
|
def from_envelope(
|
|
cls,
|
|
protocol_version: str,
|
|
client_info: Any,
|
|
client_capabilities: Any,
|
|
*,
|
|
outbound: Outbound = _NO_CHANNEL,
|
|
) -> Connection:
|
|
"""A born-ready connection populated from a request's `_meta` envelope.
|
|
|
|
`protocol_version` must be an already-validated version string - the
|
|
inbound classification ladder owns rejecting non-string or unsupported
|
|
values. `client_info` and `client_capabilities` are the raw envelope
|
|
values: this constructor owns turning them into connection identity,
|
|
identically on every modern entry, so a mis-shaped value degrades to
|
|
not-supplied rather than failing the request. `initialized` is set,
|
|
well-formed capabilities are recorded as `client_capabilities` (client
|
|
info is optional per spec PR #3002, so capability checks never depend on
|
|
it), and the full `client_params` is additionally synthesized when
|
|
client info was supplied too. `outbound` defaults to the no-channel
|
|
sentinel for the single-exchange HTTP path; duplex modern transports
|
|
(e.g. stdio) pass a notify-only wrapper around the dispatcher so
|
|
server notifications ride the pipe while server-initiated requests
|
|
stay refused.
|
|
"""
|
|
info = _typed(Implementation, client_info)
|
|
capabilities = _typed(ClientCapabilities, client_capabilities)
|
|
client_params = None
|
|
if info is not None and capabilities is not None:
|
|
client_params = InitializeRequestParams(
|
|
protocol_version=protocol_version,
|
|
capabilities=capabilities,
|
|
client_info=info,
|
|
)
|
|
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
|
|
connection.client_capabilities = capabilities
|
|
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_HANDSHAKE_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_HANDSHAKE_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.
|
|
|
|
Channel presence, not request permission: a modern (2026-07-28+)
|
|
duplex connection has a channel that carries notifications while
|
|
`send_raw_request` still refuses, because the protocol forbids
|
|
server-initiated requests."""
|
|
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 (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(
|
|
self,
|
|
method: str,
|
|
params: Mapping[str, Any] | None,
|
|
opts: CallOptions | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Send a raw request on the standalone stream.
|
|
|
|
Low-level `Outbound` channel. Prefer the typed `send_request` or the
|
|
convenience methods below; use this directly only for off-spec
|
|
messages. `opts` carries per-call `timeout` / `on_progress` /
|
|
resumption hints; see `CallOptions`.
|
|
|
|
Raises:
|
|
MCPError: The peer responded with an error.
|
|
NoBackChannelError: no back-channel for server-initiated requests -
|
|
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
|
|
connection, where the protocol forbids them.
|
|
"""
|
|
return await self.outbound.send_raw_request(method, params, opts)
|
|
|
|
@overload
|
|
async def send_request(
|
|
self, req: CreateMessageRequest, *, opts: CallOptions | None = None
|
|
) -> CreateMessageResult: ...
|
|
@overload
|
|
async def send_request(self, req: ElicitRequest, *, opts: CallOptions | None = None) -> ElicitResult: ...
|
|
@overload
|
|
async def send_request(self, req: ListRootsRequest, *, opts: CallOptions | None = None) -> ListRootsResult: ...
|
|
@overload
|
|
async def send_request(self, req: PingRequest, *, opts: CallOptions | None = None) -> EmptyResult: ...
|
|
@overload
|
|
async def send_request(
|
|
self, req: Request[Any, Any], *, result_type: type[ResultT], opts: CallOptions | None = None
|
|
) -> ResultT: ...
|
|
async def send_request(
|
|
self,
|
|
req: Request[Any, Any],
|
|
*,
|
|
result_type: type[BaseModel] | None = None,
|
|
opts: CallOptions | None = None,
|
|
) -> BaseModel:
|
|
"""Send a typed server-to-client request and return its typed result.
|
|
|
|
For spec request types the result type is inferred. For custom requests
|
|
pass `result_type=` explicitly.
|
|
|
|
Raises:
|
|
MCPError: The peer responded with an error.
|
|
NoBackChannelError: No back-channel for server-initiated requests.
|
|
pydantic.ValidationError: The peer's result does not match the expected result type.
|
|
KeyError: `result_type` omitted for a non-spec request type.
|
|
"""
|
|
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
|
|
if req.method in _methods.MONOLITH_REQUESTS:
|
|
try:
|
|
_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)]
|
|
return cls.model_validate(raw, by_name=False)
|
|
|
|
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
|
|
"""Send a best-effort notification on the standalone stream.
|
|
|
|
Never raises. If there's no standalone channel or the stream is broken,
|
|
the notification is dropped and debug-logged.
|
|
"""
|
|
try:
|
|
await self.outbound.notify(method, params, opts)
|
|
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
|
logger.debug("dropped %s: standalone stream closed", method)
|
|
|
|
async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = None) -> None:
|
|
"""Send a `ping` request on the standalone stream.
|
|
|
|
Raises:
|
|
MCPError: The peer responded with an error.
|
|
NoBackChannelError: no back-channel for server-initiated requests -
|
|
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
|
|
connection, where the protocol forbids them.
|
|
"""
|
|
await self.send_raw_request("ping", dump_params(None, meta), opts)
|
|
|
|
@deprecated("The logging capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
|
|
async def log(self, level: LoggingLevel, data: Any, logger: str | None = None, *, meta: Meta | None = None) -> None:
|
|
"""Send a `notifications/message` log entry on the standalone stream. Best-effort.
|
|
|
|
On 2026-07-28+ connections this never sends: log delivery is a
|
|
per-request opt-in that rides the requesting stream (`ctx.log`,
|
|
`ctx.session.send_log_message`), and the standalone stream is
|
|
forbidden from carrying `notifications/message`, so the entry is
|
|
debug-logged and dropped.
|
|
"""
|
|
if level not in allowed_log_levels(self.protocol_version, None):
|
|
_logger.debug("dropped notifications/message: no connection-wide log delivery at %s", self.protocol_version)
|
|
return
|
|
params: dict[str, Any] = {"level": level, "data": data}
|
|
if logger is not None:
|
|
params["logger"] = logger
|
|
await self.notify("notifications/message", _notification_params(params, meta))
|
|
|
|
async def send_tool_list_changed(self, *, meta: Meta | None = None) -> None:
|
|
await self.notify("notifications/tools/list_changed", _notification_params(None, meta))
|
|
|
|
async def send_prompt_list_changed(self, *, meta: Meta | None = None) -> None:
|
|
await self.notify("notifications/prompts/list_changed", _notification_params(None, meta))
|
|
|
|
async def send_resource_list_changed(self, *, meta: Meta | None = None) -> None:
|
|
await self.notify("notifications/resources/list_changed", _notification_params(None, meta))
|
|
|
|
async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) -> None:
|
|
await self.notify("notifications/resources/updated", _notification_params({"uri": uri}, meta))
|
|
|
|
def check_capability(self, capability: ClientCapabilities) -> bool:
|
|
"""Return whether the connected client declared the given capability.
|
|
|
|
Returns `False` when no capabilities have been recorded.
|
|
"""
|
|
# TODO(L53): redesign - mirrors v1 ServerSession.check_client_capability
|
|
# verbatim for parity.
|
|
if self.client_capabilities is None:
|
|
return False
|
|
have = self.client_capabilities
|
|
if capability.roots is not None:
|
|
if have.roots is None:
|
|
return False
|
|
if capability.roots.list_changed and not have.roots.list_changed:
|
|
return False
|
|
if capability.sampling is not None:
|
|
if have.sampling is None:
|
|
return False
|
|
if capability.sampling.context is not None and have.sampling.context is None:
|
|
return False
|
|
if capability.sampling.tools is not None and have.sampling.tools is None:
|
|
return False
|
|
if capability.elicitation is not None and have.elicitation is None:
|
|
return False
|
|
if capability.experimental is not None:
|
|
if have.experimental is None:
|
|
return False
|
|
for k, v in capability.experimental.items():
|
|
if k not in have.experimental or have.experimental[k] != v:
|
|
return False
|
|
if capability.extensions is not None:
|
|
# SEP-2133: an extension is supported when the client declares its
|
|
# identifier. Settings are negotiated per-extension (the client may
|
|
# advertise more than the server asks for), so presence - not value
|
|
# equality - is the meaningful check.
|
|
if have.extensions is None:
|
|
return False
|
|
for identifier in capability.extensions:
|
|
if identifier not in have.extensions:
|
|
return False
|
|
return True
|