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
24 changed files with 2533 additions and 993 deletions

79
src/mcp/client/_probe.py Normal file
View file

@ -0,0 +1,79 @@
"""Connect-time era negotiation for ``mode='auto'``.
The ``server/discover`` probe is sent at the newest modern version. Anything
that is not positive evidence the peer is a modern MCP server falls back to
the legacy ``initialize`` handshake — a *denylist* (only the disjoint-modern
case raises) rather than an allowlist of fallback codes.
Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only
``supported`` list. The streamable-HTTP transport already maps HTTP-layer
4xx rejections (no JSON-RPC body) into ``MCPError`` codes, so those reach
the same path. Any non-``MCPError`` exception (network/connection errors,
anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
propagates to the caller; an outage or in-process bug is never an era verdict.
"""
from __future__ import annotations
from typing import Any
from pydantic import ValidationError
from mcp import types
from mcp.client.session import ClientSession
from mcp.shared.exceptions import MCPError
from mcp.shared.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.types import UNSUPPORTED_PROTOCOL_VERSION
def _parse_supported(data: Any) -> list[str] | None:
"""Pull ``data.supported`` off a -32022 error, or ``None`` if not actionable."""
try:
return types.UnsupportedProtocolVersionErrorData.model_validate(data).supported
except ValidationError:
return None
async def negotiate_auto(session: ClientSession) -> None:
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
Probes ``server/discover`` once (twice if the server names a mutual
modern version via -32022), then either ``adopt()``s the result or falls
back to ``initialize()``. Idempotent only in the sense that one of
``session.discover_result`` / ``session.initialize_result`` is set on
return.
Raises:
MCPError: The server is modern-only and shares no version with this
client (-32022 with a disjoint ``supported`` list).
Exception: Any transport/network error from the probe propagates as-is.
"""
version = LATEST_MODERN_VERSION
for attempt in range(2):
try:
raw = await session.send_discover(version)
except MCPError as e:
if e.code == UNSUPPORTED_PROTOCOL_VERSION:
supported = _parse_supported(e.error.data)
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
if mutual and attempt == 0:
version = mutual[-1]
continue
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
raise # server is modern-only and disjoint — real incompatibility
await session.initialize() # every other rpc-error → legacy (the denylist)
return
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
# RuntimeError from adopt) → propagate
try:
result = types.DiscoverResult.model_validate(raw)
except ValidationError:
await session.initialize() # unparseable result → not modern evidence
return
session.adopt(result)
return
raise AssertionError("unreachable") # pragma: no cover — loop body always returns or raises

View file

@ -40,7 +40,6 @@ from mcp.client.auth.utils import (
validate_authorization_response_iss,
validate_metadata_issuer,
)
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import (
AuthorizationCodeResult,
OAuthClientInformationFull,
@ -54,6 +53,7 @@ from mcp.shared.auth_utils import (
check_resource_allowed,
resource_url_from_server_url,
)
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.version import is_version_at_least
logger = logging.getLogger(__name__)
@ -534,7 +534,7 @@ class OAuthClientProvider(httpx.Auth):
await self._initialize()
# Capture protocol version from request headers
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER)
if not self.context.is_token_valid() and self.context.can_refresh_token():
# Try to refresh token

View file

@ -5,7 +5,6 @@ from httpx import Request, Response
from pydantic import AnyUrl, ValidationError
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
@ -13,6 +12,7 @@ from mcp.shared.auth import (
OAuthToken,
ProtectedResourceMetadata,
)
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.types import LATEST_PROTOCOL_VERSION
@ -273,7 +273,7 @@ def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str
def create_oauth_metadata_request(url: str) -> Request:
return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
return Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION})
def create_client_registration_request(

View file

@ -2,27 +2,34 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from contextlib import AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any
from typing import Any, Literal, TypeVar
import anyio
from typing_extensions import deprecated
from mcp import types
from mcp.client._memory import InMemoryTransport
from mcp.client._probe import negotiate_auto
from mcp.client._transport import Transport
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.mcpserver import MCPServer
from mcp.shared.dispatcher import ProgressFnT
from mcp.server.runner import modern_on_request
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CallToolResult,
CompleteResult,
EmptyResult,
GetPromptResult,
Implementation,
InitializeResult,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
@ -33,8 +40,87 @@ from mcp.types import (
ReadResourceResult,
RequestParamsMeta,
ResourceTemplateReference,
ServerCapabilities,
)
ConnectMode = Literal["legacy", "auto"] | str
"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""
_T = TypeVar("_T")
_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]]
"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
are needed onto the exit stack and hand back the ``Dispatcher`` ``ClientSession`` will drive.
``mode`` and ``raise_exceptions`` are passed at call time so they're read at the same moment
``__aenter__`` reads them for the handshake step."""
def _connect_transport(transport: Transport) -> _Connector:
"""Connector for the stream-backed paths (URL, user-supplied ``Transport``)."""
async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_exceptions: bool) -> Dispatcher[Any]:
read_stream, write_stream = await exit_stack.enter_async_context(transport)
return JSONRPCDispatcher(read_stream, write_stream)
return connect
def _connect_inproc(server: Server[Any]) -> _Connector:
"""Connector for an in-process ``Server``: legacy mode drives the stream loop via
``InMemoryTransport``; any other mode drives the modern per-request path through a
``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake)."""
async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]:
if mode == "legacy":
transport = InMemoryTransport(server, raise_exceptions=raise_exceptions)
read_stream, write_stream = await exit_stack.enter_async_context(transport)
return JSONRPCDispatcher(read_stream, write_stream)
lifespan_state = await exit_stack.enter_async_context(server.lifespan(server))
client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions)
tg = await exit_stack.enter_async_context(anyio.create_task_group())
exit_stack.callback(server_disp.close)
on_request = modern_on_request(server, lifespan_state)
await tg.start(server_disp.run, on_request, _no_inbound_client_notifications)
return client_disp
return connect
def _connected(value: _T | None) -> _T:
"""Narrow a post-handshake session attribute from ``T | None`` to ``T``.
``Client.__aenter__`` only assigns ``_session`` after the handshake succeeds, so inside
``async with Client(...)`` these attributes are always populated; the ``.session`` gate
raises before this is reached otherwise. The guard exists for pyright, not runtime.
"""
if value is None: # pragma: no cover
raise RuntimeError("Client must be used within an async context manager")
return value
def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
return types.DiscoverResult(
supported_versions=[protocol_version],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="", version=""),
result_type="complete",
ttl_ms=0,
cache_scope="public",
)
async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None:
"""Server-side inbound ``OnNotify`` for the modern in-process path — receives nothing.
At 2026-07-28 the spec defines no client→server notifications: ``initialized`` and
``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel
through the direct await, not a notify). Server→client notifications (progress, log
messages) flow the other way via the per-request ``DispatchContext`` into the client's
callbacks, and are not seen here.
"""
@dataclass
class Client:
@ -65,7 +151,7 @@ class Client:
server: Server[Any] | MCPServer | Transport | str
"""The MCP server to connect to.
If the server is a `Server` or `MCPServer` instance, it will be wrapped in an `InMemoryTransport`.
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
If the server is a `Transport` instance, it will be used directly.
"""
@ -95,58 +181,83 @@ 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.
mode: ConnectMode = "auto"
"""How to negotiate the protocol version.
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. A modern
pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path
does not yet have a modern entry point.
Leave as ``None`` to negotiate the version via the initialize handshake.
"""
'auto' (the default) probes `server/discover` and falls back to the initialize handshake on legacy servers;
for an in-process `Server`/`MCPServer` it dispatches directly without JSON-RPC framing. 'legacy' forces the
initialize handshake (byte-identical pre-2026 behavior). A modern protocol-version string (e.g. '2026-07-28')
adopts that version directly without a probe — supply `prior_discover` to reuse a known DiscoverResult, or
omit it to synthesize a minimal one."""
prior_discover: types.DiscoverResult | None = None
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
Ignored when mode='legacy'."""
elicitation_callback: ElicitationFnT | None = None
"""Callback for handling elicitation requests."""
_entered: bool = field(init=False, default=False)
_session: ClientSession | None = field(init=False, default=None)
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
_transport: Transport = field(init=False)
_connect: _Connector = field(init=False, repr=False, compare=False)
def __post_init__(self) -> None:
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, protocol_version=self.protocol_version)
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
hint = (
f" ({self.mode!r} is a handshake-era version — use mode='legacy')"
if self.mode in HANDSHAKE_PROTOCOL_VERSIONS
else ""
)
raise ValueError(
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
)
srv = self.server
if isinstance(srv, MCPServer):
srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage]
if isinstance(srv, Server):
self._connect = _connect_inproc(srv)
elif isinstance(srv, str):
self._connect = _connect_transport(streamable_http_client(srv))
else:
self._transport = self.server
self._connect = _connect_transport(srv)
async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
"""Enter the resolved connector and return an un-entered ClientSession."""
dispatcher = await self._connect(exit_stack, self.mode, self.raise_exceptions)
return ClientSession(
dispatcher=dispatcher,
read_timeout_seconds=self.read_timeout_seconds,
sampling_callback=self.sampling_callback,
list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback,
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
)
async def __aenter__(self) -> Client:
"""Enter the async context manager."""
if self._session is not None:
if self._entered:
raise RuntimeError("Client is already entered; cannot reenter")
self._entered = True
async with AsyncExitStack() as exit_stack:
read_stream, write_stream = await exit_stack.enter_async_context(self._transport)
session = await self._build_session(exit_stack)
session = await exit_stack.enter_async_context(session)
self._session = await exit_stack.enter_async_context(
ClientSession(
read_stream=read_stream,
write_stream=write_stream,
read_timeout_seconds=self.read_timeout_seconds,
sampling_callback=self.sampling_callback,
list_roots_callback=self.list_roots_callback,
logging_callback=self.logging_callback,
message_handler=self.message_handler,
client_info=self.client_info,
elicitation_callback=self.elicitation_callback,
protocol_version=self.protocol_version,
)
)
if self.mode == "legacy":
await session.initialize()
elif self.mode == "auto":
await negotiate_auto(session)
else:
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
await self._session.initialize()
# Transfer ownership to self for __aexit__ to handle
# Only publish the session after the handshake succeeds, so `_session is not None`
# implies the protocol_version/server_info/server_capabilities are populated. If the
# handshake raised above, the local exit_stack unwinds the transport for us.
self._session = session
self._exit_stack = exit_stack.pop_all()
return self
@ -169,22 +280,42 @@ class Client:
raise RuntimeError("Client must be used within an async context manager")
return self._session
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
# type whose protocol_version/server_info/server_capabilities are non-Optional fields,
# eliminating these guards (and the one in .session). Same family as resolving the
# transport/connector at __post_init__ so the Optional internal fields disappear.
@property
def initialize_result(self) -> InitializeResult:
"""The server's InitializeResult.
def protocol_version(self) -> str:
"""Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.protocol_version)
Contains server_info, capabilities, instructions, and the negotiated protocol_version.
Raises RuntimeError if accessed outside the context manager.
"""
result = self.session.initialize_result
if result is None: # pragma: no cover
raise RuntimeError("Client must be used within an async context manager")
return result
@property
def server_info(self) -> Implementation:
"""Server name/version (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.server_info)
@property
def server_capabilities(self) -> ServerCapabilities:
"""Server capabilities (set by initialize/discover/adopt during ``__aenter__``)."""
return _connected(self.session.server_capabilities)
@property
def instructions(self) -> str | None:
"""Server-provided instructions text, if any."""
return self.session.instructions
@deprecated(
"ping is removed as of 2026-07-28; the method only works under mode='legacy'.",
category=MCPDeprecationWarning,
)
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Send a ping request to the server."""
return await self.session.send_ping(meta=meta)
@deprecated(
"Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.",
category=MCPDeprecationWarning,
)
async def send_progress_notification(
self,
progress_token: str | int,
@ -193,7 +324,7 @@ class Client:
message: str | None = None,
) -> None:
"""Send a progress notification to the server."""
await self.session.send_progress_notification(
await self.session.send_progress_notification( # pyright: ignore[reportDeprecated]
progress_token=progress_token,
progress=progress,
total=total,

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import TracebackType
from typing import Any, Protocol, cast
@ -17,26 +17,74 @@ from mcp.client._transport import ReadStream, WriteStream
from mcp.shared._compat import resync_tracer
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.inbound import (
MCP_METHOD_HEADER,
MCP_NAME_HEADER,
MCP_PROTOCOL_VERSION_HEADER,
encode_header_value,
)
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 MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_HANDSHAKE_VERSION,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INTERNAL_ERROR,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
RequestId,
RequestParamsMeta,
)
from mcp.types import methods as _methods
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
DISCOVER_TIMEOUT_SECONDS = 10.0
logger = logging.getLogger("client")
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
# initialize/discover forbid cancellation; other pre-handshake requests (lowlevel
# ClientSession callers may skip the handshake entirely) keep the courtesy cancel.
if data["method"] in ("initialize", "server/discover"):
opts["cancel_on_abandon"] = False
def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
return stamp
def _make_modern_stamp(
protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any]
) -> Callable[[dict[str, Any], CallOptions], None]:
def stamp(data: dict[str, Any], opts: CallOptions) -> None:
params = data.setdefault("params", {})
meta = params.setdefault("_meta", {})
meta[PROTOCOL_VERSION_META_KEY] = protocol_version
meta[CLIENT_INFO_META_KEY] = client_info
meta[CLIENT_CAPABILITIES_META_KEY] = capabilities
opts["cancel_on_abandon"] = False
headers = opts.setdefault("headers", {})
headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version
headers[MCP_METHOD_HEADER] = data["method"]
# TODO: also emit Mcp-Name for prompts/get (params.name) and resources/read (params.uri)
if data["method"] == "tools/call" and isinstance(name := params.get("name"), str):
headers[MCP_NAME_HEADER] = encode_header_value(name)
return stamp
ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
@ -149,14 +197,11 @@ 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
@ -164,24 +209,23 @@ 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
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._initialize_result: types.InitializeResult | None = None
self._discover_result: types.DiscoverResult | None = None
self._negotiated_version: str | None = None
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
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:
raise ValueError("pass read_stream/write_stream or dispatcher, not both")
self._dispatcher: Dispatcher[Any] = dispatcher
if isinstance(dispatcher, JSONRPCDispatcher) and dispatcher.on_stream_exception is None:
# Route transport-level Exception items into message_handler — only
# stream-backed dispatchers carry these; DirectDispatcher has none.
# Don't clobber a caller-supplied hook.
# TODO(L78): this leaves a bound-method ref on the dispatcher after the
# session exits (memory pin) and a second wrap of the same dispatcher would
# skip install. The Transport-as-Dispatcher rework (L77) removes this seam.
dispatcher.on_stream_exception = self._on_stream_exception
else:
if read_stream is None or write_stream is None:
raise ValueError("read_stream and write_stream are required when no dispatcher is given")
@ -242,19 +286,7 @@ 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
self._stamp(data, opts)
timeout = (
request_read_timeout_seconds
if request_read_timeout_seconds is not None
@ -269,12 +301,9 @@ class ClientSession:
opts["resumption_token"] = metadata.resumption_token
if metadata.on_resumption_token_update is not None:
opts["on_resumption_token"] = metadata.on_resumption_token_update
if method == "initialize":
# The spec forbids cancelling initialize.
opts["cancel_on_abandon"] = False
raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts)
# Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25"
version = self._negotiated_version or "2025-11-25"
try:
_methods.validate_server_result(method, version, raw)
except KeyError:
@ -288,7 +317,9 @@ class ClientSession:
dropped with a debug log instead of raising.
"""
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params"))
opts: CallOptions = {}
self._stamp(data, opts)
await self._dispatcher.notify(data["method"], data.get("params"), opts)
def _build_capabilities(self) -> types.ClientCapabilities:
sampling = (
@ -314,56 +345,180 @@ class ClientSession:
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=self._pinned_version
if self._pinned_version is not None
else types.LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=self._build_capabilities(),
client_info=self._client_info,
),
),
types.InitializeResult,
)
if result.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS:
if result.protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS:
raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}")
self._initialize_result = result
self.adopt(result)
await self.send_notification(types.InitializedNotification())
return result
def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
"""Install negotiated state from a result the caller already holds (no wire traffic).
Clears the opposite slot, so at most one of `initialize_result` /
`discover_result` is ever non-None.
Raises:
RuntimeError: `result` is a `DiscoverResult` whose `supported_versions`
shares nothing with this client's `MODERN_PROTOCOL_VERSIONS`.
"""
if isinstance(result, types.DiscoverResult):
# ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in result.supported_versions]
if not mutual:
raise RuntimeError(
f"No mutually supported modern protocol version "
f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})"
)
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities)
self._discover_result = result
self._initialize_result = None
self._negotiated_version = mutual[-1]
else:
self._stamp = _make_handshake_stamp(result.protocol_version)
self._initialize_result = result
self._discover_result = None
self._negotiated_version = result.protocol_version
async def send_discover(self, version: str) -> dict[str, Any]:
"""Send a single ``server/discover`` at ``version`` and return the raw result dict.
No retry, no ``adopt()``. The ``_meta`` envelope and the
``Mcp-Protocol-Version`` header are stamped at ``version`` so the
server-side era router sees a coherent probe. Used by ``discover()`` and
the connect-time auto-negotiation policy.
Raises:
MCPError: The server returned a JSON-RPC error, or the transport
bounced the request at its own layer (a bare HTTP 4xx is
synthesized into a JSON-RPC error by the transport).
"""
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
request = types.DiscoverRequest(
params=types.RequestParams(
_meta={
PROTOCOL_VERSION_META_KEY: version,
CLIENT_INFO_META_KEY: client_info,
CLIENT_CAPABILITIES_META_KEY: capabilities,
}
)
)
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
opts: CallOptions = {
"timeout": DISCOVER_TIMEOUT_SECONDS,
"cancel_on_abandon": False,
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
}
return await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts)
async def discover(self) -> types.DiscoverResult:
"""Probe `server/discover` and adopt the result.
Sends a single `server/discover` proposing the newest modern protocol
version. On `UNSUPPORTED_PROTOCOL_VERSION` (-32022) the server's
`supported` list is intersected with `MODERN_PROTOCOL_VERSIONS` and the
probe is retried once at the highest mutual version. Any other error —
including `METHOD_NOT_FOUND` (-32601) and `REQUEST_TIMEOUT` (-32001) —
propagates; the legacy `initialize()` fallback is the caller's policy.
Raises:
MCPError: The server rejected `server/discover`, the probe timed
out, or the -32022 retry found no mutual version / failed again.
RuntimeError: `adopt()` found no mutual version in the returned
`supported_versions`.
"""
if self._discover_result is not None:
return self._discover_result
try:
raw = await self.send_discover(LATEST_MODERN_VERSION)
except MCPError as e:
if e.code != UNSUPPORTED_PROTOCOL_VERSION:
raise
try:
data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data)
except ValidationError:
raise e from None
# ordered oldest→newest via MODERN_PROTOCOL_VERSIONS
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported]
if not mutual:
raise
raw = await self.send_discover(mutual[-1])
result = types.DiscoverResult.model_validate(raw)
self.adopt(result)
return result
@property
def initialize_result(self) -> types.InitializeResult | None:
"""The server's InitializeResult. None until initialize() has been called.
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.
"""
"""The server's InitializeResult. None unless `initialize()` ran (or was adopted)."""
return self._initialize_result
@property
def protocol_version(self) -> str | None:
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
def discover_result(self) -> types.DiscoverResult | None:
"""The server's DiscoverResult. None unless `discover()` ran (or was adopted).
Once `initialize()` has completed, this is the version the server actually
negotiated (which can differ from a stateful pin); before that, the pin.
Retained intact (supported_versions, ttl_ms, cache_scope) so callers
can round-trip it as ``prior_discover=``.
"""
return self._discover_result
@property
def protocol_version(self) -> str | None:
"""Negotiated protocol version. None until `initialize()`, `discover()`, or `adopt()`."""
return self._negotiated_version
@property
def server_info(self) -> types.Implementation | None:
"""Server name/version. None until `initialize()`, `discover()`, or `adopt()`."""
if self._discover_result is not None:
return self._discover_result.server_info
if self._initialize_result is not None:
return self._initialize_result.protocol_version
return self._pinned_version
return self._initialize_result.server_info
return None
@property
def server_capabilities(self) -> types.ServerCapabilities | None:
"""Server capabilities. None until `initialize()`, `discover()`, or `adopt()`."""
if self._discover_result is not None:
return self._discover_result.capabilities
if self._initialize_result is not None:
return self._initialize_result.capabilities
return None
@property
def instructions(self) -> str | None:
"""Server-provided instructions text, if any."""
if self._discover_result is not None:
return self._discover_result.instructions
if self._initialize_result is not None:
return self._initialize_result.instructions
return None
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a ping request."""
return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult)
@deprecated(
"Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.",
category=MCPDeprecationWarning,
)
async def send_progress_notification(
self,
progress_token: str | int,
@ -563,7 +718,7 @@ class ClientSession:
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
# handshake (which only exists at <=2025) and stateless until the header
# is plumbed; its meaning is fixed regardless of LATEST bumps.
version = self.protocol_version or "2025-11-25"
version = self._negotiated_version or "2025-11-25"
try:
request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params))
except KeyError:
@ -601,7 +756,7 @@ class ClientSession:
) -> None:
"""Route a server notification: validate, run the typed callback, tee to message_handler."""
# Same fallback as `_on_request`: covers pre-handshake and stateless.
version = self.protocol_version or "2025-11-25"
version = self._negotiated_version or "2025-11-25"
try:
notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params))
except KeyError:

View file

@ -2,10 +2,8 @@
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
@ -20,14 +18,14 @@ from mcp.client._transport import TransportStreams
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.inbound import MCP_PROTOCOL_VERSION_HEADER
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
INTERNAL_ERROR,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR,
ErrorData,
InitializeResult,
JSONRPCError,
JSONRPCMessage,
JSONRPCNotification,
@ -46,25 +44,12 @@ StreamWriter = ContextSendStream[SessionMessageOrError]
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."""
@ -88,57 +73,38 @@ class RequestContext:
class StreamableHTTPTransport:
"""StreamableHTTP client transport implementation."""
def __init__(self, url: str, protocol_version: str | None = None) -> None:
def __init__(self, url: str) -> None:
"""Initialize the StreamableHTTP transport.
Args:
url: The endpoint URL.
protocol_version: Pin the MCP-Protocol-Version header from the first request.
Only honoured for stateless 2026-07-28+ sessions that never send
initialize; for earlier (stateful) versions the header is populated
from the negotiated InitializeResult, so a pre-2026 value is ignored.
"""
self.url = url
self.session_id: str | None = None
self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None
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
# Captured from each stamped POST's metadata. Reused on outbound HTTP that carries
# no per-message header (transport-internal GET/DELETE, and dispatcher-written
# response/error/cancel POSTs that bypass the session's stamp). Cleared when an
# `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake.
self._protocol_version_header: str | None = None
def _prepare_headers(self) -> dict[str, str]:
"""Build MCP-specific request headers.
"""Build MCP-specific request headers for any outbound HTTP request.
These headers will be merged with the httpx.AsyncClient's default headers,
with these MCP-specific headers taking precedence.
These are merged with the ``httpx.AsyncClient`` defaults (these take
precedence). The cached ``MCP-Protocol-Version`` is included whenever
present so messages that don't pass through the session's stamp —
response/error/cancel POSTs, transport-internal GET/DELETE — still
carry the negotiated version. Per-message headers are layered on top
by the caller.
"""
headers: dict[str, str] = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
}
# Add session headers if available
if self.session_id:
headers[MCP_SESSION_ID] = self.session_id
if self.protocol_version:
headers[MCP_PROTOCOL_VERSION] = self.protocol_version
if self._protocol_version_header:
headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header
return headers
def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
@ -156,29 +122,12 @@ class StreamableHTTPTransport:
self.session_id = new_session_id
logger.info(f"Received session ID: {self.session_id}")
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:
# Only a modern constructor pin reaches here (pre-2026 values are dropped
# in __init__), and a modern pin never sends initialize.
return
if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch
try:
# Parse the result as InitializeResult for type safety
init_result = InitializeResult.model_validate(message.result, by_name=False)
self.protocol_version = init_result.protocol_version
logger.info(f"Negotiated protocol version: {self.protocol_version}")
except Exception: # pragma: no cover
logger.warning("Failed to parse initialization response as InitializeResult", exc_info=True)
logger.warning(f"Raw result: {message.result}")
async def _handle_sse_event(
self,
sse: ServerSentEvent,
read_stream_writer: StreamWriter,
original_request_id: RequestId | None = None,
resumption_callback: Callable[[str], Awaitable[None]] | None = None,
is_initialization: bool = False,
) -> bool:
"""Handle an SSE event, returning True if the response is complete."""
if sse.event == "message":
@ -192,10 +141,6 @@ class StreamableHTTPTransport:
message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False)
logger.debug(f"SSE message: {message}")
# Extract protocol version from initialization response
if is_initialization:
self._maybe_extract_protocol_version_from_message(message)
# If this is a response and we have original_request_id, replace it
if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError):
message.id = original_request_id
@ -299,10 +244,17 @@ class StreamableHTTPTransport:
async def _handle_post_request(self, ctx: RequestContext) -> None:
"""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)
if is_initialization:
# `initialize` is the negotiation, not a "subsequent request" — discard any
# probe-stamped value so the discover→fallback path can't leak it onto the handshake.
self._protocol_version_header = None
headers = self._prepare_headers()
if ctx.metadata is not None and ctx.metadata.headers is not None:
headers.update(ctx.metadata.headers)
if MCP_PROTOCOL_VERSION_HEADER in ctx.metadata.headers:
self._protocol_version_header = ctx.metadata.headers[MCP_PROTOCOL_VERSION_HEADER]
async with ctx.client.stream(
"POST",
@ -334,7 +286,13 @@ class StreamableHTTPTransport:
pass
logger.debug("Non-2xx body was not a JSON-RPC error; using fallback")
if response.status_code == 404:
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
if self.session_id is None:
# No session yet → 404 is the HTTP-level spelling of
# METHOD_NOT_FOUND (gateway / legacy server doesn't know
# this method); "Session terminated" would be a lie here.
error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found")
else:
error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated")
else:
error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")
session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
@ -349,11 +307,9 @@ class StreamableHTTPTransport:
if isinstance(message, JSONRPCRequest):
content_type = response.headers.get("content-type", "").lower()
if content_type.startswith("application/json"):
await self._handle_json_response(
response, ctx.read_stream_writer, is_initialization, request_id=message.id
)
await self._handle_json_response(response, ctx.read_stream_writer, request_id=message.id)
elif content_type.startswith("text/event-stream"):
await self._handle_sse_response(response, ctx, is_initialization)
await self._handle_sse_response(response, ctx)
else:
logger.error(f"Unexpected content type: {content_type}")
error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}")
@ -364,7 +320,6 @@ class StreamableHTTPTransport:
self,
response: httpx.Response,
read_stream_writer: StreamWriter,
is_initialization: bool = False,
*,
request_id: RequestId,
) -> None:
@ -372,11 +327,6 @@ class StreamableHTTPTransport:
try:
content = await response.aread()
message = jsonrpc_message_adapter.validate_json(content, by_name=False)
# Extract protocol version from initialization response
if is_initialization:
self._maybe_extract_protocol_version_from_message(message)
session_message = SessionMessage(message)
await read_stream_writer.send(session_message)
except (httpx.StreamError, ValidationError) as exc:
@ -389,7 +339,6 @@ class StreamableHTTPTransport:
self,
response: httpx.Response,
ctx: RequestContext,
is_initialization: bool = False,
) -> None:
"""Handle SSE response from the server."""
last_event_id: str | None = None
@ -416,7 +365,6 @@ class StreamableHTTPTransport:
ctx.read_stream_writer,
original_request_id=original_request_id,
resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None),
is_initialization=is_initialization,
)
# If the SSE event indicates completion, like returning response/error
# break the loop
@ -581,7 +529,6 @@ 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.
@ -591,8 +538,6 @@ 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:
@ -610,7 +555,7 @@ async def streamable_http_client(
# Create default client with recommended MCP timeouts
client = create_mcp_http_client()
transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
transport = StreamableHTTPTransport(url)
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")

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

View file

@ -73,9 +73,9 @@ class BaseContext(Generic[TransportT]):
"""
return await self._dctx.send_raw_request(method, params, opts)
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 notification to the peer on the back-channel."""
await self._dctx.notify(method, params)
await self._dctx.notify(method, params, opts)
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
"""Report progress for this request, if the peer supplied a progress token.

View file

@ -9,8 +9,10 @@ serialization, no JSON-RPC framing, and no streams. It exists to:
(`ServerRunner`, `Context`, `Connection`) without wire-level moving parts
* embed a server in-process when the JSON-RPC overhead is unnecessary
Unlike `JSONRPCDispatcher`, exceptions raised in a handler propagate directly
to the caller - there is no exception-to-`ErrorData` boundary here.
Like `JSONRPCDispatcher`, this is an exception-to-error boundary: a handler
exception surfaces to the caller as `MCPError`. The `raise_handler_exceptions`
knob controls whether unmapped exceptions are sanitized (matching the wire
path) or chained as ``__cause__`` for in-process debugging.
"""
from __future__ import annotations
@ -24,6 +26,7 @@ import anyio
import anyio.abc
from pydantic import ValidationError
from mcp.shared._compat import resync_tracer
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.message import MessageMetadata
@ -63,7 +66,7 @@ class _DirectDispatchContext:
def can_send_request(self) -> bool:
return self.transport.can_send_request
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:
await self._back_notify(method, params)
async def send_raw_request(
@ -96,8 +99,9 @@ class DirectDispatcher:
they are silently dropped.
"""
def __init__(self, transport_ctx: TransportContext):
def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: bool = True):
self._transport_ctx = transport_ctx
self._raise_handler_exceptions = raise_handler_exceptions
self._peer: DirectDispatcher | None = None
self._on_request: OnRequest | None = None
self._on_notify: OnNotify | None = None
@ -133,12 +137,13 @@ class DirectDispatcher:
raise RuntimeError("DirectDispatcher.send_raw_request called before run()")
return await self._peer._dispatch_request(method, params, opts)
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 notification by invoking the peer's `on_notify` directly.
Fire-and-forget: usable before `run()` (delivery waits for the peer to
start), and after close it is silently dropped, matching
`JSONRPCDispatcher.notify`.
`JSONRPCDispatcher.notify`. `opts` is accepted for `Dispatcher`
conformance; there is no HTTP layer here so `headers` is ignored.
"""
if self._peer is None:
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")
@ -234,12 +239,21 @@ class DirectDispatcher:
# tests see what runner-over-JSONRPC would.
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") from e
except Exception as e:
raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e
# Single owner of the in-proc exception-to-error policy (mirrors
# JSONRPCDispatcher / `_streamable_http_modern._to_jsonrpc_response`
# for the wire paths). True chains the original for in-process
# debugging; False sanitizes to match the wire path's leak guard.
if self._raise_handler_exceptions:
raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e
logger.exception("request handler raised")
raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None
except TimeoutError:
raise MCPError(
code=REQUEST_TIMEOUT,
message=f"Timed out after {opts.get('timeout')}s waiting for {method!r}",
) from None
finally:
await resync_tracer()
async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) -> None:
try:
@ -258,6 +272,7 @@ def create_direct_dispatcher_pair(
*,
can_send_request: bool = True,
headers: Mapping[str, str] | None = None,
raise_handler_exceptions: bool = True,
) -> tuple[DirectDispatcher, DirectDispatcher]:
"""Create two `DirectDispatcher` instances wired to each other.
@ -265,14 +280,19 @@ def create_direct_dispatcher_pair(
can_send_request: Sets `TransportContext.can_send_request` on both
sides. Pass `False` to simulate a transport with no back-channel.
headers: Sets `TransportContext.headers` on both sides.
raise_handler_exceptions: When `True` (the default - this is an
in-process debugging substrate), an unmapped handler exception
reaches the caller as `MCPError` with the original chained as
``__cause__``. When `False` it is sanitized to an opaque
`INTERNAL_ERROR` so the in-process path matches the wire.
Returns:
A `(client, server)` pair. The wiring is symmetric, so the roles
are conventional only.
"""
ctx = TransportContext(kind=DIRECT_TRANSPORT_KIND, can_send_request=can_send_request, headers=headers)
client = DirectDispatcher(ctx)
server = DirectDispatcher(ctx)
client = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions)
server = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions)
client.connect_to(server)
server.connect_to(client)
return client, server

