First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) (#2917)
This commit is contained in:
parent
1cec2d60f4
commit
84bf9bde05
9 changed files with 1515 additions and 93 deletions
|
|
@ -92,6 +92,15 @@ class Client:
|
|||
client_info: Implementation | None = None
|
||||
"""Client implementation info to send to server."""
|
||||
|
||||
protocol_version: str | None = None
|
||||
"""Pin the protocol version instead of negotiating it.
|
||||
|
||||
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
|
||||
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally),
|
||||
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request.
|
||||
Leave as ``None`` to negotiate the version via the initialize handshake.
|
||||
"""
|
||||
|
||||
elicitation_callback: ElicitationFnT | None = None
|
||||
"""Callback for handling elicitation requests."""
|
||||
|
||||
|
|
@ -103,7 +112,7 @@ class Client:
|
|||
if isinstance(self.server, Server | MCPServer):
|
||||
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
|
||||
elif isinstance(self.server, str):
|
||||
self._transport = streamable_http_client(self.server)
|
||||
self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
|
||||
else:
|
||||
self._transport = self.server
|
||||
|
||||
|
|
@ -126,6 +135,7 @@ class Client:
|
|||
message_handler=self.message_handler,
|
||||
client_info=self.client_info,
|
||||
elicitation_callback=self.elicitation_callback,
|
||||
protocol_version=self.protocol_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,8 +21,16 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
|||
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.types import INTERNAL_ERROR, METHOD_NOT_FOUND, RequestId, RequestParamsMeta
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
CLIENT_CAPABILITIES_META_KEY,
|
||||
CLIENT_INFO_META_KEY,
|
||||
INTERNAL_ERROR,
|
||||
METHOD_NOT_FOUND,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
RequestId,
|
||||
RequestParamsMeta,
|
||||
)
|
||||
from mcp.types import methods as _methods
|
||||
|
||||
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
|
||||
|
|
@ -141,11 +149,14 @@ class ClientSession:
|
|||
message_handler: MessageHandlerFnT | None = None,
|
||||
client_info: types.Implementation | None = None,
|
||||
*,
|
||||
protocol_version: str | None = None,
|
||||
sampling_capabilities: types.SamplingCapability | None = None,
|
||||
dispatcher: Dispatcher[Any] | None = None,
|
||||
) -> None:
|
||||
self._session_read_timeout_seconds = read_timeout_seconds
|
||||
self._client_info = client_info or DEFAULT_CLIENT_INFO
|
||||
self._pinned_version = protocol_version
|
||||
self._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS
|
||||
self._sampling_callback = sampling_callback or _default_sampling_callback
|
||||
self._sampling_capabilities = sampling_capabilities
|
||||
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
|
||||
|
|
@ -153,7 +164,19 @@ class ClientSession:
|
|||
self._logging_callback = logging_callback or _default_logging_callback
|
||||
self._message_handler = message_handler or _default_message_handler
|
||||
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
|
||||
self._initialize_result: types.InitializeResult | None = None
|
||||
self._initialize_result: types.InitializeResult | None
|
||||
if self._stateless_pinned:
|
||||
assert protocol_version is not None
|
||||
# A stateless-pinned session is born initialized: there is no handshake
|
||||
# at 2026-07-28+, so we synthesize the result locally. `server_info` is a
|
||||
# placeholder until `server/discover` is implemented to populate it.
|
||||
self._initialize_result = types.InitializeResult(
|
||||
protocol_version=protocol_version,
|
||||
capabilities=types.ServerCapabilities(),
|
||||
server_info=types.Implementation(name="", version=""),
|
||||
)
|
||||
else:
|
||||
self._initialize_result = None
|
||||
self._task_group: anyio.abc.TaskGroup | None = None
|
||||
if dispatcher is not None:
|
||||
if read_stream is not None or write_stream is not None:
|
||||
|
|
@ -219,6 +242,19 @@ class ClientSession:
|
|||
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
method: str = data["method"]
|
||||
opts: CallOptions = {}
|
||||
if self._stateless_pinned:
|
||||
params = data.setdefault("params", {})
|
||||
envelope_meta = params.setdefault("_meta", {})
|
||||
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
|
||||
envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump(
|
||||
by_alias=True, mode="json", exclude_none=True
|
||||
)
|
||||
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
|
||||
by_alias=True, mode="json", exclude_none=True
|
||||
)
|
||||
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
|
||||
# dispatcher must not emit notifications/cancelled when the caller abandons.
|
||||
opts["cancel_on_abandon"] = False
|
||||
timeout = (
|
||||
request_read_timeout_seconds
|
||||
if request_read_timeout_seconds is not None
|
||||
|
|
@ -254,7 +290,7 @@ class ClientSession:
|
|||
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
await self._dispatcher.notify(data["method"], data.get("params"))
|
||||
|
||||
async def initialize(self) -> types.InitializeResult:
|
||||
def _build_capabilities(self) -> types.ClientCapabilities:
|
||||
sampling = (
|
||||
(self._sampling_capabilities or types.SamplingCapability())
|
||||
if self._sampling_callback is not _default_sampling_callback
|
||||
|
|
@ -273,17 +309,19 @@ class ClientSession:
|
|||
if self._list_roots_callback is not _default_list_roots_callback
|
||||
else None
|
||||
)
|
||||
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
|
||||
|
||||
async def initialize(self) -> types.InitializeResult:
|
||||
if self._initialize_result is not None:
|
||||
return self._initialize_result
|
||||
capabilities = self._build_capabilities()
|
||||
result = await self.send_request(
|
||||
types.InitializeRequest(
|
||||
params=types.InitializeRequestParams(
|
||||
protocol_version=types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=types.ClientCapabilities(
|
||||
sampling=sampling,
|
||||
elicitation=elicitation,
|
||||
experimental=None,
|
||||
roots=roots,
|
||||
),
|
||||
protocol_version=self._pinned_version
|
||||
if self._pinned_version is not None
|
||||
else types.LATEST_PROTOCOL_VERSION,
|
||||
capabilities=capabilities,
|
||||
client_info=self._client_info,
|
||||
),
|
||||
),
|
||||
|
|
@ -303,14 +341,24 @@ class ClientSession:
|
|||
def initialize_result(self) -> types.InitializeResult | None:
|
||||
"""The server's InitializeResult. None until initialize() has been called.
|
||||
|
||||
Contains server_info, capabilities, instructions, and the negotiated protocol_version.
|
||||
A stateless-pinned session (protocol_version >= 2026-07-28) is born
|
||||
initialized: this property is populated at construction with a
|
||||
synthesized result and `initialize()` returns it without touching the
|
||||
wire. Contains server_info, capabilities, instructions, and the
|
||||
negotiated protocol_version.
|
||||
"""
|
||||
return self._initialize_result
|
||||
|
||||
@property
|
||||
def protocol_version(self) -> str | None:
|
||||
"""The negotiated protocol version. None until `initialize()` has completed."""
|
||||
return self._initialize_result.protocol_version if self._initialize_result else None
|
||||
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
|
||||
|
||||
Once `initialize()` has completed, this is the version the server actually
|
||||
negotiated (which can differ from a stateful pin); before that, the pin.
|
||||
"""
|
||||
if self._initialize_result is not None:
|
||||
return self._initialize_result.protocol_version
|
||||
return self._pinned_version
|
||||
|
||||
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
|
||||
"""Send a ping request."""
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -19,6 +21,7 @@ from mcp.shared._compat import resync_tracer
|
|||
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
from mcp.shared.message import ClientMessageMetadata, SessionMessage
|
||||
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
|
||||
from mcp.types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_REQUEST,
|
||||
|
|
@ -44,12 +47,24 @@ StreamReader = ContextReceiveStream[SessionMessage]
|
|||
|
||||
MCP_SESSION_ID = "mcp-session-id"
|
||||
MCP_PROTOCOL_VERSION = "mcp-protocol-version"
|
||||
MCP_METHOD = "mcp-method"
|
||||
MCP_NAME = "mcp-name"
|
||||
LAST_EVENT_ID = "last-event-id"
|
||||
|
||||
# Reconnection defaults
|
||||
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
|
||||
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
|
||||
|
||||
_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$")
|
||||
# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value.
|
||||
_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$")
|
||||
|
||||
|
||||
def _encode_header_value(value: str) -> str:
|
||||
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
|
||||
return value
|
||||
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
|
||||
|
||||
|
||||
class StreamableHTTPError(Exception):
|
||||
"""Base exception for StreamableHTTP transport errors."""
|
||||
|
|
@ -73,15 +88,40 @@ class RequestContext:
|
|||
class StreamableHTTPTransport:
|
||||
"""StreamableHTTP client transport implementation."""
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
def __init__(self, url: str, protocol_version: str | None = None) -> None:
|
||||
"""Initialize the StreamableHTTP transport.
|
||||
|
||||
Args:
|
||||
url: The endpoint URL.
|
||||
protocol_version: Pin the MCP-Protocol-Version header from the first request
|
||||
instead of waiting to snoop it from an InitializeResult. Required for
|
||||
stateless 2026-07-28 sessions that never send initialize.
|
||||
"""
|
||||
self.url = url
|
||||
self.session_id: str | None = None
|
||||
self.protocol_version: str | None = None
|
||||
self.protocol_version: str | None = protocol_version
|
||||
|
||||
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
|
||||
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports.
|
||||
|
||||
MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it
|
||||
from `self.protocol_version` for every request.
|
||||
"""
|
||||
if self.protocol_version not in MODERN_PROTOCOL_VERSIONS:
|
||||
return {}
|
||||
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
|
||||
return {}
|
||||
headers: dict[str, str] = {MCP_METHOD: message.method}
|
||||
# TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read
|
||||
# (params.uri); a method->param-key map replaces this gate when those land.
|
||||
if (
|
||||
isinstance(message, JSONRPCRequest)
|
||||
and message.method == "tools/call"
|
||||
and message.params
|
||||
and isinstance(name := message.params.get("name"), str)
|
||||
):
|
||||
headers[MCP_NAME] = _encode_header_value(name)
|
||||
return headers
|
||||
|
||||
def _prepare_headers(self) -> dict[str, str]:
|
||||
"""Build MCP-specific request headers.
|
||||
|
|
@ -117,6 +157,9 @@ class StreamableHTTPTransport:
|
|||
|
||||
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
|
||||
"""Extract protocol version from initialization response message."""
|
||||
if self.protocol_version is not None:
|
||||
# Constructor pin wins over snooping the InitializeResult.
|
||||
return
|
||||
if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch
|
||||
try:
|
||||
# Parse the result as InitializeResult for type safety
|
||||
|
|
@ -256,6 +299,7 @@ class StreamableHTTPTransport:
|
|||
"""Handle a POST request with response processing."""
|
||||
headers = self._prepare_headers()
|
||||
message = ctx.session_message.message
|
||||
headers.update(self._per_message_headers(message))
|
||||
is_initialization = self._is_initialization_request(message)
|
||||
|
||||
async with ctx.client.stream(
|
||||
|
|
@ -522,6 +566,7 @@ async def streamable_http_client(
|
|||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
terminate_on_close: bool = True,
|
||||
protocol_version: str | None = None,
|
||||
) -> AsyncGenerator[TransportStreams, None]:
|
||||
"""Client transport for StreamableHTTP.
|
||||
|
||||
|
|
@ -531,6 +576,8 @@ async def streamable_http_client(
|
|||
client with recommended MCP timeouts will be created. To configure headers,
|
||||
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
|
||||
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
|
||||
protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions.
|
||||
Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`.
|
||||
|
||||
Yields:
|
||||
Tuple containing:
|
||||
|
|
@ -548,7 +595,7 @@ async def streamable_http_client(
|
|||
# Create default client with recommended MCP timeouts
|
||||
client = create_mcp_http_client()
|
||||
|
||||
transport = StreamableHTTPTransport(url)
|
||||
transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
|
||||
|
||||
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")
|
||||
|
||||
|
|
|
|||
231
src/mcp/server/_streamable_http_modern.py
Normal file
231
src/mcp/server/_streamable_http_modern.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"""Single-exchange HTTP serving for protocol version 2026-07-28.
|
||||
|
||||
Private module — entry is via `StreamableHTTPSessionManager.handle_request`.
|
||||
The legacy streamable-HTTP transport is untouched and remains the supported
|
||||
path for earlier protocol revisions.
|
||||
|
||||
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
|
||||
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This
|
||||
module handles such a request directly in the ASGI task - no memory streams,
|
||||
no per-request task group, no `JSONRPCDispatcher`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
from pydantic import 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.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
|
||||
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
|
||||
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
PARSE_ERROR,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
RequestId,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.lowlevel.server import Server
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SingleExchangeDispatchContext:
|
||||
"""`DispatchContext` for one inbound HTTP request.
|
||||
|
||||
Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
|
||||
back-channel is closed by construction: a 2026-07-28 server cannot send
|
||||
requests to the client.
|
||||
"""
|
||||
|
||||
transport: TransportContext
|
||||
request_id: RequestId
|
||||
message_metadata: MessageMetadata
|
||||
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
|
||||
can_send_request: bool = False
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
|
||||
# TODO: no progressToken plumbing yet.
|
||||
return None
|
||||
|
||||
|
||||
class SingleExchangeDispatcher:
|
||||
"""Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST.
|
||||
|
||||
The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s
|
||||
role). Implements the `Dispatcher` Protocol so `ServerRunner` /
|
||||
`Connection` / `ServerSession` accept it; `run()` is never driven.
|
||||
"""
|
||||
|
||||
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.
|
||||
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 handle_modern_request(
|
||||
app: Server[Any],
|
||||
security_settings: TransportSecuritySettings | None,
|
||||
protocol_version: str,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
send: Send,
|
||||
) -> None:
|
||||
"""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`.
|
||||
"""
|
||||
request = Request(scope, receive)
|
||||
|
||||
security = TransportSecurityMiddleware(security_settings)
|
||||
err = await security.validate_request(request, is_post=(request.method == "POST"))
|
||||
if err is not None:
|
||||
await err(scope, receive, send)
|
||||
return
|
||||
|
||||
# TODO: validate Accept header once the JSON-vs-SSE response-mode design is settled.
|
||||
|
||||
if request.method != "POST":
|
||||
# TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder.
|
||||
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
|
||||
return
|
||||
|
||||
body = await request.body()
|
||||
try:
|
||||
req = JSONRPCRequest.model_validate_json(body)
|
||||
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)
|
||||
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],
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
|
@ -31,9 +31,8 @@ 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, DispatchMiddleware, OnRequest
|
||||
from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnRequest
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||
|
|
@ -175,7 +174,7 @@ class ServerRunner(Generic[LifespanT]):
|
|||
"""Per-connection orchestrator. One instance per client connection."""
|
||||
|
||||
server: Server[LifespanT]
|
||||
dispatcher: JSONRPCDispatcher[Any]
|
||||
dispatcher: Dispatcher[Any]
|
||||
lifespan_state: LifespanT
|
||||
has_standalone_channel: bool
|
||||
init_options: InitializationOptions | None = None
|
||||
|
|
|
|||
|
|
@ -9,16 +9,15 @@ 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, overload
|
||||
from typing import Any, TypeVar, cast, overload
|
||||
|
||||
from pydantic import AnyUrl, BaseModel
|
||||
|
||||
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, ProgressFnT
|
||||
from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT
|
||||
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
|
||||
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
|
||||
from mcp.shared.message import ServerMessageMetadata
|
||||
from mcp.types import methods as _methods
|
||||
|
||||
|
|
@ -37,7 +36,7 @@ class ServerSession:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
dispatcher: JSONRPCDispatcher[Any],
|
||||
dispatcher: Dispatcher[Any],
|
||||
connection: Connection,
|
||||
*,
|
||||
stateless: bool = False,
|
||||
|
|
@ -92,8 +91,19 @@ class ServerSession:
|
|||
# Fail fast instead of parking forever on a response that cannot
|
||||
# arrive; matches `Connection.send_raw_request`.
|
||||
raise NoBackChannelError(data["method"])
|
||||
result = await self._dispatcher.send_raw_request(
|
||||
data["method"], data.get("params"), opts or None, _related_request_id=related
|
||||
# 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"
|
||||
|
|
@ -110,7 +120,7 @@ class ServerSession:
|
|||
) -> None:
|
||||
"""Send a typed server-to-client notification."""
|
||||
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)
|
||||
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg]
|
||||
|
||||
def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
|
||||
"""Check if the client supports a specific capability."""
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from starlette.requests import Request
|
|||
from starlette.responses import Response
|
||||
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.streamable_http import (
|
||||
MCP_SESSION_ID_HEADER,
|
||||
|
|
@ -22,6 +23,7 @@ from mcp.server.streamable_http import (
|
|||
)
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -150,6 +152,13 @@ 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)
|
||||
return
|
||||
|
||||
# Dispatch to the appropriate handler
|
||||
if self.stateless:
|
||||
await self._handle_stateless_request(scope, receive, send)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
|
|||
)
|
||||
"""Every released protocol revision, oldest to newest."""
|
||||
|
||||
MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",)
|
||||
"""Protocol revisions that use the stateless per-request envelope."""
|
||||
|
||||
SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION]
|
||||
"""Protocol revisions this SDK can negotiate."""
|
||||
|
||||
|
|
|
|||
|
|
@ -185,15 +185,17 @@ class PaginatedResult(Result):
|
|||
class CacheableResult(Result):
|
||||
"""Base class for results that carry client-side caching directives (2026-07-28).
|
||||
|
||||
Both fields are required on the 2026-07-28 wire; the SDK declares no
|
||||
default, so a handler answering at 2026-07-28 must set them explicitly.
|
||||
Both fields are required on the 2026-07-28 wire. The SDK defaults to
|
||||
`ttl_ms=0` (immediately stale) and `cache_scope="private"` so a handler
|
||||
that doesn't set them still produces a valid 2026-07-28 result without
|
||||
accidentally enabling shared caching.
|
||||
"""
|
||||
|
||||
ttl_ms: Annotated[int, Field(ge=0)] | None = None
|
||||
ttl_ms: Annotated[int, Field(ge=0)] = 0
|
||||
"""How long (ms) the client MAY cache this response, analogous to HTTP
|
||||
`Cache-Control: max-age`. 0 means immediately stale."""
|
||||
|
||||
cache_scope: Literal["public", "private"] | None = None
|
||||
cache_scope: Literal["public", "private"] = "private"
|
||||
"""Analogous to HTTP `Cache-Control: public` vs `private`: "public" allows
|
||||
shared caches to serve the response to any user; "private" forbids that."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue