Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)

This commit is contained in:
Max 2026-06-25 16:09:23 +02:00 • committed by GitHub
commit f226d00d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2533 additions and 993 deletions

View file

@ -14,7 +14,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, TypeVar
@ -30,9 +30,11 @@ from mcp.server.transport_security import TransportSecurityMiddleware, Transport
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.jsonrpc_dispatcher import handler_exception_to_error_data
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.types import (
INTERNAL_ERROR,
INVALID_REQUEST,
PARSE_ERROR,
ClientCapabilities,
@ -77,7 +79,7 @@ class _SingleExchangeDispatchContext:
) -> dict[str, Any]:
raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
# TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands.
return None
@ -99,6 +101,27 @@ def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None:
return None
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 modern HTTP entry, composed around
`serve_one`. `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)
async def _write(
msg: JSONRPCResponse | JSONRPCError,
scope: Scope,
@ -193,5 +216,7 @@ async def handle_modern_request(
request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=request),
)
msg = await serve_one(app, req, connection=connection, dctx=dctx, lifespan_state=lifespan_state)
msg = await _to_jsonrpc_response(
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
)
await _write(msg, scope, receive, send)

View file

@ -17,8 +17,8 @@ from mcp.server.auth.handlers.token import TokenHandler
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
def validate_issuer_url(url: AnyHttpUrl):

View file

@ -31,8 +31,8 @@ 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.version import LATEST_HANDSHAKE_VERSION
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
CreateMessageRequest,
CreateMessageResult,
@ -93,7 +93,7 @@ class _NoChannelOutbound:
) -> dict[str, Any]:
raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
logger.debug("dropped %s: no standalone channel", method)
@ -192,12 +192,12 @@ class Connection:
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 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_PROTOCOL_VERSION,
protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_HANDSHAKE_VERSION,
session_id=session_id,
)
@ -275,14 +275,14 @@ class Connection:
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
return cls.model_validate(raw, by_name=False)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
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)
await self.outbound.notify(method, params, opts)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped %s: standalone stream closed", method)

View file

@ -94,18 +94,7 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
total: Optional total value (e.g., 100)
message: Optional message (e.g., "Starting render...")
"""
progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None
if progress_token is None:
return
await self.request_context.session.send_progress_notification(
progress_token=progress_token,
progress=progress,
total=total,
message=message,
related_request_id=self.request_id,
)
await self.request_context.session.report_progress(progress, total, message)
async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]:
"""Read a resource by URI.

View file

@ -33,23 +33,21 @@ from mcp.shared._otel import extract_trace_context, otel_span
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.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
ErrorData,
Implementation,
InitializeRequestParams,
InitializeResult,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
RequestParams,
RequestParamsMeta,
)
@ -63,11 +61,11 @@ __all__ = [
"ServerMiddleware",
"ServerRunner",
"aclose_shielded",
"modern_on_request",
"otel_middleware",
"serve_connection",
"serve_loop",
"serve_one",
"to_jsonrpc_response",
]
logger = logging.getLogger(__name__)
@ -180,26 +178,6 @@ async def aclose_shielded(connection: Connection) -> None:
)
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)
def _apply_middleware(
middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any]
) -> Awaitable[HandlerResult]:
@ -414,7 +392,7 @@ class ServerRunner(Generic[LifespanT]):
"""Validate `initialize` params and pick the protocol version."""
init = InitializeRequestParams.model_validate(params or {}, by_name=False)
requested = init.protocol_version
negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else LATEST_PROTOCOL_VERSION
negotiated = requested if requested in HANDSHAKE_PROTOCOL_VERSIONS else LATEST_HANDSHAKE_VERSION
return init, negotiated
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
@ -494,22 +472,49 @@ async def serve_loop(
async def serve_one(
server: Server[LifespanT],
request: JSONRPCRequest,
dctx: DispatchContext[TransportContext],
method: str,
params: Mapping[str, Any] | None,
*,
connection: Connection,
dctx: DispatchContext[TransportContext],
lifespan_state: LifespanT,
) -> JSONRPCResponse | JSONRPCError:
"""Handle a single ``request`` and return its JSON-RPC reply.
) -> dict[str, Any]:
"""Handle a single request ``(method, params)`` and return its result dict.
The single-exchange driver: builds the kernel, runs `on_request` once 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.
The single-exchange driver: builds the kernel, runs `on_request` once under
`dctx`, and tears down `connection.exit_stack` (shielded) on the way out.
The entry constructs the (born-ready) `Connection` and the `dctx`; this
only consumes them.
Raises whatever the handler chain raises (`MCPError` / `ValidationError` /
unmapped); callers own the exception-to-wire mapping.
"""
runner = ServerRunner(server, connection, lifespan_state)
try:
return await to_jsonrpc_response(request.id, runner.on_request(dctx, request.method, request.params))
return await runner.on_request(dctx, method, params)
finally:
await aclose_shielded(connection)
def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> OnRequest:
"""Return an `OnRequest` callback that serves each call via `serve_one` with a fresh per-request `Connection`.
Wire this into the server side of a `DirectDispatcher` peer-pair to drive an
in-process server on the modern per-request-envelope path (each request
carries protocol version, client info, and capabilities in `params._meta`;
no `initialize` handshake). Like `serve_one`, this raises whatever the
handler chain raises - the dispatcher owns the exception-to-error mapping.
"""
async def handle(
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> dict[str, Any]:
meta = (params or {}).get("_meta", {})
connection = Connection.from_envelope(
meta.get(PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION),
meta.get(CLIENT_INFO_META_KEY),
meta.get(CLIENT_CAPABILITIES_META_KEY),
)
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
return handle

View file

@ -14,7 +14,7 @@ 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, Outbound, ProgressFnT
from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods
@ -36,7 +36,7 @@ class ServerSession:
never crosses the `Outbound` Protocol.
"""
def __init__(self, request_outbound: Outbound, connection: Connection) -> None:
def __init__(self, request_outbound: DispatchContext[Any], connection: Connection) -> None:
self._request_outbound = request_outbound
self._connection = connection
@ -353,6 +353,16 @@ class ServerSession:
types.EmptyResult,
)
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for the inbound request this session is scoped to.
A no-op when the caller did not request progress. Dispatcher-agnostic:
on JSON-RPC the held `DispatchContext` emits ``notifications/progress``
against the caller's token; on the in-process direct dispatcher it
invokes the caller's callback directly.
"""
await self._request_outbound.progress(progress, total, message)
async def send_progress_notification(
self,
progress_token: str | int,

View file

@ -28,6 +28,7 @@ from starlette.types import Receive, Scope, Send
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.version import is_version_at_least
from mcp.types import (
@ -50,7 +51,6 @@ logger = logging.getLogger(__name__)
# Header names
MCP_SESSION_ID_HEADER = "mcp-session-id"
MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
LAST_EVENT_ID_HEADER = "last-event-id"
# Content types
@ -818,7 +818,7 @@ class StreamableHTTPServerTransport:
async def _validate_request_headers(self, request: Request, send: Send) -> bool:
# Protocol-version validation lives in the manager's era-routing: only
# values in `SUPPORTED_PROTOCOL_VERSIONS` (or no header at all) reach
# values in `HANDSHAKE_PROTOCOL_VERSIONS` (or no header at all) reach
# this transport, so the legacy version-gate is gone.
return await self._validate_session(request, send)

View file

@ -19,16 +19,16 @@ from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, Authorizat
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.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS
from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
if TYPE_CHECKING:
@ -169,7 +169,7 @@ class StreamableHTTPSessionManager:
# 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:
if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS:
await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send)
return