View file

@ -85,6 +85,9 @@ class CallOptions(TypedDict, total=False):
resumption is removed in the next protocol revision.
"""
headers: dict[str, str]
"""Transport-layer hint: HTTP transports merge these onto the outgoing request; non-HTTP transports ignore."""
@runtime_checkable
class Outbound(Protocol):
@ -111,7 +114,7 @@ class Outbound(Protocol):
"""
...
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 fire-and-forget notification."""
...

View file

@ -8,6 +8,8 @@ rejection's ``code`` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP
status.
"""
import base64
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
@ -34,13 +36,33 @@ __all__ = [
"ERROR_CODE_HTTP_STATUS",
"InboundLadderRejection",
"InboundModernRoute",
"MCP_METHOD_HEADER",
"MCP_NAME_HEADER",
"MCP_PROTOCOL_VERSION_HEADER",
"classify_inbound_request",
"encode_header_value",
]
MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version"
"""Canonical lowercase name of the HTTP header carrying the MCP protocol version."""
MCP_METHOD_HEADER: Final = "mcp-method"
"""Canonical lowercase name of the HTTP header carrying the JSON-RPC method."""
MCP_NAME_HEADER: Final = "mcp-name"
"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt/resource URI)."""
_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')}?="
# INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to
# -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66).
ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType(

View file

@ -75,7 +75,7 @@ def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
with empty ``data`` (no pydantic text on the wire). Returns ``None`` for
any other exception so each caller applies its own catch-all -
`JSONRPCDispatcher` currently pins ``code=0`` for v1 compat,
`to_jsonrpc_response` uses `INTERNAL_ERROR`.
the modern HTTP entry uses `INTERNAL_ERROR`.
"""
if isinstance(exc, MCPError):
return exc.error
@ -132,11 +132,11 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
def can_send_request(self) -> bool:
return self.transport.can_send_request and not self._closed
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:
if self._closed:
logger.debug("dropped %s: dispatch context closed", method)
return
await self._dispatcher.notify(method, params, _related_request_id=self._request_id)
await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id)
async def send_raw_request(
self,
@ -209,6 +209,7 @@ def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | Non
cancel_on_abandon = opts.get("cancel_on_abandon", True)
token = opts.get("resumption_token")
on_token = opts.get("on_resumption_token")
headers = opts.get("headers")
if related_request_id is not None:
if token is not None or on_token is not None:
logger.debug(
@ -217,9 +218,11 @@ def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | Non
return _OutboundPlan(ServerMessageMetadata(related_request_id=related_request_id), cancel_on_abandon)
if token is not None or on_token is not None:
return _OutboundPlan(
ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token),
ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token, headers=headers),
cancel_on_abandon=False,
)
if headers:
return _OutboundPlan(ClientMessageMetadata(headers=headers), cancel_on_abandon)
return _OutboundPlan(None, cancel_on_abandon)
@ -265,7 +268,10 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode
self._raise_handler_exceptions = raise_handler_exceptions
self._inline_methods = inline_methods
self._on_stream_exception = on_stream_exception
self.on_stream_exception = on_stream_exception
"""Observer for ``Exception`` items on the read stream. Mutable so a session can
bind it after the dispatcher is built (e.g. ``ClientSession`` routing into
``message_handler``); only consulted inside ``run()`` so pre-enter assignment is safe."""
self._next_id = 0
self._pending: dict[RequestId, _Pending] = {}
@ -395,6 +401,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
*,
_related_request_id: RequestId | None = None,
) -> None:
@ -414,7 +421,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
else:
msg = JSONRPCNotification(jsonrpc="2.0", method=method)
try:
await self._write(msg, _plan_outbound(_related_request_id, None).metadata)
await self._write(msg, _plan_outbound(_related_request_id, opts).metadata)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
# Transport tore down before run() noticed EOF.
logger.debug("dropped %s: write stream closed", method)
@ -480,11 +487,11 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
are awaited; any other `await` would head-of-line block the read loop.
"""
if isinstance(item, Exception):
if self._on_stream_exception is None:
if self.on_stream_exception is None:
logger.debug("transport yielded exception: %r", item)
return
try:
await self._on_stream_exception(item)
await self.on_stream_exception(item)
except Exception:
logger.exception("on_stream_exception observer raised")
return

View file

@ -24,6 +24,8 @@ class ClientMessageMetadata:
resumption_token: ResumptionToken | None = None
on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None
# Per-message HTTP headers (e.g. MCP-Protocol-Version, Mcp-Method) the transport should set.
headers: dict[str, str] | None = None
@dataclass
@ -35,9 +37,6 @@ class ServerMessageMetadata:
# transports, None for stdio). Typed as Any because the server layer is
# transport-agnostic.
request_context: Any = None
# Per-message protocol version observed by the transport (e.g. the
# validated MCP-Protocol-Version header).
protocol_version: str | None = None
# Callback to close SSE stream for the current request without terminating
close_sse_stream: CloseSSEStreamCallback | None = None
# Callback to close the standalone GET SSE stream (for unsolicited notifications)

Some files were not shown because too many files have changed in this diff Show more