195 lines
8.3 KiB
Python
195 lines
8.3 KiB
Python
import logging
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any, Generic, Protocol
|
|
|
|
from mcp_types import LoggingLevel, RequestId, RequestParamsMeta
|
|
from pydantic import BaseModel
|
|
from typing_extensions import TypeVar, deprecated
|
|
|
|
from mcp.server.connection import Connection, allowed_log_levels
|
|
from mcp.server.session import ServerSession
|
|
from mcp.shared.context import BaseContext
|
|
from mcp.shared.dispatcher import DispatchContext
|
|
from mcp.shared.exceptions import MCPDeprecationWarning
|
|
from mcp.shared.message import CloseSSEStreamCallback
|
|
from mcp.shared.peer import Meta
|
|
from mcp.shared.transport_context import TransportContext
|
|
|
|
logger = logging.getLogger(__name__)
|
|
# `Context.log`'s `logger` parameter (public API, the spec's logger-name
|
|
# field) shadows the module logger inside that method; this alias keeps it
|
|
# reachable there.
|
|
_logger = logger
|
|
|
|
# Invariant: parametrizes a mutable dataclass field; dict default matches the default lifespan.
|
|
LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any])
|
|
RequestT = TypeVar("RequestT", default=Any)
|
|
|
|
|
|
@dataclass(kw_only=True)
|
|
class ServerRequestContext(Generic[LifespanContextT, RequestT]):
|
|
"""Per-request context handed to lowlevel request and notification handlers.
|
|
|
|
Built by `ServerRunner._make_context` for each inbound message. Carries the
|
|
connection-scoped `ServerSession` (server-to-client requests and
|
|
notifications), per-request metadata, and any per-message data the
|
|
transport attached (the HTTP request, SSE stream-close callbacks).
|
|
"""
|
|
|
|
session: ServerSession
|
|
lifespan_context: LifespanContextT
|
|
protocol_version: str
|
|
method: str
|
|
params: Mapping[str, Any] | None = None
|
|
request_id: RequestId | None = None
|
|
meta: RequestParamsMeta | None = None
|
|
request: RequestT | None = None
|
|
close_sse_stream: CloseSSEStreamCallback | None = None
|
|
close_standalone_sse_stream: CloseSSEStreamCallback | None = None
|
|
|
|
|
|
# Covariant: `lifespan` is exposed read-only, so a `Context[AppState]` passes as `Context[object]`.
|
|
LifespanT_co = TypeVar("LifespanT_co", default=Any, covariant=True)
|
|
|
|
|
|
class Context(BaseContext[TransportContext], Generic[LifespanT_co]):
|
|
"""Server-side per-request context.
|
|
|
|
Extends `BaseContext` (transport metadata, the raw back-channel, progress
|
|
reporting) with `lifespan`, `connection`, and request-scoped `log`.
|
|
|
|
Not currently constructed by `ServerRunner`, which hands handlers a
|
|
`ServerRequestContext` instead.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
dctx: DispatchContext[TransportContext],
|
|
*,
|
|
lifespan: LifespanT_co,
|
|
connection: Connection,
|
|
meta: RequestParamsMeta | None = None,
|
|
) -> None:
|
|
super().__init__(dctx, meta=meta)
|
|
self._lifespan = lifespan
|
|
self._connection = connection
|
|
# Same per-request log gate as `ServerSession`: fixed at construction
|
|
# from this request's `_meta` log-level opt-in and the connection's era.
|
|
self._allowed_log_levels = allowed_log_levels(connection.protocol_version, meta)
|
|
|
|
@property
|
|
def lifespan(self) -> LifespanT_co:
|
|
"""The server-wide lifespan output (what `Server(..., lifespan=...)` yielded)."""
|
|
return self._lifespan
|
|
|
|
@property
|
|
def connection(self) -> Connection:
|
|
"""The per-client `Connection` for this request's connection."""
|
|
return self._connection
|
|
|
|
@property
|
|
def session_id(self) -> str | None:
|
|
"""The transport's session id for this connection, when one exists.
|
|
|
|
Convenience for `ctx.connection.session_id`. `None` on stdio and
|
|
stateless HTTP.
|
|
"""
|
|
return self._connection.session_id
|
|
|
|
@property
|
|
def headers(self) -> Mapping[str, str] | None:
|
|
"""Request headers carried by this message, when the transport has them.
|
|
|
|
Convenience for `ctx.transport.headers`. `None` on stdio.
|
|
"""
|
|
return self.transport.headers
|
|
|
|
@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 request-scoped `notifications/message` log entry.
|
|
|
|
Uses this request's back-channel (so the entry rides the request's SSE
|
|
stream in streamable HTTP), not the standalone stream - use
|
|
`ctx.connection.log(...)` for that.
|
|
|
|
On 2026-07-28+ delivery is a per-request opt-in: nothing is sent
|
|
unless this request's `_meta` carried the reserved log-level key, and
|
|
entries below the requested level are dropped (debug-logged).
|
|
Handshake versions send unconditionally, as before.
|
|
"""
|
|
if level not in self._allowed_log_levels:
|
|
_logger.debug("dropped notifications/message at %r: not opted in at that level on this request", level)
|
|
return
|
|
params: dict[str, Any] = {"level": level, "data": data}
|
|
if logger is not None:
|
|
params["logger"] = logger
|
|
if meta:
|
|
params["_meta"] = meta
|
|
await self.notify("notifications/message", params)
|
|
|
|
|
|
HandlerResult = BaseModel | dict[str, Any] | None
|
|
"""What a request handler (or middleware) may return. `ServerRunner` serializes
|
|
all three to a result dict."""
|
|
|
|
CallNext = Callable[["ServerRequestContext[Any, Any]"], Awaitable[HandlerResult]]
|
|
"""Invokes the rest of the chain with the given context. What a context
|
|
rewrite (`dataclasses.replace(ctx, ...)`) can alter depends on the tier:
|
|
`ServerMiddleware` runs before params validation, so its rewrites change what
|
|
the handler is invoked with; an `Extension` interceptor runs after, so its
|
|
rewrites change only what the handler observes on `ctx`."""
|
|
|
|
_MwLifespanT = TypeVar("_MwLifespanT")
|
|
|
|
|
|
class ServerMiddleware(Protocol[_MwLifespanT]):
|
|
"""Context-tier middleware: `(ctx, call_next) -> result`.
|
|
|
|
Runs at the top of `ServerRunner._on_request` / `_on_notify` after `ctx`
|
|
is built but before any validation, lookup, or handshake. Wraps every
|
|
inbound request and notification: `initialize`, the pre-init gate,
|
|
`METHOD_NOT_FOUND`, params validation, the handler call, and
|
|
`notifications/initialized` all run inside `call_next(ctx)`.
|
|
`notifications/cancelled` is observed too; the dispatcher applies the
|
|
cancellation itself, then forwards the notification. A request-side
|
|
failure reaches the middleware as a raised `MCPError` (or
|
|
`ValidationError` for malformed params) so observation/logging middleware
|
|
can record it. Listed outermost-first on `Server.middleware`.
|
|
|
|
The method and the raw inbound params are `ctx.method` and `ctx.params` (no
|
|
model validation has happened yet). To rewrite either before the handler
|
|
runs, pass an adjusted context: `await call_next(replace(ctx, params=...))`.
|
|
`ctx.request_id is None` distinguishes a notification from a request. For
|
|
notifications `call_next(ctx)` returns `None` (a dropped or unhandled
|
|
notification also returns `None`) and the middleware's own return value is
|
|
discarded.
|
|
|
|
!!! warning
|
|
`initialize` is handled inline - the dispatcher does not read
|
|
further inbound messages until the middleware chain returns. Awaiting a
|
|
server-to-client request (`ctx.session.send_request`, `send_ping`, ...)
|
|
while handling `initialize` therefore deadlocks the connection: the
|
|
response can never be dequeued. Send-and-forget notifications are safe.
|
|
`initialize` is observed but not rewritable: the post-chain handshake
|
|
commit reads the wire params, so to veto the handshake raise *before*
|
|
`call_next()`.
|
|
|
|
`Server[L].middleware` holds `ServerMiddleware[L]`, so an app-specific
|
|
middleware sees `ctx.lifespan_context: L`. While the context is the
|
|
mutable `ServerRequestContext` dataclass it is invariant in `L`, so a
|
|
reusable middleware should be typed `ServerMiddleware[Any]` to register on
|
|
any `Server[L]`.
|
|
"""
|
|
|
|
# TODO(maxisbey): once `_make_context` returns the (covariant) `Context[L]`
|
|
# again, restore `_MwLifespanT` to `contravariant=True` and retype `ctx`
|
|
# below to `Context[_MwLifespanT]` so reusable middleware can be
|
|
# `ServerMiddleware[object]` instead of `ServerMiddleware[Any]`.
|
|
|
|
async def __call__(
|
|
self,
|
|
ctx: ServerRequestContext[_MwLifespanT, Any],
|
|
call_next: CallNext,
|
|
) -> HandlerResult: ...
|