Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
8 changed files with 2533 additions and 993 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue