[v2] ClientSession runs on JSONRPCDispatcher; BaseSession removed (#2838)
This commit is contained in:
+19
-6
@@ -634,11 +634,9 @@ server = Server("my-server", on_call_tool=handle_call_tool)
|
||||
|
||||
The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`).
|
||||
|
||||
The `RequestContext` class has been split to separate shared fields from server-specific fields. The shared `RequestContext` now only takes 1 type parameter (the session type) instead of 3.
|
||||
|
||||
**`RequestContext` changes:**
|
||||
|
||||
- Type parameters reduced from `RequestContext[SessionT, LifespanContextT, RequestT]` to `RequestContext[SessionT]`
|
||||
- The `RequestContext[SessionT, LifespanContextT, RequestT]` generic no longer exists; use `ClientRequestContext` or `ServerRequestContext[LifespanContextT, RequestT]`
|
||||
- Server-specific fields (`lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context`
|
||||
|
||||
**Before (v1):**
|
||||
@@ -1122,9 +1120,9 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar
|
||||
)
|
||||
```
|
||||
|
||||
### `RequestContext`: request-specific fields are now optional
|
||||
### `ServerRequestContext`: request-specific fields are now optional
|
||||
|
||||
The `RequestContext` class now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`.
|
||||
`ServerRequestContext` now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`.
|
||||
|
||||
```python
|
||||
from mcp.server import ServerRequestContext
|
||||
@@ -1164,7 +1162,22 @@ In practice, replace direct `ServerSession` use with `Server.run(read_stream, wr
|
||||
|
||||
`BaseSession._in_flight` and the `RequestResponder` members that supported it (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument, and the internal `CancelScope`) have been removed. These existed to let `ServerSession` cancel a handler when a `CancelledNotification` arrived; `ServerSession` no longer drives a receive loop, so they were dead code. Inbound-cancellation handling for the server now lives in `JSONRPCDispatcher`.
|
||||
|
||||
`BaseSession` is still used by `ClientSession`, which never relied on these members. `RequestResponder.respond()` is unchanged.
|
||||
`BaseSession` itself has since been removed entirely; see the next section.
|
||||
|
||||
### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed
|
||||
|
||||
`ClientSession`'s public surface is unchanged — same constructor, typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding).
|
||||
|
||||
Behavior changes:
|
||||
|
||||
- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves.
|
||||
- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running.
|
||||
- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1.
|
||||
- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works.
|
||||
- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected.
|
||||
- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the private `mcp.shared._context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession]` become `ClientRequestContext`.
|
||||
|
||||
`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists.
|
||||
|
||||
### Experimental Tasks support removed
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, Logg
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.shared.session import ProgressFnT
|
||||
from mcp.shared.dispatcher import ProgressFnT
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
CompleteResult,
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
"""Request context for MCP client handlers."""
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.client.session import ClientRequestContext
|
||||
|
||||
ClientRequestContext = RequestContext[ClientSession]
|
||||
"""Context for handling incoming requests in a client session.
|
||||
|
||||
This context is passed to client-side callbacks (sampling, elicitation, list_roots) when the server sends requests
|
||||
to the client.
|
||||
|
||||
Attributes:
|
||||
request_id: The unique identifier for this request.
|
||||
meta: Optional metadata associated with the request.
|
||||
session: The client session handling this request.
|
||||
"""
|
||||
__all__ = ["ClientRequestContext"]
|
||||
|
||||
+204
-73
@@ -1,28 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import TracebackType
|
||||
from typing import Any, Protocol, cast, get_args
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
import anyio.lowlevel
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from typing_extensions import Self, TypeVar
|
||||
|
||||
from mcp import types
|
||||
from mcp.client._transport import ReadStream, WriteStream
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import BaseSession, ProgressFnT, RequestResponder
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
|
||||
from mcp.shared.exceptions import MCPError
|
||||
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._types import RequestParamsMeta
|
||||
from mcp.types import RequestId, RequestParamsMeta
|
||||
|
||||
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
|
||||
|
||||
logger = logging.getLogger("client")
|
||||
|
||||
ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ClientRequestContext:
|
||||
"""Context for a server-initiated request, passed to the sampling/elicitation/list-roots callbacks."""
|
||||
|
||||
session: ClientSession
|
||||
request_id: RequestId
|
||||
meta: RequestParamsMeta | None = None
|
||||
|
||||
|
||||
class SamplingFnT(Protocol):
|
||||
async def __call__(
|
||||
self,
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: ... # pragma: no branch
|
||||
|
||||
@@ -30,14 +51,14 @@ class SamplingFnT(Protocol):
|
||||
class ElicitationFnT(Protocol):
|
||||
async def __call__(
|
||||
self,
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.ElicitRequestParams,
|
||||
) -> types.ElicitResult | types.ErrorData: ... # pragma: no branch
|
||||
|
||||
|
||||
class ListRootsFnT(Protocol):
|
||||
async def __call__(
|
||||
self, context: RequestContext[ClientSession]
|
||||
self, context: ClientRequestContext
|
||||
) -> types.ListRootsResult | types.ErrorData: ... # pragma: no branch
|
||||
|
||||
|
||||
@@ -59,7 +80,7 @@ async def _default_message_handler(
|
||||
|
||||
|
||||
async def _default_sampling_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData:
|
||||
return types.ErrorData(
|
||||
@@ -69,7 +90,7 @@ async def _default_sampling_callback(
|
||||
|
||||
|
||||
async def _default_elicitation_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.ElicitRequestParams,
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
return types.ErrorData(
|
||||
@@ -79,7 +100,7 @@ async def _default_elicitation_callback(
|
||||
|
||||
|
||||
async def _default_list_roots_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
) -> types.ListRootsResult | types.ErrorData:
|
||||
return types.ErrorData(
|
||||
code=types.INVALID_REQUEST,
|
||||
@@ -104,19 +125,21 @@ spec methods this SDK deliberately doesn't model, like `tasks/*` — are
|
||||
answered with METHOD_NOT_FOUND instead of failing union validation."""
|
||||
|
||||
|
||||
class ClientSession(
|
||||
BaseSession[
|
||||
types.ClientRequest,
|
||||
types.ClientNotification,
|
||||
types.ClientResult,
|
||||
types.ServerRequest,
|
||||
types.ServerNotification,
|
||||
]
|
||||
):
|
||||
class ClientSession:
|
||||
"""Client half of an MCP connection, running on a `Dispatcher`.
|
||||
|
||||
Construct it over a transport's stream pair (or pass a pre-built
|
||||
`dispatcher=`), enter as an async context manager, then call
|
||||
`initialize()`. The dispatcher owns the receive loop and request
|
||||
correlation; this class owns the typed MCP layer and the constructor
|
||||
callbacks. Transport `Exception` items reach `message_handler` only when
|
||||
the session builds its own dispatcher from a stream pair.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
write_stream: WriteStream[SessionMessage],
|
||||
read_stream: ReadStream[SessionMessage | Exception] | None = None,
|
||||
write_stream: WriteStream[SessionMessage] | None = None,
|
||||
read_timeout_seconds: float | None = None,
|
||||
sampling_callback: SamplingFnT | None = None,
|
||||
elicitation_callback: ElicitationFnT | None = None,
|
||||
@@ -126,8 +149,9 @@ class ClientSession(
|
||||
client_info: types.Implementation | None = None,
|
||||
*,
|
||||
sampling_capabilities: types.SamplingCapability | None = None,
|
||||
dispatcher: Dispatcher[Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(read_stream, write_stream, read_timeout_seconds=read_timeout_seconds)
|
||||
self._session_read_timeout_seconds = read_timeout_seconds
|
||||
self._client_info = client_info or DEFAULT_CLIENT_INFO
|
||||
self._sampling_callback = sampling_callback or _default_sampling_callback
|
||||
self._sampling_capabilities = sampling_capabilities
|
||||
@@ -137,18 +161,99 @@ class ClientSession(
|
||||
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._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
|
||||
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")
|
||||
# Built eagerly so notifications can be sent before entering the context manager.
|
||||
self._dispatcher = JSONRPCDispatcher(
|
||||
read_stream, write_stream, on_stream_exception=self._on_stream_exception
|
||||
)
|
||||
|
||||
@property
|
||||
def _receive_request_adapter(self) -> TypeAdapter[types.ServerRequest]:
|
||||
return types.server_request_adapter
|
||||
async def __aenter__(self) -> Self:
|
||||
self._task_group = anyio.create_task_group()
|
||||
await self._task_group.__aenter__()
|
||||
try:
|
||||
await self._task_group.start(self._dispatcher.run, self._on_request, self._on_notify)
|
||||
except BaseException:
|
||||
# Unwind the entered task group before propagating: a cancellation
|
||||
# landing here (e.g. `move_on_after` around connect) would abandon
|
||||
# it and anyio would later raise "exited non-innermost cancel scope".
|
||||
task_group = self._task_group
|
||||
self._task_group = None
|
||||
task_group.cancel_scope.cancel()
|
||||
# Shield the group's own scope (a new one would break LIFO exit)
|
||||
# so a pending outer cancellation cannot re-fire inside __aexit__.
|
||||
task_group.cancel_scope.shield = True
|
||||
await task_group.__aexit__(None, None, None)
|
||||
raise
|
||||
return self
|
||||
|
||||
@property
|
||||
def _receive_request_methods(self) -> frozenset[str]:
|
||||
return _SERVER_REQUEST_METHODS
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> bool | None:
|
||||
# Exit must not block: cancel the dispatcher and in-flight callbacks.
|
||||
assert self._task_group is not None
|
||||
self._task_group.cancel_scope.cancel()
|
||||
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
|
||||
await resync_tracer()
|
||||
return result
|
||||
|
||||
@property
|
||||
def _receive_notification_adapter(self) -> TypeAdapter[types.ServerNotification]:
|
||||
return types.server_notification_adapter
|
||||
async def send_request(
|
||||
self,
|
||||
request: types.ClientRequest,
|
||||
result_type: type[ReceiveResultT],
|
||||
request_read_timeout_seconds: float | None = None,
|
||||
metadata: ClientMessageMetadata | None = None,
|
||||
progress_callback: ProgressFnT | None = None,
|
||||
) -> ReceiveResultT:
|
||||
"""Send a request and wait for its typed result.
|
||||
|
||||
Args:
|
||||
metadata: Streamable HTTP resumption hints.
|
||||
|
||||
Raises:
|
||||
MCPError: Error response, read timeout, or connection closed.
|
||||
RuntimeError: Called before entering the context manager.
|
||||
"""
|
||||
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
method: str = data["method"]
|
||||
opts: CallOptions = {}
|
||||
timeout = (
|
||||
request_read_timeout_seconds
|
||||
if request_read_timeout_seconds is not None
|
||||
else self._session_read_timeout_seconds
|
||||
)
|
||||
if timeout is not None:
|
||||
opts["timeout"] = timeout
|
||||
if progress_callback is not None:
|
||||
opts["on_progress"] = progress_callback
|
||||
if metadata is not None:
|
||||
if metadata.resumption_token is not None:
|
||||
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)
|
||||
return result_type.model_validate(raw, by_name=False)
|
||||
|
||||
async def send_notification(self, notification: types.ClientNotification) -> None:
|
||||
"""Send a one-way notification. Usable before entering the context manager.
|
||||
|
||||
Fire-and-forget: after the connection has closed, the notification is
|
||||
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"))
|
||||
|
||||
async def initialize(self) -> types.InitializeResult:
|
||||
sampling = (
|
||||
@@ -397,49 +502,75 @@ class ClientSession(
|
||||
"""Send a roots/list_changed notification."""
|
||||
await self.send_notification(types.RootsListChangedNotification())
|
||||
|
||||
async def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None:
|
||||
ctx = RequestContext[ClientSession](request_id=responder.request_id, meta=responder.request_meta, session=self)
|
||||
async def _on_request(
|
||||
self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""Answer a server-initiated request via the registered callbacks."""
|
||||
if method not in _SERVER_REQUEST_METHODS:
|
||||
raise MCPError(code=types.METHOD_NOT_FOUND, message="Method not found", data=method)
|
||||
payload: dict[str, Any] = {"method": method}
|
||||
if params is not None:
|
||||
payload["params"] = dict(params)
|
||||
request = types.server_request_adapter.validate_python(payload, by_name=False)
|
||||
|
||||
match responder.request:
|
||||
case types.CreateMessageRequest(params=params):
|
||||
with responder:
|
||||
response = await self._sampling_callback(ctx, params)
|
||||
client_response = ClientResponse.validate_python(response)
|
||||
await responder.respond(client_response)
|
||||
|
||||
case types.ElicitRequest(params=params):
|
||||
with responder:
|
||||
response = await self._elicitation_callback(ctx, params)
|
||||
client_response = ClientResponse.validate_python(response)
|
||||
await responder.respond(client_response)
|
||||
|
||||
case types.ListRootsRequest():
|
||||
with responder:
|
||||
response: types.ClientResult | types.ErrorData
|
||||
if isinstance(request, types.PingRequest):
|
||||
# Answered without a context: ping has no callback that would need one.
|
||||
response = types.EmptyResult()
|
||||
else:
|
||||
assert dctx.request_id is not None # the callback-driving dispatchers always assign ids
|
||||
ctx = ClientRequestContext(
|
||||
session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None
|
||||
)
|
||||
match request:
|
||||
case types.CreateMessageRequest(params=sampling_params):
|
||||
response = await self._sampling_callback(ctx, sampling_params)
|
||||
case types.ElicitRequest(params=elicit_params):
|
||||
response = await self._elicitation_callback(ctx, elicit_params)
|
||||
case types.ListRootsRequest(): # pragma: no branch
|
||||
response = await self._list_roots_callback(ctx)
|
||||
client_response = ClientResponse.validate_python(response)
|
||||
await responder.respond(client_response)
|
||||
client_response = ClientResponse.validate_python(response)
|
||||
if isinstance(client_response, types.ErrorData):
|
||||
raise MCPError.from_error_data(client_response)
|
||||
return client_response.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
|
||||
case types.PingRequest(): # pragma: no branch
|
||||
with responder:
|
||||
await responder.respond(types.EmptyResult())
|
||||
|
||||
async def _handle_incoming(
|
||||
self,
|
||||
req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||
async def _on_notify(
|
||||
self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
|
||||
) -> None:
|
||||
"""Handle incoming messages by forwarding to the message handler."""
|
||||
await self._message_handler(req)
|
||||
"""Route a server notification: validate, run the typed callback, tee to message_handler."""
|
||||
payload: dict[str, Any] = {"method": method}
|
||||
if params is not None:
|
||||
payload["params"] = dict(params)
|
||||
try:
|
||||
notification = types.server_notification_adapter.validate_python(payload, by_name=False)
|
||||
except ValidationError:
|
||||
logger.warning("Failed to validate notification: %s", payload, exc_info=True)
|
||||
return
|
||||
if isinstance(notification, types.CancelledNotification):
|
||||
# The dispatcher already applied the cancellation; not surfaced to message_handler.
|
||||
return
|
||||
try:
|
||||
if isinstance(notification, types.LoggingMessageNotification):
|
||||
await self._logging_callback(notification.params)
|
||||
await self._message_handler(notification)
|
||||
except Exception:
|
||||
# Contain here, not in the dispatcher: DirectDispatcher awaits this
|
||||
# handler inline in the peer's notify() call, so a raising callback
|
||||
# would otherwise fail the peer's send. A raising logging_callback
|
||||
# skips the message_handler tee for that notification (v1 parity).
|
||||
logger.exception("notification callback for %r raised", method)
|
||||
|
||||
async def _received_notification(self, notification: types.ServerNotification) -> None:
|
||||
"""Handle notifications from the server."""
|
||||
# Process specific notification types
|
||||
match notification:
|
||||
case types.LoggingMessageNotification(params=params):
|
||||
await self._logging_callback(params)
|
||||
case types.ElicitCompleteNotification(params=params):
|
||||
# Handle elicitation completion notification
|
||||
# Clients MAY use this to retry requests or update UI
|
||||
# The notification contains the elicitationId of the completed elicitation
|
||||
pass
|
||||
case _:
|
||||
pass
|
||||
async def _on_stream_exception(self, exc: Exception) -> None:
|
||||
"""Deliver a transport-level fault to message_handler via a spawned task.
|
||||
|
||||
Running the handler inline would park the dispatcher's read loop and
|
||||
deadlock handlers that await session I/O.
|
||||
"""
|
||||
assert self._task_group is not None
|
||||
self._task_group.start_soon(self._deliver_stream_exception, exc)
|
||||
|
||||
async def _deliver_stream_exception(self, exc: Exception) -> None:
|
||||
try:
|
||||
await self._message_handler(exc)
|
||||
except Exception:
|
||||
logger.exception("message_handler raised on transport exception")
|
||||
|
||||
@@ -84,7 +84,7 @@ class ServerSession:
|
||||
"""
|
||||
data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
opts: CallOptions = {}
|
||||
if request_read_timeout_seconds:
|
||||
if request_read_timeout_seconds is not None:
|
||||
opts["timeout"] = request_read_timeout_seconds
|
||||
if progress_callback is not None:
|
||||
opts["on_progress"] = progress_callback
|
||||
|
||||
@@ -717,6 +717,9 @@ class StreamableHTTPServerTransport:
|
||||
# Send the message via SSE
|
||||
event_data = self._create_event_data(event_message)
|
||||
await sse_stream_writer.send(event_data)
|
||||
except anyio.ClosedResourceError:
|
||||
# Session teardown can close the stream while the writer is between dequeues.
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("Error in standalone SSE writer") # pragma: no cover
|
||||
finally:
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Request context for MCP handlers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from mcp.shared.session import BaseSession
|
||||
from mcp.types import RequestId, RequestParamsMeta
|
||||
|
||||
SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any])
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class RequestContext(Generic[SessionT]):
|
||||
"""Common context for handling incoming requests.
|
||||
|
||||
For request handlers, request_id is always populated.
|
||||
For notification handlers, request_id is None.
|
||||
"""
|
||||
|
||||
session: SessionT
|
||||
request_id: RequestId | None = None
|
||||
meta: RequestParamsMeta | None = None
|
||||
@@ -15,6 +15,7 @@ to the caller - there is no exception-to-`ErrorData` boundary here.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -27,7 +28,9 @@ from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT
|
||||
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
||||
from mcp.shared.message import MessageMetadata
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.types import INTERNAL_ERROR, INVALID_PARAMS, REQUEST_TIMEOUT, RequestId
|
||||
from mcp.types import CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, REQUEST_TIMEOUT, RequestId
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["DirectDispatcher", "create_direct_dispatcher_pair"]
|
||||
|
||||
@@ -50,7 +53,7 @@ class _DirectDispatchContext:
|
||||
_back_request: _Request
|
||||
_back_notify: _Notify
|
||||
request_id: RequestId | None = None
|
||||
"""Always `None`: direct dispatch has no wire-level request id."""
|
||||
"""A dispatcher-synthesized id for requests; `None` for notifications."""
|
||||
message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework
|
||||
"""Always `None`: in-memory dispatch attaches no transport metadata."""
|
||||
_on_progress: ProgressFnT | None = None
|
||||
@@ -84,6 +87,13 @@ class DirectDispatcher:
|
||||
Two instances are wired together with `create_direct_dispatcher_pair`; each
|
||||
holds a reference to the other. `send_raw_request` on one awaits the peer's
|
||||
`on_request`. `run` parks until `close` is called.
|
||||
|
||||
Lifecycle mirrors `JSONRPCDispatcher`: `send_raw_request` requires `run()`
|
||||
to have started, and once a side has closed - via `close()` or `run()`
|
||||
ending - `send_raw_request` raises `MCPError` (`CONNECTION_CLOSED`) and
|
||||
inbound requests fail the peer's call the same way instead of invoking the
|
||||
handler. Notifications are fire-and-forget in both directions: after close
|
||||
they are silently dropped.
|
||||
"""
|
||||
|
||||
def __init__(self, transport_ctx: TransportContext):
|
||||
@@ -91,8 +101,11 @@ class DirectDispatcher:
|
||||
self._peer: DirectDispatcher | None = None
|
||||
self._on_request: OnRequest | None = None
|
||||
self._on_notify: OnNotify | None = None
|
||||
self._next_id = 0
|
||||
self._ready = anyio.Event()
|
||||
self._closed = anyio.Event()
|
||||
self._close_event = anyio.Event()
|
||||
self._running = False
|
||||
self._closed = False
|
||||
|
||||
def connect_to(self, peer: DirectDispatcher) -> None:
|
||||
self._peer = peer
|
||||
@@ -103,13 +116,35 @@ class DirectDispatcher:
|
||||
params: Mapping[str, Any] | None,
|
||||
opts: CallOptions | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send a request by invoking the peer's `on_request` directly.
|
||||
|
||||
Raises:
|
||||
MCPError: The peer's handler raised; `REQUEST_TIMEOUT` if
|
||||
`opts["timeout"]` elapsed; `CONNECTION_CLOSED` if either
|
||||
side has closed.
|
||||
RuntimeError: Called before `run()`.
|
||||
"""
|
||||
if self._peer is None:
|
||||
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")
|
||||
# Post-close sends get the same CONNECTION_CLOSED contract as JSONRPCDispatcher.
|
||||
if self._closed:
|
||||
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed")
|
||||
if not self._running:
|
||||
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:
|
||||
"""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`.
|
||||
"""
|
||||
if self._peer is None:
|
||||
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")
|
||||
if self._closed:
|
||||
logger.debug("dropped notification %r on closed DirectDispatcher", method)
|
||||
return
|
||||
await self._peer._dispatch_notify(method, params)
|
||||
|
||||
async def run(
|
||||
@@ -119,37 +154,77 @@ class DirectDispatcher:
|
||||
*,
|
||||
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
||||
) -> None:
|
||||
self._on_request = on_request
|
||||
self._on_notify = on_notify
|
||||
self._ready.set()
|
||||
task_status.started()
|
||||
await self._closed.wait()
|
||||
"""Mark this side ready and park until `close()` is called.
|
||||
|
||||
Single-shot, like `JSONRPCDispatcher.run`: once it returns the
|
||||
dispatcher stays closed and cannot be restarted.
|
||||
"""
|
||||
try:
|
||||
self._on_request = on_request
|
||||
self._on_notify = on_notify
|
||||
self._running = True
|
||||
self._ready.set()
|
||||
task_status.started()
|
||||
await self._close_event.wait()
|
||||
finally:
|
||||
self._running = False
|
||||
self._closed = True
|
||||
# run() may end via cancellation without close() ever being
|
||||
# called; setting the event wakes `_wait_ready` waiters so they
|
||||
# observe the closed state instead of parking forever.
|
||||
self._close_event.set()
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed.set()
|
||||
self._closed = True
|
||||
self._close_event.set()
|
||||
|
||||
def _make_context(self, on_progress: ProgressFnT | None = None) -> _DirectDispatchContext:
|
||||
def _make_context(
|
||||
self, on_progress: ProgressFnT | None = None, request_id: RequestId | None = None
|
||||
) -> _DirectDispatchContext:
|
||||
assert self._peer is not None
|
||||
peer = self._peer
|
||||
return _DirectDispatchContext(
|
||||
transport=self._transport_ctx,
|
||||
_back_request=lambda m, p, o: peer._dispatch_request(m, p, o),
|
||||
_back_notify=lambda m, p: peer._dispatch_notify(m, p),
|
||||
request_id=request_id,
|
||||
_on_progress=on_progress,
|
||||
)
|
||||
|
||||
async def _wait_ready(self) -> None:
|
||||
"""Park until `run()` has started, waking early if this side closes.
|
||||
|
||||
Raises:
|
||||
MCPError: `CONNECTION_CLOSED` if this side has closed.
|
||||
"""
|
||||
if not self._ready.is_set() and not self._close_event.is_set():
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def wake_on(event: anyio.Event) -> None:
|
||||
await event.wait()
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
tg.start_soon(wake_on, self._ready)
|
||||
tg.start_soon(wake_on, self._close_event)
|
||||
if self._closed:
|
||||
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed")
|
||||
|
||||
async def _dispatch_request(
|
||||
self,
|
||||
method: str,
|
||||
params: Mapping[str, Any] | None,
|
||||
opts: CallOptions | None,
|
||||
) -> dict[str, Any]:
|
||||
await self._ready.wait()
|
||||
assert self._on_request is not None
|
||||
opts = opts or {}
|
||||
dctx = self._make_context(on_progress=opts.get("on_progress"))
|
||||
try:
|
||||
with anyio.fail_after(opts.get("timeout")):
|
||||
# Inside the timeout scope, so a configured timeout also bounds
|
||||
# waiting on a peer whose run() has not started yet.
|
||||
await self._wait_ready()
|
||||
assert self._on_request is not None
|
||||
# Synthesize an id: the DispatchContext contract reserves None for notifications.
|
||||
self._next_id += 1
|
||||
dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=self._next_id)
|
||||
try:
|
||||
return await self._on_request(dctx, method, params)
|
||||
except MCPError:
|
||||
@@ -167,7 +242,13 @@ class DirectDispatcher:
|
||||
) from None
|
||||
|
||||
async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
await self._ready.wait()
|
||||
try:
|
||||
await self._wait_ready()
|
||||
except MCPError:
|
||||
# Notifications are fire-and-forget: a notify to a closed peer is
|
||||
# dropped, not raised back into the sender's call.
|
||||
logger.debug("dropped notification %r to closed DirectDispatcher", method)
|
||||
return
|
||||
assert self._on_notify is not None
|
||||
dctx = self._make_context()
|
||||
await self._on_notify(dctx, method, params)
|
||||
|
||||
@@ -55,6 +55,13 @@ class CallOptions(TypedDict, total=False):
|
||||
timeout: float
|
||||
"""Seconds to wait for a result before raising and sending `notifications/cancelled`."""
|
||||
|
||||
cancel_on_abandon: bool
|
||||
"""Whether abandoning this request (timeout or caller cancellation) sends `notifications/cancelled`.
|
||||
|
||||
Defaults to `True`. Set `False` for requests the protocol forbids cancelling, such as `initialize`.
|
||||
Also suppressed when resumption hints reach the transport, or when the request was never written.
|
||||
"""
|
||||
|
||||
on_progress: ProgressFnT
|
||||
"""Receive `notifications/progress` updates for this request."""
|
||||
|
||||
@@ -97,9 +104,6 @@ class Outbound(Protocol):
|
||||
) -> dict[str, Any]:
|
||||
"""Send a request and await its raw result dict.
|
||||
|
||||
`opts` carries per-call `timeout` / `on_progress` / resumption hints;
|
||||
see `CallOptions`.
|
||||
|
||||
Raises:
|
||||
MCPError: If the peer responded with an error, or the handler
|
||||
raised. Implementations normalize all handler exceptions to
|
||||
@@ -187,6 +191,8 @@ class Dispatcher(Outbound, Protocol[TransportT_co]):
|
||||
|
||||
Implementations own correlation of outbound requests to inbound results, the
|
||||
receive loop, per-request concurrency, and cancellation/progress wiring.
|
||||
|
||||
The lifecycle surface is provisional; `run()` may change before v2 stable.
|
||||
"""
|
||||
|
||||
async def run(
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
"""JSON-RPC `Dispatcher` implementation.
|
||||
"""JSON-RPC `Dispatcher` over the `SessionMessage` stream contract all transports speak.
|
||||
|
||||
Consumes the existing `SessionMessage`-based stream contract that all current
|
||||
transports (stdio, SSE, streamable HTTP) speak. Owns request-id correlation,
|
||||
the receive loop, per-request task isolation, cancellation/progress wiring, and
|
||||
the single exception-to-wire boundary.
|
||||
|
||||
The MCP type layer (`ServerRunner`, `Context`, `Client`) sits above this and
|
||||
sees only `(ctx, method, params) -> dict`. Transports sit below and see only
|
||||
`SessionMessage` reads/writes.
|
||||
|
||||
The dispatcher is *mostly* MCP-agnostic - methods/params are opaque strings and
|
||||
dicts - but it intercepts `notifications/cancelled` and
|
||||
`notifications/progress` because request correlation, cancellation and
|
||||
progress are exactly the wiring this layer exists to provide. Those few wire
|
||||
shapes are extracted with structural `match` patterns (no casts, no
|
||||
`mcp.types` model coupling); a malformed payload simply fails to match and
|
||||
the correlation is skipped.
|
||||
Owns request-id correlation, the receive loop, per-request task isolation,
|
||||
cancellation/progress wiring, and the single exception-to-wire boundary;
|
||||
methods and params are otherwise opaque strings and dicts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,17 +11,21 @@ import contextvars
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Generic, Literal, TypeVar, cast, overload
|
||||
from functools import partial
|
||||
from typing import Any, Generic, Literal, cast
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
import anyio.lowlevel
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from opentelemetry.trace import SpanKind
|
||||
from pydantic import ValidationError
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared._otel import inject_trace_context, otel_span
|
||||
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||
from mcp.shared.dispatcher import CallOptions, Dispatcher, OnNotify, OnRequest, ProgressFnT
|
||||
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest, ProgressFnT
|
||||
from mcp.shared.exceptions import MCPError, NoBackChannelError
|
||||
from mcp.shared.message import (
|
||||
ClientMessageMetadata,
|
||||
@@ -47,7 +38,6 @@ from mcp.types import (
|
||||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
REQUEST_CANCELLED,
|
||||
REQUEST_TIMEOUT,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
@@ -63,23 +53,22 @@ __all__ = ["JSONRPCDispatcher"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TransportT = TypeVar("TransportT", bound=TransportContext)
|
||||
_ABANDON_WRITE_TIMEOUT: float = 5
|
||||
"""Bound for courtesy-cancel writes on the abandon paths; the caller-cancel
|
||||
arm shields its write, so a wedged transport would otherwise hang it uncancellably."""
|
||||
|
||||
_SHUTDOWN_WRITE_TIMEOUT: float = 1
|
||||
"""Tighter bound for the shutdown-arm error write so a wedged transport can't hold session close."""
|
||||
|
||||
TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportContext)
|
||||
|
||||
PeerCancelMode = Literal["interrupt", "signal"]
|
||||
"""How inbound `notifications/cancelled` is applied to a running handler.
|
||||
|
||||
`"interrupt"` (default) cancels the handler's scope. `"signal"` only sets
|
||||
`ctx.cancel_requested` and lets the handler observe it cooperatively.
|
||||
"""
|
||||
"""How `notifications/cancelled` is applied: `"interrupt"` (default) cancels
|
||||
the handler's scope; `"signal"` only sets `ctx.cancel_requested`."""
|
||||
|
||||
|
||||
def _coerce_id(request_id: RequestId) -> RequestId:
|
||||
"""Coerce a string request ID to int when it's a valid int literal.
|
||||
|
||||
`_allocate_id` only ever produces `int` keys for `_pending`, but a peer
|
||||
may echo the ID back as a JSON string. The TypeScript SDK and `BaseSession`
|
||||
both perform this coercion at lookup time so the response still correlates.
|
||||
"""
|
||||
"""Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK)."""
|
||||
if isinstance(request_id, str):
|
||||
try:
|
||||
return int(request_id)
|
||||
@@ -113,12 +102,7 @@ class _JSONRPCDispatchContext(Generic[TransportT]):
|
||||
_dispatcher: JSONRPCDispatcher[TransportT]
|
||||
_request_id: RequestId | None
|
||||
message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework
|
||||
"""The transport-attached `SessionMessage.metadata` for this inbound message.
|
||||
|
||||
Carries `ServerMessageMetadata` (HTTP request, SSE stream-close callbacks)
|
||||
that the server lifts onto its request context. `None` for transports
|
||||
that attach nothing.
|
||||
"""
|
||||
"""Transport-attached `SessionMessage.metadata` that the server lifts onto its request context."""
|
||||
_progress_token: ProgressToken | None = None
|
||||
_closed: bool = False
|
||||
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
|
||||
@@ -166,13 +150,7 @@ def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
|
||||
|
||||
|
||||
def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:
|
||||
"""Wrap a user progress callback so it can't crash the dispatcher.
|
||||
|
||||
The callback runs as a bare task in the dispatcher's task group; an
|
||||
uncaught exception would cancel every sibling (the read loop and all
|
||||
in-flight requests). Swallow and log instead, matching the previous
|
||||
receive-loop's behavior.
|
||||
"""
|
||||
"""Wrap a user progress callback so an exception can't cancel the dispatcher's task group."""
|
||||
|
||||
async def _wrapped(progress: float, total: float | None, message: str | None) -> None:
|
||||
try:
|
||||
@@ -183,61 +161,57 @@ def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:
|
||||
return _wrapped
|
||||
|
||||
|
||||
def _outbound_metadata(related_request_id: RequestId | None, opts: CallOptions | None) -> MessageMetadata:
|
||||
"""Choose the `SessionMessage.metadata` for an outgoing request/notification.
|
||||
def _contained_notify(fn: OnNotify) -> OnNotify:
|
||||
"""Wrap a notification handler so it can't crash the dispatcher (same boundary as `_shielded_progress`)."""
|
||||
|
||||
`ServerMessageMetadata` tags a server-to-client message with the inbound
|
||||
request it belongs to (so streamable-HTTP can route it onto that request's
|
||||
SSE stream). `ClientMessageMetadata` carries resumption hints to the
|
||||
client transport. `None` is the common case.
|
||||
async def _wrapped(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
|
||||
try:
|
||||
await fn(dctx, method, params)
|
||||
except Exception:
|
||||
logger.exception("notification handler for %r raised", method)
|
||||
|
||||
`SessionMessage.metadata` carries exactly one of these, so when
|
||||
`related_request_id` is set it takes precedence and any resumption hints
|
||||
in `opts` are dropped (with a debug log): requests made from a dispatch
|
||||
context are routed onto the inbound request's stream, not resumed.
|
||||
return _wrapped
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class _OutboundPlan:
|
||||
"""Outbound metadata plus whether abandoning the request sends a courtesy `notifications/cancelled`."""
|
||||
|
||||
metadata: MessageMetadata
|
||||
cancel_on_abandon: bool
|
||||
|
||||
|
||||
def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | None) -> _OutboundPlan:
|
||||
"""Choose the outbound `SessionMessage.metadata` and the abandon-cancellation policy.
|
||||
|
||||
`related_request_id` wins over resumption hints (they are dropped). Only
|
||||
hints that actually reach the transport suppress the courtesy cancel - a
|
||||
request that is neither resumable nor cancelled would leak the peer's work.
|
||||
"""
|
||||
opts = opts or {}
|
||||
cancel_on_abandon = opts.get("cancel_on_abandon", True)
|
||||
token = opts.get("resumption_token")
|
||||
on_token = opts.get("on_resumption_token")
|
||||
if related_request_id is not None:
|
||||
if opts and (opts.get("resumption_token") is not None or opts.get("on_resumption_token") is not None):
|
||||
if token is not None or on_token is not None:
|
||||
logger.debug(
|
||||
"dropping resumption hints: related_request_id %r takes precedence on metadata", related_request_id
|
||||
)
|
||||
return ServerMessageMetadata(related_request_id=related_request_id)
|
||||
if opts:
|
||||
token = opts.get("resumption_token")
|
||||
on_token = opts.get("on_resumption_token")
|
||||
if token is not None or on_token is not None:
|
||||
return ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token)
|
||||
return None
|
||||
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),
|
||||
cancel_on_abandon=False,
|
||||
)
|
||||
return _OutboundPlan(None, cancel_on_abandon)
|
||||
|
||||
|
||||
class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
"""`Dispatcher` over the existing `SessionMessage` stream contract.
|
||||
"""`Dispatcher` over the `SessionMessage` stream contract.
|
||||
|
||||
Inherits the `Dispatcher` Protocol explicitly so pyright checks
|
||||
conformance at the class definition rather than at first use.
|
||||
Explicit Protocol base so pyright checks conformance at the class definition.
|
||||
"""
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self: JSONRPCDispatcher[TransportContext],
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
write_stream: WriteStream[SessionMessage],
|
||||
*,
|
||||
peer_cancel_mode: PeerCancelMode = "interrupt",
|
||||
raise_handler_exceptions: bool = False,
|
||||
inline_methods: frozenset[str] = frozenset(),
|
||||
) -> None: ...
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
write_stream: WriteStream[SessionMessage],
|
||||
*,
|
||||
transport_builder: Callable[[MessageMetadata], TransportT],
|
||||
peer_cancel_mode: PeerCancelMode = "interrupt",
|
||||
raise_handler_exceptions: bool = False,
|
||||
inline_methods: frozenset[str] = frozenset(),
|
||||
) -> None: ...
|
||||
def __init__(
|
||||
self,
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
@@ -247,33 +221,41 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
peer_cancel_mode: PeerCancelMode = "interrupt",
|
||||
raise_handler_exceptions: bool = False,
|
||||
inline_methods: frozenset[str] = frozenset(),
|
||||
on_stream_exception: Callable[[Exception], Awaitable[None]] | None = None,
|
||||
) -> None:
|
||||
"""Wire a dispatcher over a transport's `SessionMessage` stream pair.
|
||||
|
||||
Args:
|
||||
transport_builder: Builds each message's `TransportContext` from
|
||||
its `SessionMessage.metadata`.
|
||||
raise_handler_exceptions: Re-raise handler exceptions out of
|
||||
`run()` after the error response is written.
|
||||
inline_methods: Methods awaited in the read loop before the next
|
||||
message is dequeued (e.g. `initialize`); an inline handler
|
||||
that awaits the peer deadlocks the parked loop.
|
||||
on_stream_exception: Observer for `Exception` items on the read
|
||||
stream; without it they are debug-logged and dropped. Awaited
|
||||
inline in the read loop, so a slow observer stalls dispatch.
|
||||
"""
|
||||
self._read_stream = read_stream
|
||||
self._write_stream = write_stream
|
||||
# The overloads guarantee that when `transport_builder` is omitted,
|
||||
# `TransportT` is `TransportContext`, so the default is type-correct;
|
||||
# pyright can't see across overloads, hence the cast.
|
||||
# With transport_builder omitted, TransportT defaults to
|
||||
# TransportContext; pyright can't connect the two, hence the cast.
|
||||
self._transport_builder = cast(
|
||||
"Callable[[MessageMetadata], TransportT]",
|
||||
transport_builder or _default_transport_builder,
|
||||
)
|
||||
self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode
|
||||
self._raise_handler_exceptions = raise_handler_exceptions
|
||||
# Request methods handled inline in the read loop (awaited before the
|
||||
# next message is dequeued) instead of spawned concurrently. Use for
|
||||
# methods whose side effects must be observable to the next message,
|
||||
# e.g. `initialize`, so a pipelined follow-up sees the initialized state.
|
||||
# Only suitable for handlers that complete quickly, since inline handling
|
||||
# blocks dequeuing; a handler that awaits the peer (`send_raw_request`)
|
||||
# while inline will deadlock because the parked read loop cannot dequeue
|
||||
# the response.
|
||||
self._inline_methods = inline_methods
|
||||
self._on_stream_exception = on_stream_exception
|
||||
|
||||
self._next_id = 0
|
||||
self._pending: dict[RequestId, _Pending] = {}
|
||||
self._in_flight: dict[RequestId, _InFlight[TransportT]] = {}
|
||||
self._tg: anyio.abc.TaskGroup | None = None
|
||||
self._running = False
|
||||
self._closed = False
|
||||
|
||||
async def send_raw_request(
|
||||
self,
|
||||
@@ -285,82 +267,105 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
) -> dict[str, Any]:
|
||||
"""Send a JSON-RPC request and await its response.
|
||||
|
||||
`_related_request_id` is set only by `_JSONRPCDispatchContext` when a
|
||||
handler makes a server-to-client request mid-flight; it routes the
|
||||
outgoing message onto the correct per-request SSE stream (SHTTP) via
|
||||
`ServerMessageMetadata`. Top-level callers leave it `None`.
|
||||
`_related_request_id` is set only by `_JSONRPCDispatchContext` so that
|
||||
mid-handler requests route onto the inbound request's SSE stream.
|
||||
|
||||
Raises:
|
||||
MCPError: The peer responded with a JSON-RPC error; or
|
||||
`REQUEST_TIMEOUT` if `opts["timeout"]` elapsed; or
|
||||
`CONNECTION_CLOSED` if the dispatcher shut down while
|
||||
awaiting the response.
|
||||
RuntimeError: Called before `run()` has started or after it has
|
||||
finished.
|
||||
MCPError: Peer error response; `REQUEST_TIMEOUT` if
|
||||
`opts["timeout"]` elapsed; `CONNECTION_CLOSED` if the
|
||||
transport closed or the dispatcher shut down.
|
||||
RuntimeError: Called before `run()`.
|
||||
"""
|
||||
# Post-close sends get the same CONNECTION_CLOSED contract as in-flight waiters.
|
||||
if self._closed:
|
||||
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed")
|
||||
if not self._running:
|
||||
raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run() / after close")
|
||||
raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run()")
|
||||
opts = opts or {}
|
||||
request_id = self._allocate_id()
|
||||
out_params = dict(params) if params is not None else {}
|
||||
out_meta = dict(out_params.get("_meta") or {})
|
||||
on_progress = opts.get("on_progress")
|
||||
if on_progress is not None:
|
||||
# The caller wants progress updates. The spec mechanism is: include
|
||||
# `_meta.progressToken` on the request; the peer echoes that token on
|
||||
# any `notifications/progress` it sends. We use the request id as the
|
||||
# token so the receive loop can find this `_Pending.on_progress` by
|
||||
# `_pending[token]` without a second lookup table.
|
||||
# The request id doubles as the progress token, so `_pending[token]` finds `on_progress` directly.
|
||||
out_meta["progressToken"] = request_id
|
||||
out_params["_meta"] = out_meta
|
||||
|
||||
# buffer=1: at most one outcome is ever delivered. A `WouldBlock` from
|
||||
# `_resolve_pending`/`_fan_out_closed` means the waiter already has an
|
||||
# outcome and dropping the late/redundant signal is correct. buffer=0
|
||||
# is unsafe - there's a window between registering `_pending[id]` and
|
||||
# parking in `receive()` where a close signal would be lost.
|
||||
# buffer=1: a close signal can arrive before the waiter parks in receive();
|
||||
# a WouldBlock later just means the waiter already has its one outcome.
|
||||
send, receive = anyio.create_memory_object_stream[dict[str, Any] | ErrorData](1)
|
||||
pending = _Pending(send=send, receive=receive, on_progress=on_progress)
|
||||
self._pending[request_id] = pending
|
||||
|
||||
metadata = _outbound_metadata(_related_request_id, opts)
|
||||
plan = _plan_outbound(_related_request_id, opts)
|
||||
# Spec MUST: only previously-issued requests may be cancelled. A write
|
||||
# interrupted by cancellation may still have delivered (a memory-stream
|
||||
# send can hand its item to the receiver and still raise), so a started
|
||||
# write counts as issued: the peer ignores a cancel for an id it never
|
||||
# saw, while skipping it would leak a delivered request's handler.
|
||||
request_write_started = False
|
||||
timeout_armed = False
|
||||
|
||||
target = out_params.get("name")
|
||||
span_name = f"MCP send {method}{f' {target}' if isinstance(target, str) else ''}"
|
||||
# TODO(maxisbey): the otel span + inject below mirror
|
||||
# BaseSession.send_request for parity. They belong in an outbound
|
||||
# middleware (symmetric with otel_middleware on the inbound side) once
|
||||
# that seam exists; the dispatcher should not own otel.
|
||||
# TODO(maxisbey): move the otel span + inject into an outbound
|
||||
# middleware once that seam exists; the dispatcher should not own otel.
|
||||
try:
|
||||
with otel_span(
|
||||
span_name,
|
||||
kind=SpanKind.CLIENT,
|
||||
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
|
||||
):
|
||||
# Inject W3C trace context into _meta (SEP-414). With a no-op
|
||||
# tracer this writes nothing, but `_meta` itself is still
|
||||
# present on the wire (and the interaction suite pins that).
|
||||
# SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer.
|
||||
inject_trace_context(out_meta)
|
||||
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
|
||||
await self._write(msg, metadata)
|
||||
# Surface a pre-existing cancellation while the request provably
|
||||
# never started; past this point a cancelled write counts as issued.
|
||||
await anyio.lowlevel.checkpoint_if_cancelled()
|
||||
request_write_started = True
|
||||
try:
|
||||
await self._write(msg, plan.metadata)
|
||||
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
||||
# Transport tore down before run() noticed EOF; surface the documented contract.
|
||||
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None
|
||||
with anyio.fail_after(opts.get("timeout")):
|
||||
timeout_armed = True
|
||||
outcome = await receive.receive()
|
||||
except TimeoutError:
|
||||
# Spec-recommended courtesy: tell the peer we've given up so it can
|
||||
# stop work and free resources. v1's BaseSession.send_request does
|
||||
# NOT do this; it's new behaviour.
|
||||
await self._cancel_outbound(request_id, f"timed out after {opts.get('timeout')}s", _related_request_id)
|
||||
if not timeout_armed:
|
||||
# `fail_after` arms only after the write, so this TimeoutError is the
|
||||
# transport's own bounded send() failing - a transport error, not
|
||||
# `opts["timeout"]` elapsing. Propagate it raw (v1 kept the write
|
||||
# outside the timeout-catching try and did the same).
|
||||
raise
|
||||
# Courtesy cancel (spec-recommended, new vs v1) so the peer stops work;
|
||||
# unshielded so an outer caller cancellation can still interrupt the write.
|
||||
if plan.cancel_on_abandon:
|
||||
await self._final_write(
|
||||
partial(
|
||||
self._cancel_outbound,
|
||||
request_id,
|
||||
f"timed out after {opts.get('timeout')}s",
|
||||
_related_request_id,
|
||||
),
|
||||
shield=False,
|
||||
timeout=_ABANDON_WRITE_TIMEOUT,
|
||||
describe=f"courtesy cancel for timed-out request {request_id!r}",
|
||||
)
|
||||
raise MCPError(code=REQUEST_TIMEOUT, message=f"Request {method!r} timed out") from None
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Our caller's scope was cancelled. We're already inside a cancelled
|
||||
# scope, so any bare `await` here re-raises immediately - shield to
|
||||
# let the courtesy cancel notification go out before we propagate.
|
||||
with anyio.CancelScope(shield=True):
|
||||
await self._cancel_outbound(request_id, "caller cancelled", _related_request_id)
|
||||
# Caller cancelled: bare awaits re-raise here, so the shielded helper
|
||||
# lets the courtesy cancel go out before we propagate.
|
||||
if plan.cancel_on_abandon and request_write_started:
|
||||
await self._final_write(
|
||||
partial(self._cancel_outbound, request_id, "caller cancelled", _related_request_id),
|
||||
shield=True,
|
||||
timeout=_ABANDON_WRITE_TIMEOUT,
|
||||
describe=f"courtesy cancel for caller-cancelled request {request_id!r}",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
# Always remove the waiter, even on cancel/timeout, so a late
|
||||
# response from the peer (race) hits a closed stream and is dropped
|
||||
# in `_dispatch` rather than leaking.
|
||||
# Remove the waiter on every path so a late response is dropped, not leaked.
|
||||
self._pending.pop(request_id, None)
|
||||
send.close()
|
||||
receive.close()
|
||||
@@ -376,15 +381,26 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
*,
|
||||
_related_request_id: RequestId | None = None,
|
||||
) -> None:
|
||||
# Leave `params` unset (not explicitly None) when there are none:
|
||||
# transports serialize with `exclude_unset=True`, and an explicit None
|
||||
# would survive as `"params": null`, which JSON-RPC 2.0 forbids and
|
||||
# strict peers (e.g. the TypeScript SDK's zod schemas) reject.
|
||||
"""Send a fire-and-forget notification.
|
||||
|
||||
Fire-and-forget all the way: a post-close send or a write onto a
|
||||
torn-down transport drops the notification with a debug log instead
|
||||
of raising (same policy as the response writes and `ctx.notify`).
|
||||
"""
|
||||
if self._closed:
|
||||
logger.debug("dropped %s: dispatcher closed", method)
|
||||
return
|
||||
# Leave `params` unset when None: with `exclude_unset=True` an explicit
|
||||
# None would serialize as `"params": null`, which JSON-RPC 2.0 forbids.
|
||||
if params is not None:
|
||||
msg = JSONRPCNotification(jsonrpc="2.0", method=method, params=dict(params))
|
||||
else:
|
||||
msg = JSONRPCNotification(jsonrpc="2.0", method=method)
|
||||
await self._write(msg, _outbound_metadata(_related_request_id, None))
|
||||
try:
|
||||
await self._write(msg, _plan_outbound(_related_request_id, None).metadata)
|
||||
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
||||
# Transport tore down before run() noticed EOF.
|
||||
logger.debug("dropped %s: write stream closed", method)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -395,50 +411,44 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
) -> None:
|
||||
"""Drive the receive loop until the read stream closes.
|
||||
|
||||
Each inbound request is handled in its own task in an internal task
|
||||
group; `task_status.started()` fires once that group is open, so
|
||||
`await tg.start(dispatcher.run, ...)` resumes when `send_raw_request`
|
||||
is usable.
|
||||
`task_status.started()` fires once `send_raw_request` is usable.
|
||||
Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted.
|
||||
"""
|
||||
try:
|
||||
async with anyio.create_task_group() as tg:
|
||||
self._tg = tg
|
||||
self._running = True
|
||||
task_status.started()
|
||||
try:
|
||||
async with self._read_stream, self._write_stream:
|
||||
try:
|
||||
async for item in self._read_stream:
|
||||
# Duck-typed: `_context_streams.ContextReceiveStream`
|
||||
# exposes `.last_context` (the sender's contextvars
|
||||
# snapshot per message). Plain memory streams don't.
|
||||
sender_ctx: contextvars.Context | None = getattr(
|
||||
self._read_stream, "last_context", None
|
||||
)
|
||||
await self._dispatch(item, on_request, on_notify, sender_ctx)
|
||||
except anyio.ClosedResourceError:
|
||||
# The transport closed our receive end and we looped
|
||||
# back to `__anext__` on the now-closed stream
|
||||
# (stateless SHTTP teardown). Same as EOF.
|
||||
logger.debug("read stream closed by transport; treating as EOF")
|
||||
# Read stream EOF: wake any blocked `send_raw_request` waiters
|
||||
# (callers outside this task group) with CONNECTION_CLOSED.
|
||||
self._running = False
|
||||
self._fan_out_closed()
|
||||
finally:
|
||||
# Transport closed: cancel in-flight handlers. Without this
|
||||
# the task-group join waits for them, and a handler that
|
||||
# outlives its caller (its request timed out client-side, or
|
||||
# the client disconnected mid-call) would keep `run()` from
|
||||
# returning forever. Same behaviour as `Server.run()` before
|
||||
# the dispatcher rework.
|
||||
tg.cancel_scope.cancel()
|
||||
# LIFO exits: the write stream closes only after the task-group join, so teardown writes still land.
|
||||
async with self._write_stream:
|
||||
async with anyio.create_task_group() as tg:
|
||||
self._tg = tg
|
||||
self._running = True
|
||||
task_status.started()
|
||||
try:
|
||||
async with self._read_stream:
|
||||
try:
|
||||
async for item in self._read_stream:
|
||||
# Duck-typed: only `ContextReceiveStream` carries the
|
||||
# sender's per-message contextvars snapshot.
|
||||
sender_ctx: contextvars.Context | None = getattr(
|
||||
self._read_stream, "last_context", None
|
||||
)
|
||||
await self._dispatch(item, on_request, on_notify, sender_ctx)
|
||||
except anyio.ClosedResourceError:
|
||||
# Receive end closed under us (stateless SHTTP teardown); same as EOF.
|
||||
logger.debug("read stream closed by transport; treating as EOF")
|
||||
# EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
|
||||
self._running = False
|
||||
self._closed = True
|
||||
self._fan_out_closed()
|
||||
finally:
|
||||
# Cancel in-flight handlers; otherwise the task-group join
|
||||
# waits on handlers whose callers are already gone.
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
# Covers the cancel/crash paths where the inline fan-out above is
|
||||
# never reached. Idempotent.
|
||||
# Covers cancel/crash paths that skip the inline fan-out; idempotent.
|
||||
self._running = False
|
||||
self._closed = True
|
||||
self._tg = None
|
||||
self._fan_out_closed()
|
||||
await resync_tracer()
|
||||
|
||||
async def _dispatch(
|
||||
self,
|
||||
@@ -449,13 +459,17 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
) -> None:
|
||||
"""Route one inbound item.
|
||||
|
||||
Everything here is `send_nowait` or `_spawn`; the only `await` is for
|
||||
`inline_methods` requests, which deliberately block dequeuing until
|
||||
handled. Any other `await` would let one slow message head-of-line
|
||||
block the entire read loop.
|
||||
Only `inline_methods` requests and the `on_stream_exception` observer
|
||||
are awaited; any other `await` would head-of-line block the read loop.
|
||||
"""
|
||||
if isinstance(item, Exception):
|
||||
logger.debug("transport yielded exception: %r", item)
|
||||
if self._on_stream_exception is None:
|
||||
logger.debug("transport yielded exception: %r", item)
|
||||
return
|
||||
try:
|
||||
await self._on_stream_exception(item)
|
||||
except Exception:
|
||||
logger.exception("on_stream_exception observer raised")
|
||||
return
|
||||
metadata = item.metadata
|
||||
msg = item.message
|
||||
@@ -467,9 +481,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
case JSONRPCResponse():
|
||||
self._resolve_pending(msg.id, msg.result)
|
||||
case JSONRPCError(): # pragma: no branch
|
||||
# `id` may be None per JSON-RPC (parse error before id known).
|
||||
# The match is exhaustive over JSONRPCMessage; the no-match arc
|
||||
# on this final case is unreachable.
|
||||
# Exhaustive over JSONRPCMessage, so the no-match arc is unreachable.
|
||||
self._resolve_pending(msg.id, msg.error)
|
||||
|
||||
async def _dispatch_request(
|
||||
@@ -481,8 +493,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
) -> None:
|
||||
progress_token: ProgressToken | None
|
||||
match req.params:
|
||||
# The bool guard matters: `int()` patterns match bool (a subclass),
|
||||
# and `True == 1` would alias dict lookups to request id 1.
|
||||
# bool subclasses int: without the guard True would alias request id 1.
|
||||
case {"_meta": {"progressToken": str() | int() as progress_token}} if not isinstance(progress_token, bool):
|
||||
pass
|
||||
case _:
|
||||
@@ -490,9 +501,7 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
try:
|
||||
transport_ctx = self._transport_builder(metadata)
|
||||
except Exception:
|
||||
# Containment boundary for the user-supplied builder: a raising
|
||||
# builder must cost only this message, not the whole connection
|
||||
# (the exception would otherwise escape into run()'s read loop).
|
||||
# A raising builder must cost only this message, not the connection.
|
||||
logger.exception("transport_builder raised; rejecting request %r", req.id)
|
||||
self._spawn(
|
||||
self._write_error,
|
||||
@@ -509,20 +518,13 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
_progress_token=progress_token,
|
||||
)
|
||||
scope = anyio.CancelScope()
|
||||
# TODO(maxisbey): the spec puts request-id uniqueness on the sender;
|
||||
# neither v1 nor the TS SDK guards a duplicate id here, so for now we
|
||||
# blind-overwrite (parity). Revisit rejecting with INVALID_REQUEST.
|
||||
# Coerced key so `notifications/cancelled` correlates regardless of
|
||||
# whether the peer stringifies the id between request and cancel
|
||||
# (`_dispatch_notification` coerces at lookup; responses still echo
|
||||
# `req.id` verbatim).
|
||||
# TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit
|
||||
# rejecting with INVALID_REQUEST. Key coerced so a stringified
|
||||
# `notifications/cancelled` id still correlates.
|
||||
self._in_flight[_coerce_id(req.id)] = _InFlight(scope=scope, dctx=dctx)
|
||||
if req.method in self._inline_methods:
|
||||
# Spawn (so `sender_ctx` applies, matching the concurrent path) but
|
||||
# park the read loop until the handler returns; that's the inline
|
||||
# ordering guarantee. Because the read loop is parked, a handler
|
||||
# that awaits the peer here (e.g. `dctx.send_raw_request`) will
|
||||
# deadlock: the response can never be dequeued.
|
||||
# Spawn so `sender_ctx` applies, but park the read loop until the
|
||||
# handler returns - that's the inline ordering guarantee.
|
||||
done = anyio.Event()
|
||||
|
||||
async def _run_inline() -> None:
|
||||
@@ -546,17 +548,12 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
"""Route one inbound notification.
|
||||
|
||||
`notifications/cancelled` and `notifications/progress` are intercepted
|
||||
here because they correlate against JSON-RPC request IDs - the
|
||||
`_in_flight` / `_pending` tables this layer owns - so no higher layer
|
||||
can act on them. Both are still teed to `on_notify` afterwards, so
|
||||
middleware and registered notification handlers observe every inbound
|
||||
notification. See the module docstring for the design rationale.
|
||||
here (they correlate against the `_in_flight`/`_pending` tables this
|
||||
layer owns) and still teed to `on_notify` afterwards.
|
||||
"""
|
||||
if msg.method == "notifications/cancelled":
|
||||
match msg.params:
|
||||
# The bool guards here and below matter: `int()` patterns match
|
||||
# bool (a subclass), and `True == 1` would alias the dict lookup
|
||||
# to the entry keyed by request id 1.
|
||||
# bool subclasses int: the guards keep True from aliasing request id 1.
|
||||
case {"requestId": str() | int() as rid} if (
|
||||
not isinstance(rid, bool) and (in_flight := self._in_flight.get(_coerce_id(rid))) is not None
|
||||
):
|
||||
@@ -565,9 +562,6 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
in_flight.scope.cancel()
|
||||
case _:
|
||||
pass
|
||||
# fall through: cancelled is also teed to on_notify so middleware
|
||||
# and registered handlers can observe it (matches DirectDispatcher,
|
||||
# which forwards every notification).
|
||||
elif msg.method == "notifications/progress":
|
||||
match msg.params:
|
||||
case {"progressToken": str() | int() as token, "progress": int() | float() as progress} if (
|
||||
@@ -587,18 +581,16 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
)
|
||||
case _:
|
||||
pass
|
||||
# fall through: progress is also teed to on_notify
|
||||
try:
|
||||
transport_ctx = self._transport_builder(metadata)
|
||||
except Exception:
|
||||
# Same containment boundary as `_dispatch_request`: a raising
|
||||
# builder drops this notification instead of killing the read loop.
|
||||
# Same containment as `_dispatch_request`: drop the notification, keep the loop.
|
||||
logger.exception("transport_builder raised; dropping notification %r", msg.method)
|
||||
return
|
||||
dctx = _JSONRPCDispatchContext(
|
||||
transport=transport_ctx, _dispatcher=self, _request_id=None, message_metadata=metadata
|
||||
)
|
||||
self._spawn(on_notify, dctx, msg.method, msg.params, sender_ctx=sender_ctx)
|
||||
self._spawn(_contained_notify(on_notify), dctx, msg.method, msg.params, sender_ctx=sender_ctx)
|
||||
|
||||
def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None:
|
||||
pending = self._pending.get(_coerce_id(request_id)) if request_id is not None else None
|
||||
@@ -618,10 +610,8 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
) -> None:
|
||||
"""Schedule `fn(*args)` in the run() task group, propagating the sender's contextvars.
|
||||
|
||||
ASGI middleware (auth, OTel) sets contextvars on the request task that
|
||||
wrote into the read stream. `Context.run(tg.start_soon, ...)` makes
|
||||
the spawned handler inherit *that* context instead of the receive
|
||||
loop's, so `auth_context_var` and OTel spans survive.
|
||||
ASGI middleware (auth, OTel) sets contextvars on the task that wrote the
|
||||
message; `Context.run` makes the spawned handler inherit that context.
|
||||
"""
|
||||
assert self._tg is not None
|
||||
if sender_ctx is not None:
|
||||
@@ -632,10 +622,9 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
def _fan_out_closed(self) -> None:
|
||||
"""Wake every pending `send_raw_request` waiter with `CONNECTION_CLOSED`.
|
||||
|
||||
Synchronous (uses `send_nowait`) because it's called from `finally`
|
||||
which may be inside a cancelled scope. Idempotent.
|
||||
Synchronous: callers may be inside a cancelled scope. Idempotent.
|
||||
"""
|
||||
closed = ErrorData(code=CONNECTION_CLOSED, message="connection closed")
|
||||
closed = ErrorData(code=CONNECTION_CLOSED, message="Connection closed")
|
||||
for pending in self._pending.values():
|
||||
try:
|
||||
pending.send.send_nowait(closed)
|
||||
@@ -652,61 +641,65 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
) -> None:
|
||||
"""Run `on_request` for one inbound request and write its response.
|
||||
|
||||
This is the single exception-to-wire boundary: handler exceptions are
|
||||
caught here and serialized to `JSONRPCError`. Nothing above this in
|
||||
the stack constructs wire errors.
|
||||
The single exception-to-wire boundary: handler exceptions become `JSONRPCError` here.
|
||||
"""
|
||||
answer_write_started = False
|
||||
try:
|
||||
with scope:
|
||||
try:
|
||||
result = await on_request(dctx, req.method, req.params)
|
||||
finally:
|
||||
# Handler done: close the back-channel (detached work that
|
||||
# later calls `dctx.send_raw_request()` should see
|
||||
# `NoBackChannelError`) and drop from `_in_flight` so a
|
||||
# late `notifications/cancelled` is a no-op rather than
|
||||
# racing the result write below. No checkpoint between
|
||||
# handler return and the pop, so the cancel can't
|
||||
# interleave there.
|
||||
# Close the back-channel and drop from `_in_flight`; no checkpoint
|
||||
# since handler return, so a peer cancel can't interleave.
|
||||
# Identity guard: don't evict a duplicate id's newer entry.
|
||||
dctx.close()
|
||||
self._in_flight.pop(_coerce_id(req.id), None)
|
||||
key = _coerce_id(req.id)
|
||||
if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx:
|
||||
del self._in_flight[key]
|
||||
# A write interrupted by cancellation may still have delivered
|
||||
# (a memory-stream send can hand its item to the receiver and
|
||||
# still raise), so a started answer write counts as sent below:
|
||||
# peers drop late responses, while a second answer for one id
|
||||
# would break JSON-RPC.
|
||||
answer_write_started = True
|
||||
await self._write_result(req.id, result)
|
||||
if scope.cancel_called:
|
||||
# Peer-cancel: `_dispatch_notification` cancelled this scope
|
||||
# while the handler was running. anyio swallows a scope's *own*
|
||||
# cancel at __exit__, so execution lands here rather than the
|
||||
# `except cancelled` arm below.
|
||||
# TODO(maxisbey): spec says SHOULD NOT respond after cancel.
|
||||
# The existing server always has, so match that for now.
|
||||
if scope.cancelled_caught:
|
||||
# anyio absorbs the scope's own cancel at __exit__, and
|
||||
# `cancelled_caught` (unlike `cancel_called`) guarantees the
|
||||
# result write above did not happen - no double response.
|
||||
# TODO(maxisbey): spec says SHOULD NOT respond after cancel;
|
||||
# the existing server always has, so match that for now.
|
||||
answer_write_started = True
|
||||
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Outer-cancel: run()'s task group is shutting down. Any bare
|
||||
# `await` here re-raises immediately, so shield the courtesy write.
|
||||
with anyio.CancelScope(shield=True):
|
||||
await self._write_error(req.id, ErrorData(code=REQUEST_CANCELLED, message="Request cancelled"))
|
||||
# Shutdown: answer the request so the peer isn't left waiting - unless
|
||||
# an answer write already started (it may have reached the transport;
|
||||
# prefer possibly-zero answers over possibly-two). The shielded helper
|
||||
# is needed because bare awaits re-raise here.
|
||||
if not answer_write_started:
|
||||
await self._final_write(
|
||||
partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")),
|
||||
shield=True,
|
||||
timeout=_SHUTDOWN_WRITE_TIMEOUT,
|
||||
describe=f"shutdown error response for request {req.id!r}",
|
||||
)
|
||||
raise
|
||||
except MCPError as e:
|
||||
await self._write_error(req.id, e.error)
|
||||
except ValidationError:
|
||||
# TODO(maxisbey): data="" is pinned compat with the existing
|
||||
# server (which never leaked pydantic error text onto the wire).
|
||||
# Consider putting the validation detail in `data` once the
|
||||
# interaction suite's divergence entry is resolved.
|
||||
# TODO(maxisbey): data="" pins existing-server compat (no pydantic
|
||||
# text on the wire); revisit per the suite's divergence entry.
|
||||
await self._write_error(
|
||||
req.id, ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("handler for %r raised", req.method)
|
||||
# TODO(maxisbey): code=0 is pinned compat with the existing
|
||||
# server's `_handle_request`. JSON-RPC says INTERNAL_ERROR
|
||||
# (-32603); revisit once the suite's divergence entry is resolved.
|
||||
# TODO(maxisbey): code=0 pins existing-server compat; JSON-RPC says
|
||||
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
|
||||
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
|
||||
if self._raise_handler_exceptions:
|
||||
raise
|
||||
# No outer `_in_flight` pop here: the inner `finally` above already
|
||||
# removes the entry on every path out of the handler, and a second
|
||||
# pop after the awaited response writes could evict a newer request
|
||||
# that reused the id during that window.
|
||||
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
|
||||
|
||||
def _allocate_id(self) -> int:
|
||||
self._next_id += 1
|
||||
@@ -727,16 +720,31 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
|
||||
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
||||
logger.debug("dropped error for %r: write stream closed", request_id)
|
||||
|
||||
async def _final_write(
|
||||
self,
|
||||
write: Callable[[], Awaitable[None]],
|
||||
*,
|
||||
shield: bool,
|
||||
timeout: float,
|
||||
describe: str,
|
||||
) -> None:
|
||||
"""Attempt one last write under the shared abandon/teardown policy.
|
||||
|
||||
`shield=True` is for arms already inside a cancelled scope (a bare
|
||||
`await` would re-raise); the bound keeps a wedged transport write
|
||||
from becoming an uncancellable hang.
|
||||
"""
|
||||
with anyio.move_on_after(timeout, shield=shield) as scope:
|
||||
await write()
|
||||
if scope.cancelled_caught:
|
||||
logger.warning("%s gave up: transport write blocked", describe)
|
||||
|
||||
async def _cancel_outbound(self, request_id: RequestId, reason: str, related_request_id: RequestId | None) -> None:
|
||||
# Thread `related_request_id` so streamable-HTTP routes the cancel onto
|
||||
# the same per-request SSE stream as the request it cancels; without it
|
||||
# the notification falls through to the standalone GET stream and is
|
||||
# dropped when no GET stream is open.
|
||||
try:
|
||||
await self.notify(
|
||||
"notifications/cancelled",
|
||||
{"requestId": request_id, "reason": reason},
|
||||
_related_request_id=related_request_id,
|
||||
)
|
||||
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
|
||||
pass
|
||||
# Thread `related_request_id` so streamable HTTP routes the cancel onto
|
||||
# the request's own SSE stream instead of a possibly-absent GET stream.
|
||||
# `notify` swallows connection-state errors itself, so no guard here.
|
||||
await self.notify(
|
||||
"notifications/cancelled",
|
||||
{"requestId": request_id, "reason": reason},
|
||||
_related_request_id=related_request_id,
|
||||
)
|
||||
|
||||
+12
-478
@@ -1,487 +1,21 @@
|
||||
from __future__ import annotations
|
||||
"""Compatibility names that outlived the removed v1 session layer (`BaseSession`)."""
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
from contextlib import AsyncExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any, Generic, Protocol, TypeVar
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
import anyio
|
||||
from anyio.streams.memory import MemoryObjectSendStream
|
||||
from opentelemetry.trace import SpanKind
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import Self
|
||||
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared._otel import inject_trace_context, otel_span
|
||||
from mcp.shared._stream_protocols import ReadStream, WriteStream
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
|
||||
from mcp.types import (
|
||||
CONNECTION_CLOSED,
|
||||
INVALID_PARAMS,
|
||||
METHOD_NOT_FOUND,
|
||||
REQUEST_TIMEOUT,
|
||||
CancelledNotification,
|
||||
ClientNotification,
|
||||
ClientRequest,
|
||||
ClientResult,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ProgressNotification,
|
||||
ProgressToken,
|
||||
RequestParamsMeta,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
ServerResult,
|
||||
)
|
||||
|
||||
SendRequestT = TypeVar("SendRequestT", ClientRequest, ServerRequest)
|
||||
SendResultT = TypeVar("SendResultT", ClientResult, ServerResult)
|
||||
SendNotificationT = TypeVar("SendNotificationT", ClientNotification, ServerNotification)
|
||||
ReceiveRequestT = TypeVar("ReceiveRequestT", ClientRequest, ServerRequest)
|
||||
ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
|
||||
ReceiveNotificationT = TypeVar("ReceiveNotificationT", ClientNotification, ServerNotification)
|
||||
from mcp.shared.dispatcher import ProgressFnT as ProgressFnT
|
||||
from mcp.shared.message import MessageMetadata
|
||||
from mcp.types import RequestParamsMeta
|
||||
|
||||
RequestId = str | int
|
||||
|
||||
|
||||
class ProgressFnT(Protocol):
|
||||
"""Protocol for progress notification callbacks."""
|
||||
|
||||
async def __call__(
|
||||
self, progress: float, total: float | None, message: str | None
|
||||
) -> None: ... # pragma: no branch
|
||||
ReceiveRequestT = TypeVar("ReceiveRequestT")
|
||||
SendResultT = TypeVar("SendResultT")
|
||||
|
||||
|
||||
class RequestResponder(Generic[ReceiveRequestT, SendResultT]):
|
||||
"""Handles responding to MCP requests and manages request lifecycle.
|
||||
"""Typing stub for the v1 responder; the SDK never instantiates it."""
|
||||
|
||||
This class MUST be used as a context manager to ensure proper cleanup and
|
||||
cancellation handling:
|
||||
|
||||
Example:
|
||||
```python
|
||||
with request_responder as resp:
|
||||
await resp.respond(result)
|
||||
```
|
||||
|
||||
The context manager ensures:
|
||||
1. Proper cancellation scope setup and cleanup
|
||||
2. Request completion tracking
|
||||
3. Cleanup of in-flight requests
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_id: RequestId,
|
||||
request_meta: RequestParamsMeta | None,
|
||||
request: ReceiveRequestT,
|
||||
session: BaseSession[SendRequestT, SendNotificationT, SendResultT, ReceiveRequestT, ReceiveNotificationT],
|
||||
message_metadata: MessageMetadata = None,
|
||||
context: contextvars.Context | None = None,
|
||||
) -> None:
|
||||
self.request_id = request_id
|
||||
self.request_meta = request_meta
|
||||
self.request = request
|
||||
self.message_metadata = message_metadata
|
||||
self.context = context
|
||||
self._session = session
|
||||
self._completed = False
|
||||
self._entered = False # Track if we're in a context manager
|
||||
|
||||
def __enter__(self) -> RequestResponder[ReceiveRequestT, SendResultT]:
|
||||
self._entered = True
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
self._entered = False
|
||||
|
||||
async def respond(self, response: SendResultT | ErrorData) -> None:
|
||||
"""Send a response for this request.
|
||||
|
||||
Must be called within a context manager block.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If not used within a context manager
|
||||
AssertionError: If request was already responded to
|
||||
"""
|
||||
if not self._entered: # pragma: no cover
|
||||
raise RuntimeError("RequestResponder must be used as a context manager")
|
||||
assert not self._completed, "Request already responded to"
|
||||
self._completed = True
|
||||
await self._session._send_response( # type: ignore[reportPrivateUsage]
|
||||
request_id=self.request_id, response=response
|
||||
)
|
||||
|
||||
|
||||
class BaseSession(
|
||||
Generic[
|
||||
SendRequestT,
|
||||
SendNotificationT,
|
||||
SendResultT,
|
||||
ReceiveRequestT,
|
||||
ReceiveNotificationT,
|
||||
],
|
||||
):
|
||||
"""Implements an MCP "session" on top of read/write streams, including features
|
||||
like request/response linking, notifications, and progress.
|
||||
|
||||
This class is an async context manager that automatically starts processing
|
||||
messages when entered.
|
||||
"""
|
||||
|
||||
_response_streams: dict[RequestId, MemoryObjectSendStream[JSONRPCResponse | JSONRPCError]]
|
||||
_request_id: int
|
||||
_progress_callbacks: dict[RequestId, ProgressFnT]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
write_stream: WriteStream[SessionMessage],
|
||||
# If none, reading will never time out
|
||||
read_timeout_seconds: float | None = None,
|
||||
) -> None:
|
||||
self._read_stream = read_stream
|
||||
self._write_stream = write_stream
|
||||
self._response_streams = {}
|
||||
self._request_id = 0
|
||||
self._session_read_timeout_seconds = read_timeout_seconds
|
||||
self._progress_callbacks = {}
|
||||
self._exit_stack = AsyncExitStack()
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
self._task_group = anyio.create_task_group()
|
||||
await self._task_group.__aenter__()
|
||||
self._task_group.start_soon(self._receive_loop)
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> bool | None:
|
||||
await self._exit_stack.aclose()
|
||||
# Using BaseSession as a context manager should not block on exit (this
|
||||
# would be very surprising behavior), so make sure to cancel the tasks
|
||||
# in the task group.
|
||||
self._task_group.cancel_scope.cancel()
|
||||
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
|
||||
await resync_tracer()
|
||||
return result
|
||||
|
||||
async def send_request(
|
||||
self,
|
||||
request: SendRequestT,
|
||||
result_type: type[ReceiveResultT],
|
||||
request_read_timeout_seconds: float | None = None,
|
||||
metadata: MessageMetadata = None,
|
||||
progress_callback: ProgressFnT | None = None,
|
||||
) -> ReceiveResultT:
|
||||
"""Sends a request and waits for a response.
|
||||
|
||||
Raises an MCPError if the response contains an error. If a request read timeout is provided, it will take
|
||||
precedence over the session read timeout.
|
||||
|
||||
Do not use this method to emit notifications! Use send_notification() instead.
|
||||
"""
|
||||
request_id = self._request_id
|
||||
self._request_id = request_id + 1
|
||||
|
||||
response_stream, response_stream_reader = anyio.create_memory_object_stream[JSONRPCResponse | JSONRPCError](1)
|
||||
self._response_streams[request_id] = response_stream
|
||||
|
||||
# Set up progress token if progress callback is provided
|
||||
request_data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
if progress_callback is not None:
|
||||
# Use request_id as progress token
|
||||
if "params" not in request_data: # pragma: lax no cover
|
||||
request_data["params"] = {}
|
||||
if "_meta" not in request_data["params"]: # pragma: lax no cover
|
||||
request_data["params"]["_meta"] = {}
|
||||
request_data["params"]["_meta"]["progressToken"] = request_id
|
||||
# Store the callback for this request
|
||||
self._progress_callbacks[request_id] = progress_callback
|
||||
|
||||
try:
|
||||
target = request_data.get("params", {}).get("name")
|
||||
span_name = f"MCP send {request.method} {target}" if target else f"MCP send {request.method}"
|
||||
|
||||
with otel_span(
|
||||
span_name,
|
||||
kind=SpanKind.CLIENT,
|
||||
attributes={"mcp.method.name": request.method, "jsonrpc.request.id": str(request_id)},
|
||||
):
|
||||
# Inject W3C trace context into _meta (SEP-414).
|
||||
meta: dict[str, Any] = request_data.setdefault("params", {}).setdefault("_meta", {})
|
||||
inject_trace_context(meta)
|
||||
|
||||
jsonrpc_request = JSONRPCRequest(jsonrpc="2.0", id=request_id, **request_data)
|
||||
await self._write_stream.send(SessionMessage(message=jsonrpc_request, metadata=metadata))
|
||||
|
||||
# request read timeout takes precedence over session read timeout
|
||||
timeout = request_read_timeout_seconds or self._session_read_timeout_seconds
|
||||
|
||||
try:
|
||||
with anyio.fail_after(timeout):
|
||||
response_or_error = await response_stream_reader.receive()
|
||||
except TimeoutError:
|
||||
class_name = request.__class__.__name__
|
||||
message = f"Timed out while waiting for response to {class_name}. Waited {timeout} seconds."
|
||||
raise MCPError(code=REQUEST_TIMEOUT, message=message)
|
||||
|
||||
if isinstance(response_or_error, JSONRPCError):
|
||||
raise MCPError.from_jsonrpc_error(response_or_error)
|
||||
else:
|
||||
return result_type.model_validate(response_or_error.result, by_name=False)
|
||||
|
||||
finally:
|
||||
self._response_streams.pop(request_id, None)
|
||||
self._progress_callbacks.pop(request_id, None)
|
||||
await response_stream.aclose()
|
||||
await response_stream_reader.aclose()
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
notification: SendNotificationT,
|
||||
related_request_id: RequestId | None = None,
|
||||
) -> None:
|
||||
"""Emits a notification, which is a one-way message that does not expect a response."""
|
||||
# Some transport implementations may need to set the related_request_id
|
||||
# to attribute to the notifications to the request that triggered them.
|
||||
jsonrpc_notification = JSONRPCNotification(
|
||||
jsonrpc="2.0",
|
||||
**notification.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
session_message = SessionMessage(
|
||||
message=jsonrpc_notification,
|
||||
metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None,
|
||||
)
|
||||
await self._write_stream.send(session_message)
|
||||
|
||||
async def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None:
|
||||
if isinstance(response, ErrorData):
|
||||
jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response)
|
||||
session_message = SessionMessage(message=jsonrpc_error)
|
||||
await self._write_stream.send(session_message)
|
||||
else:
|
||||
jsonrpc_response = JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
result=response.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
session_message = SessionMessage(message=jsonrpc_response)
|
||||
await self._write_stream.send(session_message)
|
||||
|
||||
@property
|
||||
def _receive_request_adapter(self) -> TypeAdapter[ReceiveRequestT]:
|
||||
"""Each subclass must provide its own request adapter."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def _receive_request_methods(self) -> frozenset[str]:
|
||||
"""Method names in the receive-request union; anything else is
|
||||
answered with METHOD_NOT_FOUND before validation is attempted."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def _receive_notification_adapter(self) -> TypeAdapter[ReceiveNotificationT]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
async with self._read_stream, self._write_stream:
|
||||
try:
|
||||
|
||||
async def _handle_session_message(message: SessionMessage) -> None:
|
||||
sender_context: contextvars.Context | None = getattr(self._read_stream, "last_context", None)
|
||||
if isinstance(message.message, JSONRPCRequest):
|
||||
if message.message.method not in self._receive_request_methods:
|
||||
# Unknown methods are METHOD_NOT_FOUND (-32601) per
|
||||
# JSON-RPC 2.0, not validation failures (-32602).
|
||||
error_response = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=message.message.id,
|
||||
error=ErrorData(
|
||||
code=METHOD_NOT_FOUND, message="Method not found", data=message.message.method
|
||||
),
|
||||
)
|
||||
await self._write_stream.send(SessionMessage(message=error_response))
|
||||
return
|
||||
try:
|
||||
validated_request = self._receive_request_adapter.validate_python(
|
||||
message.message.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
by_name=False,
|
||||
)
|
||||
responder = RequestResponder(
|
||||
request_id=message.message.id,
|
||||
request_meta=validated_request.params.meta if validated_request.params else None,
|
||||
request=validated_request,
|
||||
session=self,
|
||||
message_metadata=message.metadata,
|
||||
context=sender_context,
|
||||
)
|
||||
await self._received_request(responder)
|
||||
except Exception:
|
||||
# For request validation errors, send a proper JSON-RPC error
|
||||
# response instead of crashing the server
|
||||
logging.warning("Failed to validate request", exc_info=True)
|
||||
logging.debug(f"Message that failed validation: {message.message}")
|
||||
error_response = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=message.message.id,
|
||||
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
|
||||
)
|
||||
session_message = SessionMessage(message=error_response)
|
||||
await self._write_stream.send(session_message)
|
||||
|
||||
elif isinstance(message.message, JSONRPCNotification):
|
||||
try:
|
||||
notification = self._receive_notification_adapter.validate_python(
|
||||
message.message.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
by_name=False,
|
||||
)
|
||||
if isinstance(notification, CancelledNotification):
|
||||
# ClientSession runs server-initiated requests
|
||||
# inline in this loop, so by the time a peer
|
||||
# cancellation is read there is nothing left to
|
||||
# cancel. Consume it here so message_handler
|
||||
# keeps the contract it had before the
|
||||
# dispatcher swap removed _in_flight.
|
||||
return
|
||||
# Handle progress notifications callback
|
||||
if isinstance(notification, ProgressNotification):
|
||||
progress_token = notification.params.progress_token
|
||||
# If there is a progress callback for this token,
|
||||
# call it with the progress information
|
||||
if progress_token in self._progress_callbacks:
|
||||
callback = self._progress_callbacks[progress_token]
|
||||
try:
|
||||
await callback(
|
||||
notification.params.progress,
|
||||
notification.params.total,
|
||||
notification.params.message,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Progress callback raised an exception")
|
||||
await self._received_notification(notification)
|
||||
await self._handle_incoming(notification)
|
||||
except Exception:
|
||||
# For other validation errors, log and continue
|
||||
logging.warning(
|
||||
"Failed to validate notification: %s",
|
||||
message.message,
|
||||
exc_info=True,
|
||||
)
|
||||
else: # Response or error
|
||||
await self._handle_response(message)
|
||||
|
||||
async for message in self._read_stream:
|
||||
if isinstance(message, Exception):
|
||||
await self._handle_incoming(message)
|
||||
continue
|
||||
|
||||
await _handle_session_message(message)
|
||||
|
||||
except anyio.ClosedResourceError:
|
||||
# This is expected when the client disconnects abruptly.
|
||||
# Without this handler, the exception would propagate up and
|
||||
# crash the server's task group.
|
||||
logging.debug("Read stream closed by client")
|
||||
except Exception as e:
|
||||
# Other exceptions are not expected and should be logged. We purposefully
|
||||
# catch all exceptions here to avoid crashing the server.
|
||||
logging.exception(f"Unhandled exception in receive loop: {e}") # pragma: no cover
|
||||
finally:
|
||||
# after the read stream is closed, we need to send errors
|
||||
# to any pending requests
|
||||
# Snapshot: stream.send() wakes the waiter, whose finally pops
|
||||
# from _response_streams before the next __next__() call.
|
||||
for id, stream in list(self._response_streams.items()):
|
||||
error = ErrorData(code=CONNECTION_CLOSED, message="Connection closed")
|
||||
try:
|
||||
await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error))
|
||||
await stream.aclose()
|
||||
except Exception: # pragma: lax no cover
|
||||
# Stream might already be closed
|
||||
pass
|
||||
self._response_streams.clear()
|
||||
|
||||
def _normalize_request_id(self, response_id: RequestId) -> RequestId:
|
||||
"""Normalize a response ID to match how request IDs are stored.
|
||||
|
||||
Since the client always sends integer IDs, we normalize string IDs
|
||||
to integers when possible. This matches the TypeScript SDK approach:
|
||||
https://github.com/modelcontextprotocol/typescript-sdk/blob/a606fb17909ea454e83aab14c73f14ea45c04448/src/shared/protocol.ts#L861
|
||||
|
||||
Args:
|
||||
response_id: The response ID from the incoming message.
|
||||
|
||||
Returns:
|
||||
The normalized ID (int if possible, otherwise original value).
|
||||
"""
|
||||
if isinstance(response_id, str):
|
||||
try:
|
||||
return int(response_id)
|
||||
except ValueError:
|
||||
logging.warning(f"Response ID {response_id!r} cannot be normalized to match pending requests")
|
||||
return response_id
|
||||
|
||||
async def _handle_response(self, message: SessionMessage) -> None:
|
||||
"""Handle an incoming response or error message."""
|
||||
# This check is always true at runtime: the caller (_receive_loop) only invokes
|
||||
# this method in the else branch after checking for JSONRPCRequest and
|
||||
# JSONRPCNotification. However, the type checker can't infer this from the
|
||||
# method signature, so we need this guard for type narrowing.
|
||||
if not isinstance(message.message, JSONRPCResponse | JSONRPCError):
|
||||
return # pragma: no cover
|
||||
|
||||
if message.message.id is None:
|
||||
# Narrows to JSONRPCError since JSONRPCResponse.id is always RequestId
|
||||
error = message.message.error
|
||||
logging.warning(f"Received error with null ID: {error.message}")
|
||||
await self._handle_incoming(MCPError(error.code, error.message, error.data))
|
||||
return
|
||||
# Normalize response ID to handle type mismatches (e.g., "0" vs 0)
|
||||
response_id = self._normalize_request_id(message.message.id)
|
||||
|
||||
stream = self._response_streams.pop(response_id, None)
|
||||
if stream:
|
||||
await stream.send(message.message)
|
||||
else:
|
||||
await self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}"))
|
||||
|
||||
async def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None:
|
||||
"""Can be overridden by subclasses to handle a request without needing to
|
||||
listen on the message stream.
|
||||
|
||||
If the request is responded to within this method, it will not be
|
||||
forwarded on to the message stream.
|
||||
"""
|
||||
|
||||
async def _received_notification(self, notification: ReceiveNotificationT) -> None:
|
||||
"""Can be overridden by subclasses to handle a notification without needing
|
||||
to listen on the message stream.
|
||||
"""
|
||||
|
||||
async def send_progress_notification(
|
||||
self,
|
||||
progress_token: ProgressToken,
|
||||
progress: float,
|
||||
total: float | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
"""Sends a progress notification for a request that is currently being processed."""
|
||||
|
||||
async def _handle_incoming(
|
||||
self, req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception
|
||||
) -> None:
|
||||
"""A generic handler for incoming messages. Overridden by subclasses."""
|
||||
request_id: RequestId
|
||||
request_meta: RequestParamsMeta | None
|
||||
request: ReceiveRequestT
|
||||
message_metadata: MessageMetadata
|
||||
|
||||
@@ -152,7 +152,6 @@ from mcp.types.jsonrpc import (
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
REQUEST_CANCELLED,
|
||||
REQUEST_TIMEOUT,
|
||||
URL_ELICITATION_REQUIRED,
|
||||
ErrorData,
|
||||
@@ -320,7 +319,6 @@ __all__ = [
|
||||
"INVALID_REQUEST",
|
||||
"METHOD_NOT_FOUND",
|
||||
"PARSE_ERROR",
|
||||
"REQUEST_CANCELLED",
|
||||
"REQUEST_TIMEOUT",
|
||||
"URL_ELICITATION_REQUIRED",
|
||||
"ErrorData",
|
||||
|
||||
@@ -43,7 +43,6 @@ URL_ELICITATION_REQUIRED = -32042
|
||||
# SDK error codes
|
||||
CONNECTION_CLOSED = -32000
|
||||
REQUEST_TIMEOUT = -32001
|
||||
REQUEST_CANCELLED = -32002
|
||||
|
||||
# Standard JSON-RPC error codes
|
||||
PARSE_ERROR = -32700
|
||||
|
||||
@@ -2,9 +2,8 @@ import pytest
|
||||
from pydantic import FileUrl
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.types import ListRootsResult, Root, TextContent
|
||||
|
||||
|
||||
@@ -20,7 +19,7 @@ async def test_list_roots_callback():
|
||||
)
|
||||
|
||||
async def list_roots_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
) -> ListRootsResult:
|
||||
return callback_return
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import BaseSession, RequestId, SendResultT
|
||||
from mcp.types import ClientNotification, ClientRequest, ClientResult, EmptyResult, ErrorData, PingRequest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_stream_cleanup():
|
||||
"""Test that send_request properly cleans up streams when an exception occurs.
|
||||
|
||||
This test mocks out most of the session functionality to focus on stream cleanup.
|
||||
"""
|
||||
|
||||
# Create a mock session with the minimal required functionality
|
||||
class TestSession(BaseSession[ClientRequest, ClientNotification, ClientResult, Any, Any]):
|
||||
async def _send_response(
|
||||
self, request_id: RequestId, response: SendResultT | ErrorData
|
||||
) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
@property
|
||||
def _receive_request_adapter(self) -> TypeAdapter[Any]:
|
||||
return TypeAdapter(object) # pragma: no cover
|
||||
|
||||
@property
|
||||
def _receive_notification_adapter(self) -> TypeAdapter[Any]:
|
||||
return TypeAdapter(object) # pragma: no cover
|
||||
|
||||
# Create streams
|
||||
write_stream_send, write_stream_receive = anyio.create_memory_object_stream[SessionMessage](1)
|
||||
read_stream_send, read_stream_receive = anyio.create_memory_object_stream[SessionMessage](1)
|
||||
|
||||
# Create the session
|
||||
session = TestSession(read_stream_receive, write_stream_send)
|
||||
|
||||
# Create a test request
|
||||
request = PingRequest()
|
||||
|
||||
# Patch the _write_stream.send method to raise an exception
|
||||
async def mock_send(*args: Any, **kwargs: Any):
|
||||
raise RuntimeError("Simulated network error")
|
||||
|
||||
# Record the response streams before the test
|
||||
initial_stream_count = len(session._response_streams)
|
||||
|
||||
# Run the test with the patched method
|
||||
with patch.object(session._write_stream, "send", mock_send):
|
||||
with pytest.raises(RuntimeError):
|
||||
await session.send_request(request, EmptyResult)
|
||||
|
||||
# Verify that no response streams were leaked
|
||||
assert len(session._response_streams) == initial_stream_count, (
|
||||
f"Expected {initial_stream_count} response streams after request, but found {len(session._response_streams)}"
|
||||
)
|
||||
|
||||
# Clean up
|
||||
await write_stream_send.aclose()
|
||||
await write_stream_receive.aclose()
|
||||
await read_stream_send.aclose()
|
||||
await read_stream_receive.aclose()
|
||||
@@ -1,9 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.types import (
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
@@ -26,7 +25,7 @@ async def test_sampling_callback():
|
||||
)
|
||||
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: CreateMessageRequestParams,
|
||||
) -> CreateMessageResult:
|
||||
return callback_return
|
||||
@@ -71,7 +70,7 @@ async def test_create_message_backwards_compat_single_content():
|
||||
)
|
||||
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: CreateMessageRequestParams,
|
||||
) -> CreateMessageResult:
|
||||
return callback_return
|
||||
|
||||
+417
-27
@@ -1,22 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import anyio.abc
|
||||
import anyio.streams.memory
|
||||
import pytest
|
||||
from pydantic import FileUrl
|
||||
|
||||
from mcp import types
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
|
||||
from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest
|
||||
from mcp.shared.message import 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 (
|
||||
CONNECTION_CLOSED,
|
||||
INVALID_PARAMS,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
METHOD_NOT_FOUND,
|
||||
REQUEST_TIMEOUT,
|
||||
CallToolResult,
|
||||
Implementation,
|
||||
InitializedNotification,
|
||||
@@ -420,7 +428,7 @@ async def test_client_capabilities_with_custom_callbacks():
|
||||
received_capabilities = None
|
||||
|
||||
async def custom_sampling_callback( # pragma: no cover
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
return types.CreateMessageResult(
|
||||
@@ -430,7 +438,7 @@ async def test_client_capabilities_with_custom_callbacks():
|
||||
)
|
||||
|
||||
async def custom_list_roots_callback( # pragma: no cover
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
) -> types.ListRootsResult | types.ErrorData:
|
||||
return types.ListRootsResult(roots=[])
|
||||
|
||||
@@ -504,7 +512,7 @@ async def test_client_capabilities_with_sampling_tools():
|
||||
received_capabilities = None
|
||||
|
||||
async def custom_sampling_callback( # pragma: no cover
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
return types.CreateMessageResult(
|
||||
@@ -751,8 +759,32 @@ async def test_receive_loop_answers_malformed_inbound_request_with_invalid_param
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_answers_invalid_params_when_sampling_callback_raises():
|
||||
"""Same boundary catches exceptions from the request handler itself."""
|
||||
async def test_receive_loop_answers_unknown_request_method_with_method_not_found():
|
||||
"""An unknown request method is answered with METHOD_NOT_FOUND, not INVALID_PARAMS (spec-mandated)."""
|
||||
async with raw_client_session() as (_session, to_client, from_client):
|
||||
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=7, method="x/unknown")))
|
||||
out = await from_client.receive()
|
||||
assert isinstance(out.message, JSONRPCError)
|
||||
assert out.message.id == 7
|
||||
assert out.message.error == types.ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_drops_unknown_notification_method_without_response():
|
||||
"""An unknown notification method is dropped silently: JSON-RPC forbids responses to notifications."""
|
||||
async with raw_client_session() as (_session, to_client, from_client):
|
||||
await to_client.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="x/unknown")))
|
||||
# The answered follow-up ping proves no response was emitted and the loop survived.
|
||||
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
|
||||
out = await from_client.receive()
|
||||
assert isinstance(out.message, JSONRPCResponse)
|
||||
assert out.message.id == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raising_sampling_callback_answers_with_code_zero():
|
||||
"""A raising sampling callback is answered with code 0 and `str(exc)` (SDK-defined).
|
||||
Raw streams because the assertion is the outbound `JSONRPCError` envelope itself."""
|
||||
|
||||
async def boom(ctx: object, params: object) -> types.CreateMessageResult:
|
||||
raise RuntimeError("sampling boom")
|
||||
@@ -767,12 +799,13 @@ async def test_receive_loop_answers_invalid_params_when_sampling_callback_raises
|
||||
)
|
||||
out = await from_client.receive()
|
||||
assert isinstance(out.message, JSONRPCError)
|
||||
assert out.message.error.code == INVALID_PARAMS
|
||||
assert out.message.error == types.ErrorData(code=0, message="sampling boom")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest.LogCaptureFixture):
|
||||
"""A notification that fails ServerNotification validation is logged and dropped."""
|
||||
"""A malformed notification is logged and dropped without reaching `message_handler` (SDK-defined).
|
||||
Scripted peer: the typed API cannot emit a method outside the spec's notification union."""
|
||||
seen: list[object] = []
|
||||
delivered = anyio.Event()
|
||||
|
||||
@@ -792,19 +825,54 @@ async def test_receive_loop_logs_and_drops_malformed_notification(caplog: pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_forwards_transport_exception_to_message_handler():
|
||||
async def test_raising_message_handler_on_transport_exception_costs_the_delivery_not_the_connection(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""A `message_handler` that raises on a transport-level `Exception` item is contained: the
|
||||
failure is logged and the receive loop keeps serving (SDK-defined). Raw streams because
|
||||
only a transport can put an `Exception` item on the read stream."""
|
||||
seen: list[object] = []
|
||||
delivered = anyio.Event()
|
||||
|
||||
async def handler(msg: object) -> None:
|
||||
seen.append(msg)
|
||||
delivered.set()
|
||||
# No checkpoint between set() and the containment log, so after wait() the log entry exists.
|
||||
raise RuntimeError("handler boom")
|
||||
|
||||
async with raw_client_session(message_handler=handler) as (_session, to_client, _):
|
||||
async with raw_client_session(message_handler=handler) as (_session, to_client, from_client):
|
||||
exc = ValueError("bad bytes")
|
||||
await to_client.send(exc)
|
||||
await delivered.wait()
|
||||
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=9, method="ping")))
|
||||
out = await from_client.receive()
|
||||
assert seen == [exc]
|
||||
assert isinstance(out.message, JSONRPCResponse)
|
||||
assert out.message.id == 9
|
||||
assert "message_handler raised on transport exception" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_message_handler_awaiting_session_traffic_on_transport_exception_completes():
|
||||
"""A `message_handler` that awaits session traffic on a transport `Exception` item completes:
|
||||
fault deliveries are spawned into the task group, not run inline in the read loop (SDK-defined).
|
||||
Raw streams because only a transport can put an `Exception` item on the read stream."""
|
||||
ponged = anyio.Event()
|
||||
|
||||
# `session` resolves at call time, after the `as` clause binds it.
|
||||
async def handler(msg: object) -> None:
|
||||
assert isinstance(msg, Exception)
|
||||
await session.send_ping()
|
||||
ponged.set()
|
||||
|
||||
async with raw_client_session(message_handler=handler) as (session, to_client, from_client):
|
||||
await to_client.send(ValueError("bad bytes"))
|
||||
# Serve the handler's ping like a transport would; inline delivery would deadlock here.
|
||||
out = await from_client.receive()
|
||||
assert isinstance(out.message, JSONRPCRequest)
|
||||
assert out.message.method == "ping"
|
||||
await to_client.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=out.message.id, result={})))
|
||||
await ponged.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -814,6 +882,7 @@ async def test_receive_loop_consumes_server_cancelled_without_reaching_message_h
|
||||
The server dispatcher now emits this on sampling/elicitation timeout, but
|
||||
ClientSession has no in-flight tracking to act on it, so surfacing it would
|
||||
only break user handlers that exhaustively match ServerNotification.
|
||||
Scripted peer: the typed server API cannot emit a bare `notifications/cancelled`.
|
||||
"""
|
||||
seen: list[object] = []
|
||||
delivered = anyio.Event()
|
||||
@@ -841,23 +910,344 @@ async def test_receive_loop_consumes_server_cancelled_without_reaching_message_h
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_swallows_progress_callback_exception(caplog: pytest.LogCaptureFixture):
|
||||
async def test_request_timeout_zero_overrides_session_timeout():
|
||||
"""`request_read_timeout_seconds=0` is a real per-request timeout (fail at the
|
||||
first checkpoint, `anyio.fail_after(0)` semantics), not a fall-through to the
|
||||
session-level timeout. The request is never answered, so falling back to the
|
||||
30s session timeout would trip the harness's 5s guard instead."""
|
||||
async with raw_client_session(read_timeout_seconds=30) as (session, _to_client, _from_client):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
|
||||
assert exc_info.value.error.code == REQUEST_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_progress_notification_reaches_request_callback_and_message_handler():
|
||||
"""A `notifications/progress` for an in-flight request reaches both the `progress_callback` and
|
||||
`message_handler` (SDK-defined). Scripted peer: the progress token must echo the wire request id."""
|
||||
updates: list[tuple[float, float | None, str | None]] = []
|
||||
teed: list[types.ProgressNotification] = []
|
||||
request_id: types.RequestId | None = None
|
||||
progressed = anyio.Event()
|
||||
delivered = anyio.Event()
|
||||
|
||||
async def boom(progress: float, total: float | None, message: str | None) -> None:
|
||||
raise RuntimeError("progress boom")
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
updates.append((progress, total, message))
|
||||
progressed.set()
|
||||
|
||||
async def handler(msg: object) -> None:
|
||||
# Only the progress notification is teed to the message handler here.
|
||||
assert isinstance(msg, types.ProgressNotification)
|
||||
teed.append(msg)
|
||||
delivered.set()
|
||||
|
||||
async with raw_client_session(message_handler=handler) as (session, to_client, _):
|
||||
# Register the callback under a known token without sending a request.
|
||||
session._progress_callbacks[42] = boom # pyright: ignore[reportPrivateUsage]
|
||||
params = {"progressToken": 42, "progress": 0.5}
|
||||
await to_client.send(
|
||||
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params=params))
|
||||
)
|
||||
# The progress notification also reaches the message handler after the
|
||||
# callback runs, so this fires once the callback's exception is handled.
|
||||
await delivered.wait()
|
||||
assert "Progress callback raised an exception" in caplog.text
|
||||
async with raw_client_session(message_handler=handler) as (session, to_client, from_client):
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def call() -> None:
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult, progress_callback=on_progress)
|
||||
|
||||
tg.start_soon(call)
|
||||
request = await from_client.receive()
|
||||
assert isinstance(request.message, JSONRPCRequest)
|
||||
request_id = request.message.id
|
||||
# The request id doubles as the progress token.
|
||||
params = {"progressToken": request_id, "progress": 0.5, "total": 1.0, "message": "halfway"}
|
||||
await to_client.send(
|
||||
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/progress", params=params))
|
||||
)
|
||||
await progressed.wait()
|
||||
await delivered.wait()
|
||||
await to_client.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request_id, result={})))
|
||||
assert updates == [(0.5, 1.0, "halfway")]
|
||||
assert request_id is not None
|
||||
assert len(teed) == 1
|
||||
assert teed[0].params == types.ProgressNotificationParams(
|
||||
progress_token=request_id, progress=0.5, total=1.0, message="halfway"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatcher_keyword_runs_over_direct_dispatch():
|
||||
"""A session built with dispatcher= works without a stream pair (in-process embedding)."""
|
||||
client_side, server_side = create_direct_dispatcher_pair()
|
||||
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> dict[str, object]:
|
||||
assert method == "ping"
|
||||
return {}
|
||||
|
||||
notified: list[str] = []
|
||||
|
||||
async def server_on_notify(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> None:
|
||||
notified.append(method)
|
||||
|
||||
session = ClientSession(dispatcher=client_side)
|
||||
results: list[types.EmptyResult] = []
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(server_side.run, server_on_request, server_on_notify)
|
||||
async with session:
|
||||
results.append(await session.send_ping(meta=None))
|
||||
# Server-to-client: direct dispatch delivers ping with no params member (no _meta injection).
|
||||
assert await server_side.send_raw_request("ping", None) == {}
|
||||
await session.send_notification(types.RootsListChangedNotification())
|
||||
server_side.close()
|
||||
assert results == [types.EmptyResult()]
|
||||
assert notified == ["notifications/roots/list_changed"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_dispatch_roots_list_reaches_callback_with_synthesized_request_id():
|
||||
"""A server-initiated roots/list over dispatcher= reaches the registered callback and round-trips
|
||||
the result; the callback context carries an int request_id (SDK-defined: DirectDispatcher
|
||||
synthesizes ids)."""
|
||||
client_side, server_side = create_direct_dispatcher_pair()
|
||||
contexts: list[ClientRequestContext] = []
|
||||
|
||||
async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
|
||||
contexts.append(context)
|
||||
return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))])
|
||||
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> dict[str, object]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def server_on_notify(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots)
|
||||
result: dict[str, Any] | None = None
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(server_side.run, server_on_request, server_on_notify)
|
||||
async with session:
|
||||
result = await server_side.send_raw_request("roots/list", None)
|
||||
server_side.close()
|
||||
assert result == {"roots": [{"uri": "file:///workspace"}]}
|
||||
assert len(contexts) == 1
|
||||
assert isinstance(contexts[0].request_id, int)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raising_notification_callbacks_over_direct_dispatch_cost_only_that_delivery(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""A raising `logging_callback` or `message_handler` is contained in the session, so the
|
||||
in-process peer's notify() returns normally and the session keeps serving requests
|
||||
(SDK-defined: DirectDispatcher awaits notification handlers inline in the peer's call).
|
||||
A raising `logging_callback` skips the `message_handler` tee for that notification."""
|
||||
client_side, server_side = create_direct_dispatcher_pair()
|
||||
teed: list[types.ServerNotification] = []
|
||||
|
||||
async def logging_callback(params: types.LoggingMessageNotificationParams) -> None:
|
||||
raise ValueError("logging callback boom")
|
||||
|
||||
async def message_handler(
|
||||
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||
) -> None:
|
||||
assert not isinstance(message, RequestResponder | Exception)
|
||||
teed.append(message)
|
||||
raise ValueError("message handler boom")
|
||||
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> dict[str, object]:
|
||||
assert method == "ping"
|
||||
return {}
|
||||
|
||||
async def server_on_notify(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, message_handler=message_handler)
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(server_side.run, server_on_request, server_on_notify)
|
||||
async with session:
|
||||
# logging_callback raises: notify() must return, and message_handler is skipped.
|
||||
await server_side.notify("notifications/message", {"level": "info", "data": "hello"})
|
||||
# message_handler raises: notify() must return.
|
||||
await server_side.notify("notifications/tools/list_changed", None)
|
||||
# The session still serves requests afterwards.
|
||||
assert await session.send_ping() == types.EmptyResult()
|
||||
server_side.close()
|
||||
assert [type(n) for n in teed] == [types.ToolListChangedNotification]
|
||||
assert caplog.text.count("notification callback for") == 2
|
||||
assert "notification callback for 'notifications/message' raised" in caplog.text
|
||||
assert "notification callback for 'notifications/tools/list_changed' raised" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatcher_keyword_send_request_before_enter_raises_runtimeerror():
|
||||
"""The documented pre-enter RuntimeError holds for dispatcher= sessions too."""
|
||||
client_side, _server_side = create_direct_dispatcher_pair()
|
||||
session = ClientSession(dispatcher=client_side)
|
||||
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc:
|
||||
await session.send_ping()
|
||||
assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatcher_keyword_send_request_after_exit_raises_connection_closed():
|
||||
"""After __aexit__ a dispatcher= session raises MCPError(CONNECTION_CLOSED), matching the JSONRPC path."""
|
||||
client_side, server_side = create_direct_dispatcher_pair()
|
||||
|
||||
async def server_on_request(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> dict[str, object]:
|
||||
assert method == "ping"
|
||||
return {}
|
||||
|
||||
async def server_on_notify(
|
||||
ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
session = ClientSession(dispatcher=client_side)
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(server_side.run, server_on_request, server_on_notify)
|
||||
async with session:
|
||||
assert await session.send_ping() == types.EmptyResult()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.send_ping()
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
server_side.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispatcher_keyword_request_timeout_bounds_wait_for_never_run_peer():
|
||||
"""request_read_timeout_seconds fires even when the peer dispatcher never started running."""
|
||||
client_side, _server_side = create_direct_dispatcher_pair()
|
||||
session = ClientSession(dispatcher=client_side)
|
||||
with anyio.fail_after(5):
|
||||
async with session:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.01)
|
||||
assert exc.value.error.code == REQUEST_TIMEOUT
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_leave_it_unset():
|
||||
"""`send_request` passes `cancel_on_abandon=False` for `initialize` — the spec forbids
|
||||
cancelling it — and leaves the option unset for every other method."""
|
||||
|
||||
class RecordingDispatcher:
|
||||
"""Records `send_raw_request` opts and answers with canned results."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, CallOptions]] = []
|
||||
|
||||
async def run(
|
||||
self,
|
||||
on_request: OnRequest,
|
||||
on_notify: OnNotify,
|
||||
*,
|
||||
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
||||
) -> None:
|
||||
task_status.started()
|
||||
await anyio.sleep_forever()
|
||||
|
||||
async def send_raw_request(
|
||||
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
|
||||
) -> dict[str, Any]:
|
||||
self.calls.append((method, opts or {}))
|
||||
if method == "initialize":
|
||||
return InitializeResult(
|
||||
protocol_version=LATEST_PROTOCOL_VERSION,
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="mock-server", version="0.1.0"),
|
||||
).model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||
return {}
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
pass
|
||||
|
||||
dispatcher = RecordingDispatcher()
|
||||
with anyio.fail_after(5):
|
||||
async with ClientSession(dispatcher=dispatcher) as session:
|
||||
await session.initialize()
|
||||
await session.send_ping()
|
||||
opts_by_method = dict(dispatcher.calls)
|
||||
assert opts_by_method["initialize"].get("cancel_on_abandon") is False
|
||||
assert "cancel_on_abandon" not in opts_by_method["ping"]
|
||||
|
||||
|
||||
def test_constructor_rejects_streams_and_dispatcher_together():
|
||||
client_side, _server_side = create_direct_dispatcher_pair()
|
||||
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
|
||||
with pytest.raises(ValueError, match="not both"):
|
||||
ClientSession(s2c_recv, dispatcher=client_side)
|
||||
s2c_send.close()
|
||||
s2c_recv.close()
|
||||
|
||||
|
||||
def test_constructor_requires_both_streams_without_dispatcher():
|
||||
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
|
||||
with pytest.raises(ValueError, match="read_stream and write_stream are required"):
|
||||
ClientSession(s2c_recv)
|
||||
with pytest.raises(ValueError, match="read_stream and write_stream are required"):
|
||||
ClientSession()
|
||||
s2c_send.close()
|
||||
s2c_recv.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
|
||||
"""Cancellation while `__aenter__` waits for the dispatcher to start unwinds the half-entered
|
||||
task group cleanly, not via anyio's "exited non-innermost cancel scope" RuntimeError (SDK-defined)."""
|
||||
|
||||
class NeverStartsDispatcher:
|
||||
"""`run()` parks without ever signalling `task_status.started()`."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
on_request: OnRequest,
|
||||
on_notify: OnNotify,
|
||||
*,
|
||||
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
|
||||
) -> None:
|
||||
await anyio.sleep_forever()
|
||||
|
||||
async def send_raw_request(
|
||||
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
|
||||
) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
session = ClientSession(dispatcher=NeverStartsDispatcher())
|
||||
async with AsyncExitStack() as stack:
|
||||
# `start()` is parked forever, so the deadline only ends the wait — any duration is non-racy.
|
||||
with anyio.move_on_after(0.01) as scope:
|
||||
await stack.enter_async_context(session)
|
||||
assert scope.cancelled_caught
|
||||
# The failed enter must not leave the session half-entered.
|
||||
assert session._task_group is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_notification_after_close_is_dropped_silently():
|
||||
"""Post-close `send_notification` is fire-and-forget: the notification is dropped,
|
||||
not surfaced as a raw transport error (v1 leaked `anyio.ClosedResourceError`)."""
|
||||
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)
|
||||
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage](4)
|
||||
try:
|
||||
async with ClientSession(s2c_recv, c2s_send) as session:
|
||||
pass
|
||||
with anyio.fail_after(5):
|
||||
await session.send_notification(types.RootsListChangedNotification())
|
||||
with pytest.raises(anyio.EndOfStream):
|
||||
c2s_recv.receive_nowait() # nothing reached the wire
|
||||
finally:
|
||||
for s in (s2c_send, s2c_recv, c2s_send, c2s_recv):
|
||||
s.close()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Concurrency over a single client session: multiple requests in flight at once, in both directions."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_concurrent_tool_calls_resolve_out_of_order_to_their_own_callers() -> None:
|
||||
"""Three tool calls in flight at once on one session each receive their own result, even though
|
||||
the responses come back in the reverse of the order the requests were sent.
|
||||
|
||||
SDK-defined contract: pins the client request machinery's support for concurrent in-flight
|
||||
calls with out-of-order response correlation. Each handler parks on its own release event
|
||||
after signalling it started; a session that serialized requests would never start the later
|
||||
handlers and the test would time out instead.
|
||||
"""
|
||||
send_order = ["a", "b", "c"]
|
||||
started = {tag: anyio.Event() for tag in send_order}
|
||||
release = {tag: anyio.Event() for tag in send_order}
|
||||
done = {tag: anyio.Event() for tag in send_order}
|
||||
completion_order: list[str] = []
|
||||
results: dict[str, CallToolResult] = {}
|
||||
|
||||
server = MCPServer("parking")
|
||||
|
||||
@server.tool()
|
||||
async def park(tag: str) -> str:
|
||||
started[tag].set()
|
||||
await release[tag].wait()
|
||||
return f"result:{tag}"
|
||||
|
||||
async with Client(server) as client:
|
||||
|
||||
async def call_and_record(tag: str) -> None:
|
||||
results[tag] = await client.call_tool("park", {"tag": tag})
|
||||
completion_order.append(tag)
|
||||
done[tag].set()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as task_group: # pragma: no branch
|
||||
# Waiting for each handler to start before issuing the next call fixes the send
|
||||
# order, and leaves all three parked in flight together once the loop finishes.
|
||||
for tag in send_order:
|
||||
task_group.start_soon(call_and_record, tag)
|
||||
await started[tag].wait()
|
||||
|
||||
# Nothing completed yet: all three calls are genuinely concurrent.
|
||||
assert completion_order == []
|
||||
|
||||
# Release in reverse, awaiting each completion so the finish order is forced.
|
||||
for tag in reversed(send_order):
|
||||
release[tag].set()
|
||||
await done[tag].wait()
|
||||
|
||||
assert completion_order == ["c", "b", "a"]
|
||||
assert results == snapshot(
|
||||
{
|
||||
"c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}),
|
||||
"b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}),
|
||||
"a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_overlapping_sampling_requests_are_serviced_concurrently_by_the_client() -> None:
|
||||
"""A server tool that fans out two sampling requests at once gets both echoes back: the client
|
||||
runs overlapping inbound `create_message` requests concurrently instead of serializing them in
|
||||
its receive loop.
|
||||
|
||||
Regression pin for https://github.com/modelcontextprotocol/python-sdk/issues/2489 -- v1's
|
||||
`BaseSession` awaited each inbound request handler inline, so the second sampling callback
|
||||
could not start until the first returned; here both rendezvous before either is released.
|
||||
"""
|
||||
sampling_started = {"x": anyio.Event(), "y": anyio.Event()}
|
||||
sampling_release = anyio.Event()
|
||||
tool_results: list[CallToolResult] = []
|
||||
|
||||
server = MCPServer("fan_out_server")
|
||||
|
||||
@server.tool()
|
||||
async def fan_out(ctx: Context) -> str:
|
||||
echoes: dict[str, str] = {}
|
||||
|
||||
async def sample(tag: str) -> None:
|
||||
result = await ctx.session.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(text=tag))],
|
||||
max_tokens=10,
|
||||
)
|
||||
assert isinstance(result.content, TextContent)
|
||||
echoes[tag] = result.content.text
|
||||
|
||||
async with anyio.create_task_group() as sampler_group:
|
||||
sampler_group.start_soon(sample, "x")
|
||||
sampler_group.start_soon(sample, "y")
|
||||
return f"{echoes['x']} {echoes['y']}"
|
||||
|
||||
async def sampling_callback(
|
||||
context: ClientRequestContext, params: CreateMessageRequestParams
|
||||
) -> CreateMessageResult:
|
||||
content = params.messages[0].content
|
||||
assert isinstance(content, TextContent)
|
||||
sampling_started[content.text].set()
|
||||
await sampling_release.wait()
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
content=TextContent(text=f"echo:{content.text}"),
|
||||
model="test-model",
|
||||
stop_reason="endTurn",
|
||||
)
|
||||
|
||||
async with Client(server, sampling_callback=sampling_callback) as client:
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as task_group: # pragma: no branch
|
||||
|
||||
async def invoke_fan_out() -> None:
|
||||
tool_results.append(await client.call_tool("fan_out", {}))
|
||||
|
||||
task_group.start_soon(invoke_fan_out)
|
||||
|
||||
# Both sampling callbacks are mid-flight before either may answer -- a client that
|
||||
# serialized inbound requests would never start the second one.
|
||||
await sampling_started["x"].wait()
|
||||
await sampling_started["y"].wait()
|
||||
sampling_release.set()
|
||||
|
||||
assert tool_results == snapshot(
|
||||
[CallToolResult(content=[TextContent(text="echo:x echo:y")], structured_content={"result": "echo:x echo:y"})]
|
||||
)
|
||||
+12
-11
@@ -193,11 +193,13 @@ many requirements at once; if the assertions would be separate, write separate t
|
||||
|
||||
### Notifications and concurrency
|
||||
|
||||
The client's receive loop dispatches each incoming message to completion before reading the next,
|
||||
and the in-memory transport delivers everything on one ordered stream. Together these guarantee
|
||||
that every notification a server handler emits before its response reaches the client callback
|
||||
before the originating request returns — so tests collect notifications into a plain list and
|
||||
assert after the call, with no synchronisation. The exceptions:
|
||||
The client's dispatcher starts a task per incoming notification in arrival order but does not
|
||||
await it before reading the next message, so completion order is not structural. What still
|
||||
holds: the in-memory transport delivers everything on one ordered stream, and a callback that
|
||||
records synchronously (no `await` before the append) finishes its scheduling slice before the
|
||||
awaited request's waiter — woken strictly later — resumes. So tests whose callbacks are plain
|
||||
appends may still collect into a list and assert after the call. A callback that awaits before
|
||||
recording loses that ordering and must synchronise. The other exceptions:
|
||||
|
||||
- a notification not triggered by a request the test is awaiting needs an `anyio.Event` set in
|
||||
the receiving handler and awaited under `anyio.fail_after(5)`;
|
||||
@@ -220,9 +222,8 @@ but still inside an outer `async with`, and no restructure can avoid it.
|
||||
|
||||
A handful of `# pragma: lax no cover` markers in `src/` cover teardown exception handlers whose
|
||||
execution is timing-dependent under the in-process HTTP bridge — the POST-stream and
|
||||
stateless-session `except Exception` handlers in `server/streamable_http*.py`, the `_terminated`
|
||||
check in `message_router`, and the response-stream double-close guard in
|
||||
`BaseSession._receive_loop`. `strict-no-cover` does not check `lax` lines; do not promote them to
|
||||
strict `no cover` without first making the teardown ordering deterministic. The suite also relies
|
||||
on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that closes a stream the
|
||||
SSE leg would otherwise leak.
|
||||
stateless-session `except Exception` handlers in `server/streamable_http*.py` and the
|
||||
`_terminated` check in `message_router`. `strict-no-cover` does not check `lax` lines; do not
|
||||
promote them to strict `no cover` without first making the teardown ordering deterministic. The
|
||||
suite also relies on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that
|
||||
closes a stream the SSE leg would otherwise leak.
|
||||
|
||||
@@ -67,8 +67,9 @@ class _RecordingWriteStream:
|
||||
self._log = log
|
||||
|
||||
async def send(self, item: SessionMessage, /) -> None:
|
||||
self._log.append(item)
|
||||
# Record only after the inner send returns: a failed or cancelled send never reached the transport.
|
||||
await self._inner.send(item)
|
||||
self._log.append(item)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._inner.aclose()
|
||||
|
||||
@@ -268,18 +268,15 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||
divergence=Divergence(
|
||||
note=(
|
||||
"The spec says receivers of a cancellation SHOULD NOT send a response for the cancelled "
|
||||
"request; the server sends an error response (code 0, 'Request cancelled'), which is what "
|
||||
"unblocks the SDK client's pending call."
|
||||
"request; both seats send an error response (code 0, 'Request cancelled') instead — the "
|
||||
"server for cancelled client requests, and the client for cancelled server-initiated "
|
||||
"requests — which is what unblocks the sender's pending call."
|
||||
),
|
||||
),
|
||||
),
|
||||
"protocol:cancel:initialize-not-cancellable": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
|
||||
behavior="The client never sends notifications/cancelled for the initialize request.",
|
||||
deferred=(
|
||||
"Not implemented in the SDK: the client has no public cancellation API at all, so no pathway "
|
||||
"exists that could cancel initialize; there is no distinct behaviour to pin beyond that absence."
|
||||
),
|
||||
),
|
||||
"protocol:cancel:late-response-ignored": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements",
|
||||
@@ -287,14 +284,6 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||
"A response that arrives after the sender issued notifications/cancelled is ignored; the "
|
||||
"request stays failed and no error is raised."
|
||||
),
|
||||
divergence=Divergence(
|
||||
note=(
|
||||
"A response whose id matches no in-flight request is delivered to the message handler "
|
||||
"as a RuntimeError rather than being silently ignored. The post-cancellation case is the "
|
||||
"same code path; tested in its unknown-id form because that is deterministic without the "
|
||||
"client-side cancellation API the SDK does not yet provide."
|
||||
),
|
||||
),
|
||||
),
|
||||
"protocol:cancel:server-survives": Requirement(
|
||||
source="sdk",
|
||||
@@ -306,19 +295,6 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||
"A server that abandons an in-flight server-initiated request (sampling, elicitation, roots) "
|
||||
"cancels it, and the client stops processing the cancelled request."
|
||||
),
|
||||
divergence=Divergence(
|
||||
note=(
|
||||
"Abandoning a server-side send_request emits no cancellation notification, and the client "
|
||||
"could not act on one anyway: client callbacks run inline in the receive loop, so a "
|
||||
"cancellation is not even read until the callback has finished."
|
||||
),
|
||||
),
|
||||
deferred=(
|
||||
"Not implemented in the SDK: abandoning a server-side send_request emits no cancellation "
|
||||
"notification (the same sender-side gap recorded on protocol:timeout:sends-cancellation), and "
|
||||
"the client could not act on one anyway because client callbacks run inline in the receive "
|
||||
"loop, so a cancellation would not even be read until the callback had already finished."
|
||||
),
|
||||
),
|
||||
"protocol:cancel:unknown-id-ignored": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling",
|
||||
@@ -363,6 +339,27 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||
source=f"{SPEC_BASE_URL}/basic#responses",
|
||||
behavior="A request whose method has no registered handler is answered with a METHOD_NOT_FOUND error.",
|
||||
),
|
||||
"protocol:error:null-id": Requirement(
|
||||
source="sdk",
|
||||
behavior=(
|
||||
"An error response carrying a null id — the JSON-RPC shape for a peer reporting a failure it "
|
||||
"could not attribute to a request, such as a parse error — is surfaced to the application "
|
||||
"rather than silently discarded."
|
||||
),
|
||||
divergence=Divergence(
|
||||
note=(
|
||||
"The dispatcher drops null-id error responses with a debug log; in v1, JSONRPCError.id was "
|
||||
"non-nullable, so a null-id error response failed transport validation and the resulting "
|
||||
"ValidationError was surfaced to message_handler as an exception. A typed fault channel "
|
||||
"restoring visibility is planned before v2 stable."
|
||||
),
|
||||
),
|
||||
deferred=(
|
||||
"Not yet covered here: the current drop is pinned at the dispatcher level by "
|
||||
"tests/shared/test_jsonrpc_dispatcher.py; an interaction-level test waits on the planned "
|
||||
"fault channel."
|
||||
),
|
||||
),
|
||||
"protocol:meta:related-task": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata",
|
||||
behavior="Messages may carry related-task _meta associating them with a task.",
|
||||
@@ -466,11 +463,6 @@ REQUIREMENTS: dict[str, Requirement] = {
|
||||
"When a request times out, the sender issues notifications/cancelled for that request before "
|
||||
"failing the local call."
|
||||
),
|
||||
divergence=Divergence(
|
||||
note=(
|
||||
"The client only raises locally and sends nothing on timeout, so the server keeps running the handler."
|
||||
),
|
||||
),
|
||||
),
|
||||
"protocol:timeout:session-survives": Requirement(
|
||||
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
|
||||
|
||||
@@ -11,11 +11,12 @@ import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientSession
|
||||
from mcp.client import ClientRequestContext, ClientSession
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import (
|
||||
REQUEST_TIMEOUT,
|
||||
CallToolResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
@@ -155,14 +156,71 @@ async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) ->
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="unbothered")]))
|
||||
|
||||
|
||||
@requirement("protocol:cancel:server-to-client")
|
||||
async def test_abandoned_server_request_cancels_the_client_callback(connect: Connect) -> None:
|
||||
"""A server that abandons a sampling request cancels it, interrupting the client's callback mid-await."""
|
||||
callback_started = anyio.Event()
|
||||
callback_cancelled = anyio.Event()
|
||||
|
||||
async def sampling_callback(
|
||||
context: ClientRequestContext, params: types.CreateMessageRequestParams
|
||||
) -> types.CreateMessageResult:
|
||||
callback_started.set()
|
||||
try:
|
||||
await anyio.Event().wait() # blocks until the cancellation interrupts it
|
||||
except anyio.get_cancelled_exc_class():
|
||||
callback_cancelled.set()
|
||||
raise
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "impatient"
|
||||
request = types.CreateMessageRequest(
|
||||
params=types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
|
||||
max_tokens=8,
|
||||
)
|
||||
)
|
||||
async with anyio.create_task_group() as abandon_scope:
|
||||
|
||||
async def sample() -> None:
|
||||
await ctx.session.send_request(request, types.CreateMessageResult)
|
||||
raise NotImplementedError # unreachable: the scope is cancelled
|
||||
|
||||
abandon_scope.start_soon(sample)
|
||||
with anyio.fail_after(5):
|
||||
await callback_started.wait()
|
||||
abandon_scope.cancel_scope.cancel()
|
||||
with anyio.fail_after(5):
|
||||
await callback_cancelled.wait()
|
||||
return CallToolResult(content=[TextContent(text="abandoned")])
|
||||
|
||||
server = Server("abandoner", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
async with connect(server, sampling_callback=sampling_callback) as client:
|
||||
result = await client.call_tool("impatient", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="abandoned")]))
|
||||
assert callback_cancelled.is_set()
|
||||
|
||||
|
||||
@requirement("protocol:cancel:late-response-ignored")
|
||||
async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_handler() -> None:
|
||||
"""A response whose id matches no in-flight request is surfaced to the message handler as a RuntimeError.
|
||||
async def test_a_response_for_an_unknown_request_id_is_ignored() -> None:
|
||||
"""A response whose id matches no in-flight request is ignored, as the spec asks.
|
||||
|
||||
The spec says a sender SHOULD ignore a response that arrives after it issued a cancellation;
|
||||
that is the same client-side code path as any response with an unknown id, and that form is
|
||||
deterministic to test without depending on the cancellation API the SDK does not yet provide.
|
||||
See the divergence note on the requirement.
|
||||
deterministic to test without a client-side cancellation API.
|
||||
|
||||
"Ignored" is proved in two halves: the pong round-trip proves the read loop survived the
|
||||
fabricated response (the ordered in-memory stream routed it first), and `surfaced` holding
|
||||
only the control notification proves the fabricated response was never delivered to
|
||||
`message_handler` (v1 surfaced it there as a RuntimeError).
|
||||
|
||||
A real Server cannot be made to answer with a fabricated id, so the test plays the server's
|
||||
side of the wire by hand. Reserve this pattern for behaviour no real server can produce. The
|
||||
@@ -208,14 +266,18 @@ async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_hand
|
||||
assert isinstance(ping, SessionMessage)
|
||||
assert isinstance(ping.message, JSONRPCRequest)
|
||||
assert ping.message.method == "ping"
|
||||
# First answer with a fabricated id that matches nothing in flight, then the real id.
|
||||
# First a fabricated id that matches nothing in flight, then a control notification that
|
||||
# is surfaced to message_handler (proving the handler is live), then the real id.
|
||||
await server_write.send(respond(9999, EmptyResult()))
|
||||
await server_write.send(
|
||||
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
|
||||
)
|
||||
await server_write.send(respond(ping.message.id, EmptyResult()))
|
||||
|
||||
incoming: list[IncomingMessage] = []
|
||||
surfaced: list[IncomingMessage] = []
|
||||
|
||||
async def message_handler(message: IncomingMessage) -> None:
|
||||
incoming.append(message)
|
||||
surfaced.append(message)
|
||||
|
||||
async with (
|
||||
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
|
||||
@@ -228,7 +290,56 @@ async def test_a_response_for_an_unknown_request_id_surfaces_to_the_message_hand
|
||||
pong = await session.send_request(PingRequest(), EmptyResult)
|
||||
|
||||
assert pong == snapshot(EmptyResult())
|
||||
assert len(incoming) == 1
|
||||
assert isinstance(incoming[0], RuntimeError)
|
||||
# The full message embeds the response object's repr; only the prefix is stable.
|
||||
assert str(incoming[0]).startswith("Received response with an unknown request ID:")
|
||||
# The stream is ordered, so the fabricated response was routed before the control
|
||||
# notification: only the control surfaced, so the unknown-id response was dropped.
|
||||
assert surfaced == snapshot([types.ToolListChangedNotification()])
|
||||
|
||||
|
||||
@requirement("protocol:cancel:initialize-not-cancellable")
|
||||
async def test_timed_out_initialize_sends_no_cancellation() -> None:
|
||||
"""An abandoned initialize is not followed by notifications/cancelled on the wire (spec-mandated).
|
||||
|
||||
A real Server always answers initialize, so the test plays a stalling server by hand.
|
||||
"""
|
||||
received_methods: list[str] = []
|
||||
|
||||
async def scripted_server(streams: MessageStream) -> None:
|
||||
server_read, server_write = streams
|
||||
|
||||
# Hold the initialize request unanswered until the client's read timeout fires.
|
||||
init = await server_read.receive()
|
||||
assert isinstance(init, SessionMessage)
|
||||
assert isinstance(init.message, JSONRPCRequest)
|
||||
received_methods.append(init.message.method)
|
||||
|
||||
follow_up = await server_read.receive()
|
||||
assert isinstance(follow_up, SessionMessage)
|
||||
assert isinstance(follow_up.message, JSONRPCRequest)
|
||||
received_methods.append(follow_up.message.method)
|
||||
await server_write.send(
|
||||
SessionMessage(
|
||||
JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id=follow_up.message.id,
|
||||
result=EmptyResult().model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async with (
|
||||
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
|
||||
anyio.create_task_group() as task_group,
|
||||
# The session-level read timeout is the only public pathway that abandons initialize.
|
||||
ClientSession(client_read, client_write, read_timeout_seconds=0.000001) as session,
|
||||
):
|
||||
task_group.start_soon(scripted_server, server_streams)
|
||||
with anyio.fail_after(5):
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await session.initialize()
|
||||
assert exc_info.value.error.code == REQUEST_TIMEOUT
|
||||
# Override the session-level timeout: this ping must round-trip normally.
|
||||
pong = await session.send_request(PingRequest(), EmptyResult, request_read_timeout_seconds=5)
|
||||
|
||||
assert pong == snapshot(EmptyResult())
|
||||
# The stream is ordered, so a courtesy cancel would have arrived ahead of the ping.
|
||||
assert received_methods == snapshot(["initialize", "ping"])
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""Logging interactions against the low-level Server, driven through the public Client API.
|
||||
|
||||
Notification ordering: the in-memory transport delivers every server-to-client message on one
|
||||
ordered stream, and the client's receive loop dispatches each incoming message to completion
|
||||
before reading the next one. Over streamable HTTP that ordered single-stream guarantee holds
|
||||
only for messages that carry a ``related_request_id`` (they ride the originating request's POST
|
||||
stream); without it the message routes to the standalone GET stream and may arrive after the
|
||||
response. These tests pass ``related_request_id`` so they can collect into a plain list and
|
||||
assert after the request completes on every transport leg -- no events, no waiting.
|
||||
Notification ordering: await-free callbacks finish in arrival order, and passing
|
||||
``related_request_id`` keeps each notification on the originating request's POST stream over
|
||||
streamable HTTP, so plain-list collection is deterministic on every transport leg.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -87,8 +87,8 @@ async def test_progress_token_visible_to_handler(connect: Connect) -> None:
|
||||
async with connect(server) as client:
|
||||
result = await client.call_tool("inspect", {}, progress_callback=ignore)
|
||||
|
||||
# The token is the request id of the tools/call request itself (initialize is request 0).
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="1")]))
|
||||
# The token is the request id of the tools/call request itself (initialize is request 1).
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="2")]))
|
||||
|
||||
|
||||
@requirement("protocol:progress:no-token")
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
The handler blocks on an event that is never set, so the awaited response can never arrive and
|
||||
any positive timeout fires deterministically on the next event-loop pass. Per-request timeouts are
|
||||
set to an effectively-zero duration; the session-level test runs on trio's virtual clock instead
|
||||
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero itself
|
||||
cannot be used: a falsy read_timeout_seconds is silently treated as "no timeout".)
|
||||
(see the comment there). Either way the tests add no wall-clock time to the suite. (Zero would
|
||||
also time out immediately, but a tiny positive value keeps the duration visible in the
|
||||
cancellation reason these tests snapshot.)
|
||||
"""
|
||||
|
||||
import anyio
|
||||
@@ -13,9 +14,13 @@ from inline_snapshot import snapshot
|
||||
from trio.testing import MockClock
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client._memory import InMemoryTransport
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, TextContent
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import REQUEST_TIMEOUT, CallToolResult, ErrorData, JSONRPCNotification, TextContent
|
||||
from tests.interaction._helpers import RecordingTransport
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
@@ -26,16 +31,19 @@ pytestmark = pytest.mark.anyio
|
||||
async def test_request_timeout_fails_the_pending_call() -> None:
|
||||
"""A request whose response does not arrive within its read timeout fails with a timeout error.
|
||||
|
||||
No cancellation is sent to the server (see the divergence note on the requirement): the handler
|
||||
starts and is still running after the caller has already given up. The test waits for the
|
||||
handler to have started only after the timeout has fired, so the timeout itself races nothing.
|
||||
The timeout is followed by notifications/cancelled, which interrupts the server's handler.
|
||||
"""
|
||||
handler_started = anyio.Event()
|
||||
handler_cancelled = anyio.Event()
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "block"
|
||||
handler_started.set()
|
||||
await anyio.Event().wait() # blocks until the session is torn down
|
||||
try:
|
||||
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
|
||||
except anyio.get_cancelled_exc_class():
|
||||
handler_cancelled.set()
|
||||
raise
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
@@ -44,18 +52,79 @@ async def test_request_timeout_fails_the_pending_call() -> None:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("block", {}, read_timeout_seconds=0.000001)
|
||||
|
||||
# The request was already on the wire: the handler still runs even though the caller gave up.
|
||||
# The request was already on the wire: the handler started and was then cancelled.
|
||||
with anyio.fail_after(5):
|
||||
await handler_started.wait()
|
||||
await handler_cancelled.wait()
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=REQUEST_TIMEOUT,
|
||||
message="Timed out while waiting for response to CallToolRequest. Waited 1e-06 seconds.",
|
||||
message="Request 'tools/call' timed out",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("protocol:timeout:basic")
|
||||
@requirement("protocol:timeout:sends-cancellation")
|
||||
async def test_server_request_timeout_sends_cancellation_to_the_client() -> None:
|
||||
"""A server-initiated request that times out fails server-side and cancels the client's work.
|
||||
|
||||
The sampling callback answers only after the server gave up; the late response is discarded.
|
||||
"""
|
||||
release = anyio.Event()
|
||||
callback_started = anyio.Event()
|
||||
errors: list[ErrorData] = []
|
||||
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(tools=[types.Tool(name="impatient", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "impatient"
|
||||
request = types.CreateMessageRequest(
|
||||
params=types.CreateMessageRequestParams(
|
||||
messages=[types.SamplingMessage(role="user", content=TextContent(text="Say hello."))],
|
||||
max_tokens=8,
|
||||
)
|
||||
)
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await ctx.session.send_request(request, types.CreateMessageResult, request_read_timeout_seconds=0.000001)
|
||||
errors.append(exc_info.value.error)
|
||||
release.set()
|
||||
return CallToolResult(content=[TextContent(text="gave up")])
|
||||
|
||||
server = Server("impatient", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
recording = RecordingTransport(InMemoryTransport(server))
|
||||
|
||||
async def sampling_callback(
|
||||
context: ClientRequestContext, params: types.CreateMessageRequestParams
|
||||
) -> types.CreateMessageResult:
|
||||
callback_started.set()
|
||||
with anyio.fail_after(5):
|
||||
await release.wait()
|
||||
return types.CreateMessageResult(role="assistant", content=TextContent(text="too late"), model="test-model")
|
||||
|
||||
async with Client(recording, sampling_callback=sampling_callback) as client:
|
||||
result = await client.call_tool("impatient", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="gave up")]))
|
||||
assert callback_started.is_set()
|
||||
assert errors == snapshot([ErrorData(code=REQUEST_TIMEOUT, message="Request 'sampling/createMessage' timed out")])
|
||||
cancellations = [
|
||||
item.message
|
||||
for item in recording.received
|
||||
if isinstance(item, SessionMessage)
|
||||
and isinstance(item.message, JSONRPCNotification)
|
||||
and item.message.method == "notifications/cancelled"
|
||||
]
|
||||
# requestId 1 is the sampling request, the server's first outbound request.
|
||||
assert [notification.params for notification in cancellations] == snapshot(
|
||||
[{"requestId": 1, "reason": "timed out after 1e-06s"}]
|
||||
)
|
||||
|
||||
|
||||
@requirement("protocol:timeout:session-survives")
|
||||
async def test_session_serves_requests_after_timeout() -> None:
|
||||
"""A timed-out request does not poison the session: the next request succeeds."""
|
||||
@@ -73,7 +142,7 @@ async def test_session_serves_requests_after_timeout() -> None:
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
if params.name == "echo":
|
||||
return CallToolResult(content=[TextContent(text="still alive")])
|
||||
await anyio.Event().wait() # blocks until the session is torn down
|
||||
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
@@ -105,7 +174,7 @@ async def test_session_level_timeout_applies_to_every_request() -> None:
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "block"
|
||||
await anyio.Event().wait() # blocks until the session is torn down
|
||||
await anyio.Event().wait() # blocks until the courtesy cancellation interrupts it
|
||||
raise NotImplementedError # unreachable
|
||||
|
||||
server = Server("blocker", on_call_tool=call_tool)
|
||||
@@ -117,6 +186,6 @@ async def test_session_level_timeout_applies_to_every_request() -> None:
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=REQUEST_TIMEOUT,
|
||||
message="Timed out while waiting for response to CallToolRequest. Waited 0.05 seconds.",
|
||||
message="Request 'tools/call' timed out",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ def _echo_server() -> Server:
|
||||
async def test_request_ids_are_unique_and_never_null() -> None:
|
||||
"""Every request the client sends carries a distinct, non-null id.
|
||||
|
||||
The id sequence is pinned: sequential integers from zero, in send order.
|
||||
The id sequence is pinned: sequential integers from one, in send order.
|
||||
"""
|
||||
recording = RecordingTransport(InMemoryTransport(_echo_server()))
|
||||
|
||||
@@ -77,7 +77,7 @@ async def test_request_ids_are_unique_and_never_null() -> None:
|
||||
assert len(request_ids) == len(set(request_ids))
|
||||
# initialize, tools/list, tools/call, tools/call, ping -- the client does not issue a
|
||||
# schema-cache refresh here because the explicit tools/list already populated the cache.
|
||||
assert request_ids == snapshot([0, 1, 2, 3, 4])
|
||||
assert request_ids == snapshot([1, 2, 3, 4, 5])
|
||||
|
||||
|
||||
@requirement("protocol:notifications:no-response")
|
||||
|
||||
@@ -12,7 +12,14 @@ from mcp.client.session import ClientSession
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent
|
||||
from mcp.types import (
|
||||
REQUEST_TIMEOUT,
|
||||
CallToolRequestParams,
|
||||
CallToolResult,
|
||||
ListToolsResult,
|
||||
PaginatedRequestParams,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -55,7 +62,8 @@ async def test_notification_validation_error(tmp_path: Path):
|
||||
assert params.name in ("slow", "fast"), f"Unknown tool: {params.name}"
|
||||
|
||||
if params.name == "slow":
|
||||
await slow_request_lock.wait() # it should timeout here
|
||||
# The client's timeout fires during this wait; the courtesy cancellation then interrupts it.
|
||||
await slow_request_lock.wait()
|
||||
text = f"slow {request_count}"
|
||||
else:
|
||||
text = f"fast {request_count}"
|
||||
@@ -95,9 +103,9 @@ async def test_notification_validation_error(tmp_path: Path):
|
||||
# Use very small timeout to trigger quickly without waiting
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await session.call_tool("slow", read_timeout_seconds=0.000001) # artificial timeout that always fails
|
||||
assert "Timed out while waiting" in str(exc_info.value)
|
||||
assert exc_info.value.error.code == REQUEST_TIMEOUT
|
||||
|
||||
# release the slow request not to have hanging process
|
||||
# No-op if the courtesy cancellation already interrupted the handler.
|
||||
slow_request_lock.set()
|
||||
|
||||
# Third call should work (fast operation, no timeout),
|
||||
|
||||
@@ -6,9 +6,9 @@ import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession, ElicitationFnT
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client.session import ElicitationFnT
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ async def test_elicitation_accept_returns_the_users_answer_to_the_tool():
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
# Create a custom handler for elicitation requests
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if params.message == "Tool wants to ask: What is your name?":
|
||||
return ElicitResult(action="accept", content={"answer": "Test User"})
|
||||
else: # pragma: no cover
|
||||
@@ -81,7 +81,7 @@ async def test_elicitation_decline_reaches_the_tool_without_content():
|
||||
mcp = MCPServer(name="ElicitationDeclineServer")
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
await call_tool_and_assert(
|
||||
@@ -119,9 +119,7 @@ async def test_elicitation_schema_validation():
|
||||
create_validation_tool("nested_model", InvalidNestedSchema)
|
||||
|
||||
# Dummy callback (won't be called due to validation failure)
|
||||
async def elicitation_callback(
|
||||
context: RequestContext[ClientSession], params: ElicitRequestParams
|
||||
): # pragma: no cover
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
@@ -176,7 +174,7 @@ async def test_elicitation_with_optional_fields():
|
||||
|
||||
for content, expected in test_cases:
|
||||
|
||||
async def callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept", content=content)
|
||||
|
||||
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
|
||||
@@ -194,9 +192,7 @@ async def test_elicitation_with_optional_fields():
|
||||
except TypeError as e:
|
||||
return f"Validation failed: {str(e)}"
|
||||
|
||||
async def elicitation_callback(
|
||||
context: RequestContext[ClientSession], params: ElicitRequestParams
|
||||
): # pragma: no cover
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
await call_tool_and_assert(
|
||||
@@ -219,7 +215,7 @@ async def test_elicitation_with_optional_fields():
|
||||
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "Please provide tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
@@ -239,7 +235,7 @@ async def test_elicitation_with_optional_fields():
|
||||
return f"Name: {result.data.name}, Tags: {tags_str}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def optional_multiselect_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def optional_multiselect_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "Please provide optional tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
@@ -273,7 +269,7 @@ async def test_elicitation_with_default_values():
|
||||
return f"User {result.action}"
|
||||
|
||||
# First verify that defaults are present in the JSON schema sent to clients
|
||||
async def callback_schema_verify(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def callback_schema_verify(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify the schema includes defaults
|
||||
assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation"
|
||||
schema = params.requested_schema
|
||||
@@ -295,7 +291,7 @@ async def test_elicitation_with_default_values():
|
||||
)
|
||||
|
||||
# Test overriding defaults
|
||||
async def callback_override(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def callback_override(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(
|
||||
action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False}
|
||||
)
|
||||
@@ -371,7 +367,7 @@ async def test_elicitation_with_enum_titles():
|
||||
return f"User: {result.data.user_name}, Color: {result.data.color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def enum_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "colors" in params.message and "legacy" not in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
|
||||
elif "color" in params.message:
|
||||
|
||||
@@ -26,9 +26,7 @@ from examples.snippets.servers import (
|
||||
structured_output,
|
||||
tool_progress,
|
||||
)
|
||||
from mcp.client import Client
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.client import Client, ClientRequestContext
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
ClientResult,
|
||||
@@ -80,9 +78,7 @@ class NotificationCollector:
|
||||
self.tool_notifications.append(message.params)
|
||||
|
||||
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession], params: CreateMessageRequestParams
|
||||
) -> CreateMessageResult:
|
||||
async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
||||
"""Sampling callback for tests."""
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
@@ -94,7 +90,7 @@ async def sampling_callback(
|
||||
)
|
||||
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
"""Elicitation callback for tests."""
|
||||
# For restaurant booking test
|
||||
if "No tables available" in params.message:
|
||||
|
||||
@@ -1098,10 +1098,10 @@ class TestContextInjection:
|
||||
assert "Logged messages for test" in content.text
|
||||
|
||||
assert mock_log.call_count == 4
|
||||
mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="1")
|
||||
mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="1")
|
||||
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="1")
|
||||
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="1")
|
||||
mock_log.assert_any_call(level="debug", data="Debug message", logger=None, related_request_id="2")
|
||||
mock_log.assert_any_call(level="info", data="Info message", logger=None, related_request_id="2")
|
||||
mock_log.assert_any_call(level="warning", data="Warning message", logger=None, related_request_id="2")
|
||||
mock_log.assert_any_call(level="error", data="Error message", logger=None, related_request_id="2")
|
||||
|
||||
async def test_optional_context(self):
|
||||
"""Test that context is optional."""
|
||||
|
||||
@@ -5,10 +5,9 @@ import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
|
||||
@@ -28,7 +27,7 @@ async def test_url_elicitation_accept():
|
||||
return f"User {result.action}"
|
||||
|
||||
# Create elicitation callback that accepts URL mode
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
assert params.url == "https://example.com/api_key_setup"
|
||||
assert params.elicitation_id == "test-elicitation-001"
|
||||
@@ -57,7 +56,7 @@ async def test_url_elicitation_decline():
|
||||
# Test only checks decline path
|
||||
return f"User {result.action} authorization"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
@@ -83,7 +82,7 @@ async def test_url_elicitation_cancel():
|
||||
# Test only checks cancel path
|
||||
return f"User {result.action} payment"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
@@ -110,7 +109,7 @@ async def test_url_elicitation_helper_function():
|
||||
# Test only checks accept path - return the type name
|
||||
return type(result).__name__
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
@@ -137,7 +136,7 @@ async def test_url_no_content_in_response():
|
||||
assert result.content is None
|
||||
return f"Action: {result.action}, Content: {result.content}"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify that this is URL mode
|
||||
assert params.mode == "url"
|
||||
assert isinstance(params, types.ElicitRequestURLParams)
|
||||
@@ -170,7 +169,7 @@ async def test_form_mode_still_works():
|
||||
assert result.data is not None
|
||||
return f"Hello, {result.data.name}!"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify form mode parameters
|
||||
assert params.mode == "form"
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
@@ -206,7 +205,7 @@ async def test_elicit_complete_notification():
|
||||
|
||||
return "Elicitation completed"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept") # pragma: no cover
|
||||
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
@@ -263,7 +262,7 @@ async def test_elicit_url_typed_results():
|
||||
return "Not cancelled" # pragma: no cover
|
||||
|
||||
# Test declined result
|
||||
async def decline_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(mcp, elicitation_callback=decline_callback) as client:
|
||||
@@ -273,7 +272,7 @@ async def test_elicit_url_typed_results():
|
||||
assert result.content[0].text == "Declined"
|
||||
|
||||
# Test cancelled result
|
||||
async def cancel_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(mcp, elicitation_callback=cancel_callback) as client:
|
||||
@@ -303,7 +302,7 @@ async def test_deprecated_elicit_method():
|
||||
return f"Email: {result.content.get('email', 'none')}"
|
||||
return "No email provided" # pragma: no cover
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify this is form mode
|
||||
assert params.mode == "form"
|
||||
assert params.requested_schema is not None
|
||||
@@ -331,7 +330,7 @@ async def test_ctx_elicit_url_convenience_method():
|
||||
)
|
||||
return f"Result: {result.action}"
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession], params: ElicitRequestParams):
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
assert params.elicitation_id == "ctx-test-001"
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
@@ -103,13 +103,13 @@ async def test_send_request_omits_call_options_when_none_given():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_send_request_timeout_zero_means_no_timeout():
|
||||
"""0 falls through BaseSession's `or`-fallback, so it has always meant
|
||||
"no timeout"; ClientSession still reads it that way."""
|
||||
async def test_send_request_timeout_zero_is_forwarded():
|
||||
"""0 is a real timeout (fail at the first checkpoint, `anyio.fail_after(0)`
|
||||
semantics) and must reach the dispatcher; only `None` means "no timeout"."""
|
||||
dispatcher = StubDispatcher(result={})
|
||||
session = _make_session(dispatcher)
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0)
|
||||
assert dispatcher.requests[0][2] is None
|
||||
await session.send_request(types.PingRequest(), types.EmptyResult, request_read_timeout_seconds=0.0)
|
||||
assert dispatcher.requests[0][2] == {"timeout": 0.0}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tests for the contextvars-carrying memory-stream wrappers."""
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared._context_streams import create_context_streams
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
async def test_sync_close_closes_the_underlying_streams() -> None:
|
||||
"""The wrappers mirror anyio's memory streams: close() is the sync form of aclose()."""
|
||||
send, receive = create_context_streams[str](1)
|
||||
await send.send("queued")
|
||||
send.close()
|
||||
receive.close()
|
||||
with pytest.raises(anyio.ClosedResourceError):
|
||||
await send.send("after close")
|
||||
with pytest.raises(anyio.ClosedResourceError):
|
||||
await receive.receive()
|
||||
@@ -13,11 +13,20 @@ from typing import TYPE_CHECKING, Any
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair
|
||||
from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest, Outbound
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.transport_context import TransportContext
|
||||
from mcp.types import INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, REQUEST_TIMEOUT, ErrorData, Tool
|
||||
from mcp.types import (
|
||||
CONNECTION_CLOSED,
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
REQUEST_TIMEOUT,
|
||||
ErrorData,
|
||||
Tool,
|
||||
)
|
||||
|
||||
from .conftest import PairFactory, direct_pair
|
||||
|
||||
@@ -72,6 +81,7 @@ async def running_pair(
|
||||
finally:
|
||||
tg.cancel_scope.cancel()
|
||||
finally:
|
||||
await resync_tracer()
|
||||
close()
|
||||
|
||||
|
||||
@@ -228,13 +238,15 @@ async def test_ctx_message_metadata_is_none_when_transport_attaches_nothing(pair
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_request_id_exposes_inbound_id(pair_factory: PairFactory):
|
||||
"""JSON-RPC carries the wire id through; direct dispatch has none."""
|
||||
"""Every dispatcher assigns each inbound request a distinct int id; JSON-RPC carries
|
||||
the wire id through, DirectDispatcher synthesizes one (SDK-defined)."""
|
||||
async with running_pair(pair_factory) as (client, _server, _crec, srec):
|
||||
with anyio.fail_after(5):
|
||||
await client.send_raw_request("tools/call", None)
|
||||
await client.send_raw_request("tools/call", None)
|
||||
a, b = (ctx.request_id for ctx in srec.contexts)
|
||||
assert (a is None and b is None) or (isinstance(a, int) and isinstance(b, int) and a != b)
|
||||
assert isinstance(a, int) and isinstance(b, int)
|
||||
assert a != b
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -259,13 +271,10 @@ async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_
|
||||
s_req, s_notify = echo_handlers(Recorder())
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
|
||||
async def late_start():
|
||||
await anyio.sleep(0)
|
||||
await server.run(s_req, s_notify)
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(client.run, c_req, c_notify)
|
||||
tg.start_soon(late_start)
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
# start_soon: the server side only becomes ready once the request below has parked.
|
||||
tg.start_soon(server.run, s_req, s_notify)
|
||||
with anyio.fail_after(5):
|
||||
result = await client.send_raw_request("ping", None)
|
||||
assert result == {"echoed": "ping", "params": {}}
|
||||
@@ -273,6 +282,99 @@ async def test_direct_send_raw_request_issued_before_peer_run_blocks_until_peer_
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_before_run_raises_runtimeerror():
|
||||
"""The not-running guard fires immediately - before any waiting on the peer - matching JSONRPCDispatcher."""
|
||||
client, _server = create_direct_dispatcher_pair()
|
||||
with anyio.fail_after(5), pytest.raises(RuntimeError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert str(exc.value) == "DirectDispatcher.send_raw_request called before run()"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_to_never_run_peer_honors_timeout():
|
||||
"""A configured timeout bounds the wait for a peer whose run() has not started."""
|
||||
client, _server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
with anyio.fail_after(5), pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None, {"timeout": 0})
|
||||
assert exc.value.error.code == REQUEST_TIMEOUT
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_request_parked_waiting_for_peer_run_is_woken_by_peer_close():
|
||||
"""A request waiting on a never-run peer fails with CONNECTION_CLOSED when that peer closes."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
|
||||
async def send() -> None:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
client.close()
|
||||
|
||||
tg.start_soon(send)
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
server.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_after_local_close_raises_and_notify_is_dropped():
|
||||
"""After this side has closed, send_raw_request raises CONNECTION_CLOSED and notify
|
||||
drops fire-and-forget, matching JSONRPCDispatcher (SDK-defined)."""
|
||||
async with running_pair(direct_pair) as (client, _server, _crec, srec):
|
||||
pass # exiting cancels both run() loops, closing both sides
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
await client.notify("notifications/roots/list_changed", None)
|
||||
assert srec.requests == []
|
||||
assert srec.notifications == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_inbound_after_peer_close_refuses_requests_and_drops_notifications():
|
||||
"""Dispatch to a closed side fails the peer's request with CONNECTION_CLOSED and silently
|
||||
drops the peer's notify; the closed side's handlers are never invoked (SDK-defined)."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
crec, srec = Recorder(), Recorder()
|
||||
c_req, c_notify = echo_handlers(crec)
|
||||
s_req, s_notify = echo_handlers(srec)
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
await tg.start(server.run, s_req, s_notify)
|
||||
client.close()
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await server.send_raw_request("roots/list", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
await server.notify("notifications/message", None)
|
||||
server.close()
|
||||
assert crec.requests == []
|
||||
assert crec.notifications == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_inbound_to_closed_never_run_peer_fails_with_connection_closed():
|
||||
"""A peer that closed without ever running refuses dispatch instead of parking the caller."""
|
||||
client, server = create_direct_dispatcher_pair()
|
||||
c_req, c_notify = echo_handlers(Recorder())
|
||||
server.close()
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
await tg.start(client.run, c_req, c_notify)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.send_raw_request("ping", None)
|
||||
assert exc.value.error.code == CONNECTION_CLOSED
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_direct_send_raw_request_and_notify_raise_runtimeerror_when_no_peer_connected():
|
||||
d = DirectDispatcher(TransportContext(kind="direct", can_send_request=True))
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
|
||||
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData
|
||||
from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams, ErrorData, JSONRPCError
|
||||
|
||||
|
||||
def test_url_elicitation_required_error_create_with_single_elicitation() -> None:
|
||||
@@ -162,3 +162,14 @@ def test_url_elicitation_required_error_exception_message() -> None:
|
||||
|
||||
# The exception's string representation should match the message
|
||||
assert str(error) == "URL elicitation required"
|
||||
|
||||
|
||||
def test_from_jsonrpc_error_preserves_code_message_and_data() -> None:
|
||||
"""Building an MCPError from a wire JSONRPCError keeps every error field."""
|
||||
wire = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=3,
|
||||
error=ErrorData(code=URL_ELICITATION_REQUIRED, message="go elsewhere", data={"hint": "y"}),
|
||||
)
|
||||
error = MCPError.from_jsonrpc_error(wire)
|
||||
assert error.error == ErrorData(code=URL_ELICITATION_REQUIRED, message="go elsewhere", data={"hint": "y"})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,447 +0,0 @@
|
||||
import anyio
|
||||
import pytest
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp.shared.memory import create_client_server_memory_streams
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
METHOD_NOT_FOUND,
|
||||
PARSE_ERROR,
|
||||
CancelledNotification,
|
||||
CancelledNotificationParams,
|
||||
ClientResult,
|
||||
EmptyResult,
|
||||
ErrorData,
|
||||
JSONRPCError,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_request_cancellation():
|
||||
"""Test that requests can be cancelled while in-flight."""
|
||||
ev_tool_called = anyio.Event()
|
||||
ev_cancelled = anyio.Event()
|
||||
request_id = None
|
||||
|
||||
# Create a server with a slow tool
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
nonlocal request_id, ev_tool_called
|
||||
if params.name == "slow_tool":
|
||||
request_id = ctx.request_id
|
||||
ev_tool_called.set()
|
||||
await anyio.sleep(10) # Long enough to ensure we can cancel
|
||||
return types.CallToolResult(content=[]) # pragma: no cover
|
||||
raise ValueError(f"Unknown tool: {params.name}") # pragma: no cover
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
raise NotImplementedError
|
||||
|
||||
server = Server(
|
||||
name="TestSessionServer",
|
||||
on_call_tool=handle_call_tool,
|
||||
on_list_tools=handle_list_tools,
|
||||
)
|
||||
|
||||
async def make_request(client: Client):
|
||||
nonlocal ev_cancelled
|
||||
try:
|
||||
await client.session.send_request(
|
||||
types.CallToolRequest(
|
||||
params=types.CallToolRequestParams(name="slow_tool", arguments={}),
|
||||
),
|
||||
types.CallToolResult,
|
||||
)
|
||||
pytest.fail("Request should have been cancelled") # pragma: no cover
|
||||
except MCPError as e:
|
||||
# Expected - request was cancelled
|
||||
assert "Request cancelled" in str(e)
|
||||
ev_cancelled.set()
|
||||
|
||||
async with Client(server) as client:
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
tg.start_soon(make_request, client)
|
||||
|
||||
# Wait for the request to be in-flight
|
||||
with anyio.fail_after(1): # Timeout after 1 second
|
||||
await ev_tool_called.wait()
|
||||
|
||||
# Send cancellation notification
|
||||
assert request_id is not None
|
||||
await client.session.send_notification(
|
||||
CancelledNotification(params=CancelledNotificationParams(request_id=request_id))
|
||||
)
|
||||
|
||||
# Give cancellation time to process
|
||||
with anyio.fail_after(1): # pragma: no branch
|
||||
await ev_cancelled.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_response_id_type_mismatch_string_to_int():
|
||||
"""Test that responses with string IDs are correctly matched to requests sent with
|
||||
integer IDs.
|
||||
|
||||
This handles the case where a server returns "id": "0" (string) but the client
|
||||
sent "id": 0 (integer). Without ID type normalization, this would cause a timeout.
|
||||
"""
|
||||
ev_response_received = anyio.Event()
|
||||
result_holder: list[types.EmptyResult] = []
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Receive a request and respond with a string ID instead of integer."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
root = message.message
|
||||
assert isinstance(root, JSONRPCRequest)
|
||||
# Get the original request ID (which is an integer)
|
||||
request_id = root.id
|
||||
assert isinstance(request_id, int), f"Expected int, got {type(request_id)}"
|
||||
|
||||
# Respond with the ID as a string (simulating a buggy server)
|
||||
response = JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id=str(request_id), # Convert to string to simulate mismatch
|
||||
result={},
|
||||
)
|
||||
await server_write.send(SessionMessage(message=response))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
nonlocal result_holder
|
||||
# Send a ping request (uses integer ID internally)
|
||||
result = await client_session.send_ping()
|
||||
result_holder.append(result)
|
||||
ev_response_received.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_response_received.wait()
|
||||
|
||||
assert len(result_holder) == 1
|
||||
assert isinstance(result_holder[0], EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_error_response_id_type_mismatch_string_to_int():
|
||||
"""Test that error responses with string IDs are correctly matched to requests
|
||||
sent with integer IDs.
|
||||
|
||||
This handles the case where a server returns an error with "id": "0" (string)
|
||||
but the client sent "id": 0 (integer).
|
||||
"""
|
||||
ev_error_received = anyio.Event()
|
||||
error_holder: list[MCPError | Exception] = []
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Receive a request and respond with an error using a string ID."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
root = message.message
|
||||
assert isinstance(root, JSONRPCRequest)
|
||||
request_id = root.id
|
||||
assert isinstance(request_id, int)
|
||||
|
||||
# Respond with an error, using the ID as a string
|
||||
error_response = JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=str(request_id), # Convert to string to simulate mismatch
|
||||
error=ErrorData(code=-32600, message="Test error"),
|
||||
)
|
||||
await server_write.send(SessionMessage(message=error_response))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
nonlocal error_holder
|
||||
try:
|
||||
await client_session.send_ping()
|
||||
pytest.fail("Expected MCPError to be raised") # pragma: no cover
|
||||
except MCPError as e:
|
||||
error_holder.append(e)
|
||||
ev_error_received.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_error_received.wait()
|
||||
|
||||
assert len(error_holder) == 1
|
||||
assert "Test error" in str(error_holder[0])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_response_id_non_numeric_string_no_match():
|
||||
"""Test that responses with non-numeric string IDs don't incorrectly match
|
||||
integer request IDs.
|
||||
|
||||
If a server returns "id": "abc" (non-numeric string), it should not match
|
||||
a request sent with "id": 0 (integer).
|
||||
"""
|
||||
ev_timeout = anyio.Event()
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Receive a request and respond with a non-numeric string ID."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
|
||||
# Respond with a non-numeric string ID (should not match)
|
||||
response = JSONRPCResponse(
|
||||
jsonrpc="2.0",
|
||||
id="not_a_number", # Non-numeric string
|
||||
result={},
|
||||
)
|
||||
await server_write.send(SessionMessage(message=response))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
try:
|
||||
# Use a short timeout since we expect this to fail
|
||||
await client_session.send_request(
|
||||
types.PingRequest(),
|
||||
types.EmptyResult,
|
||||
request_read_timeout_seconds=0.5,
|
||||
)
|
||||
pytest.fail("Expected timeout") # pragma: no cover
|
||||
except MCPError as e:
|
||||
assert "Timed out" in str(e)
|
||||
ev_timeout.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_timeout.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_connection_closed():
|
||||
"""Test that pending requests are cancelled when the connection is closed remotely."""
|
||||
|
||||
ev_closed = anyio.Event()
|
||||
ev_response = anyio.Event()
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
"""Send a request in a separate task"""
|
||||
nonlocal ev_response
|
||||
try:
|
||||
# any request will do
|
||||
await client_session.initialize()
|
||||
pytest.fail("Request should have errored") # pragma: no cover
|
||||
except MCPError as e:
|
||||
# Expected - request errored
|
||||
assert "Connection closed" in str(e)
|
||||
ev_response.set()
|
||||
|
||||
async def mock_server():
|
||||
"""Wait for a request, then close the connection"""
|
||||
nonlocal ev_closed
|
||||
# Wait for a request
|
||||
await server_read.receive()
|
||||
# Close the connection, as if the server exited
|
||||
server_write.close()
|
||||
server_read.close()
|
||||
ev_closed.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(read_stream=client_read, write_stream=client_write) as client_session,
|
||||
):
|
||||
tg.start_soon(make_request, client_session)
|
||||
tg.start_soon(mock_server)
|
||||
|
||||
with anyio.fail_after(1):
|
||||
await ev_closed.wait()
|
||||
with anyio.fail_after(1): # pragma: no branch
|
||||
await ev_response.wait()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_null_id_error_surfaced_via_message_handler():
|
||||
"""Test that a JSONRPCError with id=None is surfaced to the message handler.
|
||||
|
||||
Per JSON-RPC 2.0, error responses use id=null when the request id could not
|
||||
be determined (e.g., parse errors). These cannot be correlated to any pending
|
||||
request, so they are forwarded to the message handler as MCPError.
|
||||
"""
|
||||
ev_error_received = anyio.Event()
|
||||
error_holder: list[MCPError] = []
|
||||
|
||||
async def capture_errors(
|
||||
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
|
||||
) -> None:
|
||||
assert isinstance(message, MCPError)
|
||||
error_holder.append(message)
|
||||
ev_error_received.set()
|
||||
|
||||
sent_error = ErrorData(code=PARSE_ERROR, message="Parse error")
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
_server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Send a null-id error (simulating a parse error)."""
|
||||
error_response = JSONRPCError(jsonrpc="2.0", id=None, error=sent_error)
|
||||
await server_write.send(SessionMessage(message=error_response))
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(
|
||||
read_stream=client_read,
|
||||
write_stream=client_write,
|
||||
message_handler=capture_errors,
|
||||
) as _client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_error_received.wait()
|
||||
|
||||
assert len(error_holder) == 1
|
||||
assert error_holder[0].error == sent_error
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_null_id_error_does_not_affect_pending_request():
|
||||
"""Test that a null-id error doesn't interfere with an in-flight request.
|
||||
|
||||
When a null-id error arrives while a request is pending, the error should
|
||||
go to the message handler and the pending request should still complete
|
||||
normally with its own response.
|
||||
"""
|
||||
ev_error_received = anyio.Event()
|
||||
ev_response_received = anyio.Event()
|
||||
error_holder: list[MCPError] = []
|
||||
result_holder: list[EmptyResult] = []
|
||||
|
||||
async def capture_errors(
|
||||
message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception,
|
||||
) -> None:
|
||||
assert isinstance(message, MCPError)
|
||||
error_holder.append(message)
|
||||
ev_error_received.set()
|
||||
|
||||
sent_error = ErrorData(code=PARSE_ERROR, message="Parse error")
|
||||
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async def mock_server():
|
||||
"""Read a request, inject a null-id error, then respond normally."""
|
||||
message = await server_read.receive()
|
||||
assert isinstance(message, SessionMessage)
|
||||
assert isinstance(message.message, JSONRPCRequest)
|
||||
request_id = message.message.id
|
||||
|
||||
# First, send a null-id error (should go to message handler)
|
||||
await server_write.send(SessionMessage(message=JSONRPCError(jsonrpc="2.0", id=None, error=sent_error)))
|
||||
|
||||
# Then, respond normally to the pending request
|
||||
await server_write.send(SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=request_id, result={})))
|
||||
|
||||
async def make_request(client_session: ClientSession):
|
||||
result = await client_session.send_ping()
|
||||
result_holder.append(result)
|
||||
ev_response_received.set()
|
||||
|
||||
async with (
|
||||
anyio.create_task_group() as tg,
|
||||
ClientSession(
|
||||
read_stream=client_read,
|
||||
write_stream=client_write,
|
||||
message_handler=capture_errors,
|
||||
) as client_session,
|
||||
):
|
||||
tg.start_soon(mock_server)
|
||||
tg.start_soon(make_request, client_session)
|
||||
|
||||
with anyio.fail_after(2): # pragma: no branch
|
||||
await ev_error_received.wait()
|
||||
await ev_response_received.wait()
|
||||
|
||||
# Null-id error reached the message handler
|
||||
assert len(error_holder) == 1
|
||||
assert error_holder[0].error == sent_error
|
||||
|
||||
# Pending request completed successfully
|
||||
assert len(result_holder) == 1
|
||||
assert isinstance(result_holder[0], EmptyResult)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_answers_unknown_request_method_with_method_not_found():
|
||||
"""A peer request whose method is not in the receive union gets -32601
|
||||
(METHOD_NOT_FOUND) on the wire, not a validation failure (-32602)."""
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async with ClientSession(read_stream=client_read, write_stream=client_write):
|
||||
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=7, method="x/unknown")))
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
out = await server_read.receive()
|
||||
|
||||
assert isinstance(out, SessionMessage)
|
||||
assert isinstance(out.message, JSONRPCError)
|
||||
assert out.message.id == 7
|
||||
assert out.message.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="x/unknown")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_receive_loop_drops_unknown_notification_method_without_response():
|
||||
"""An unknown notification method is dropped silently: JSON-RPC forbids
|
||||
responses to notifications, and the receive loop keeps serving."""
|
||||
async with create_client_server_memory_streams() as (client_streams, server_streams):
|
||||
client_read, client_write = client_streams
|
||||
server_read, server_write = server_streams
|
||||
|
||||
async with ClientSession(read_stream=client_read, write_stream=client_write):
|
||||
await server_write.send(SessionMessage(message=JSONRPCNotification(jsonrpc="2.0", method="x/unknown")))
|
||||
# The next wire output must be the answer to this follow-up ping,
|
||||
# proving the notification produced no response and the loop survived.
|
||||
await server_write.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")))
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
out = await server_read.receive()
|
||||
|
||||
assert isinstance(out, SessionMessage)
|
||||
assert isinstance(out.message, JSONRPCResponse)
|
||||
assert out.message.id == 1
|
||||
@@ -18,16 +18,20 @@ from urllib.parse import urlparse
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from httpx_sse import ServerSentEvent
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Mount
|
||||
from starlette.types import Message, Scope
|
||||
|
||||
from mcp import MCPError, types
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import StreamableHTTPTransport, streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.streamable_http import (
|
||||
GET_STREAM_KEY,
|
||||
MCP_PROTOCOL_VERSION_HEADER,
|
||||
MCP_SESSION_ID_HEADER,
|
||||
SESSION_ID_PATTERN,
|
||||
@@ -41,7 +45,6 @@ from mcp.server.streamable_http import (
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from mcp.shared._compat import resync_tracer
|
||||
from mcp.shared._context import RequestContext
|
||||
from mcp.shared._context_streams import create_context_streams
|
||||
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
@@ -1232,7 +1235,7 @@ async def test_streamablehttp_server_sampling(basic_app: Starlette) -> None:
|
||||
|
||||
# Define sampling callback that returns a mock response
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession],
|
||||
context: ClientRequestContext,
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult:
|
||||
nonlocal sampling_callback_invoked, captured_message_params
|
||||
@@ -2224,3 +2227,115 @@ async def test_streamable_http_client_preserves_custom_with_mcp_headers(context_
|
||||
|
||||
assert "content-type" in headers_data
|
||||
assert headers_data["content-type"] == "application/json"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_standalone_stream_teardown_mid_listen_is_not_an_error(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Standalone-stream teardown while the writer is parked in receive() logs no error (SDK-defined)."""
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
app=_create_server(),
|
||||
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
|
||||
notified = anyio.Event()
|
||||
|
||||
async def message_handler(
|
||||
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
|
||||
) -> None:
|
||||
# Only the standalone-stream notification is teed to the handler here.
|
||||
assert isinstance(message, types.ResourceUpdatedNotification)
|
||||
notified.set()
|
||||
|
||||
async with session_manager.run():
|
||||
async with (
|
||||
make_client(app) as http_client,
|
||||
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) as (read_stream, write_stream),
|
||||
ClientSession(read_stream, write_stream, message_handler=message_handler) as session,
|
||||
):
|
||||
await session.initialize()
|
||||
# A notification with no related request rides the GET stream, proving the writer is live.
|
||||
await session.call_tool("test_tool_with_standalone_notification", {})
|
||||
with anyio.fail_after(5):
|
||||
await notified.wait()
|
||||
# Tear the standalone stream down while the writer is parked on it.
|
||||
(transport,) = session_manager._server_instances.values() # pyright: ignore[reportPrivateUsage]
|
||||
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
|
||||
assert "Error in standalone SSE writer" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_standalone_stream_teardown_between_dequeues_is_not_an_error(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Teardown landing while the standalone writer is between dequeues logs no error.
|
||||
|
||||
SDK-defined: after teardown the writer's next dequeue hits its own closed stream — expected
|
||||
disconnect noise. The public surface cannot force this window (the in-process client consumes
|
||||
SSE without backpressure), so the test drives the transport's ASGI entry point with a gated `send`.
|
||||
"""
|
||||
transport = StreamableHTTPServerTransport(
|
||||
mcp_session_id=None,
|
||||
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
|
||||
)
|
||||
# The GET handler only checks that a read-stream writer exists; it is never written to.
|
||||
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
|
||||
transport._read_stream_writer = read_stream_writer # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
stream_registered = anyio.Event()
|
||||
|
||||
class SignalingStreams(
|
||||
dict[types.RequestId, tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]]]
|
||||
):
|
||||
# Only the GET handler inserts here, so any insert is the standalone stream registration.
|
||||
def __setitem__(
|
||||
self,
|
||||
key: types.RequestId,
|
||||
value: tuple[MemoryObjectSendStream[EventMessage], MemoryObjectReceiveStream[EventMessage]],
|
||||
) -> None:
|
||||
super().__setitem__(key, value)
|
||||
stream_registered.set()
|
||||
|
||||
transport._request_streams = SignalingStreams() # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
gate = anyio.Event()
|
||||
sent: list[Message] = []
|
||||
|
||||
async def asgi_send(message: Message) -> None:
|
||||
sent.append(message)
|
||||
await gate.wait()
|
||||
|
||||
# Never delivers anything, parking the response's disconnect listener.
|
||||
disconnect_send, disconnect_receive = anyio.create_memory_object_stream[Message](0)
|
||||
|
||||
async def asgi_receive() -> Message:
|
||||
return await disconnect_receive.receive()
|
||||
|
||||
scope: Scope = {
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/mcp",
|
||||
"query_string": b"",
|
||||
"headers": [(b"accept", b"text/event-stream")],
|
||||
}
|
||||
notification = types.JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized")
|
||||
|
||||
async with read_stream_writer, read_stream, disconnect_send, disconnect_receive:
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
tg.start_soon(transport.handle_request, scope, asgi_receive, asgi_send)
|
||||
await stream_registered.wait()
|
||||
standalone_send = transport._request_streams[GET_STREAM_KEY][0] # pyright: ignore[reportPrivateUsage]
|
||||
# Zero-buffer rendezvous: once send() returns, the writer has dequeued the event
|
||||
# and is blocked forwarding it past the closed gate — the between-dequeues window.
|
||||
await standalone_send.send(EventMessage(notification))
|
||||
await transport._clean_up_memory_streams(GET_STREAM_KEY) # pyright: ignore[reportPrivateUsage]
|
||||
# Unblock the response; the writer's next dequeue hits its closed stream.
|
||||
gate.set()
|
||||
|
||||
assert sent[0]["type"] == "http.response.start"
|
||||
assert sent[0]["status"] == 200
|
||||
body_chunks = [message for message in sent if message["type"] == "http.response.body"]
|
||||
assert b"notifications/initialized" in body_chunks[0]["body"]
|
||||
assert body_chunks[-1] == {"type": "http.response.body", "body": b"", "more_body": False}
|
||||
assert "Error in standalone SSE writer" not in caplog.text
|
||||
assert "Error in standalone SSE response" not in caplog.text
|
||||
|
||||
Reference in New Issue
Block a user