Files
kefu/wechat_rpa/.venv/Lib/site-packages/mcp/shared/direct_dispatcher.py
T
2026-09-21 10:34:06 +08:00

335 lines
14 KiB
Python

"""In-memory `Dispatcher` that wires two peers together with no transport.
`DirectDispatcher` is the simplest possible `Dispatcher` implementation: a
request on one side directly invokes the other side's `on_request`. There is no
serialization, no JSON-RPC framing, and no streams. It exists to:
* prove the `Dispatcher` Protocol is implementable without JSON-RPC
* provide a fast substrate for testing the layers above the dispatcher
(`ServerRunner`, `Context`, `Connection`) without wire-level moving parts
* embed a server in-process when the JSON-RPC overhead is unnecessary
Like `JSONRPCDispatcher`, this is an exception-to-error boundary: a handler
exception surfaces to the caller as `MCPError`. The `raise_handler_exceptions`
knob controls whether unmapped exceptions are sanitized (matching the wire
path) or chained as ``__cause__`` for in-process debugging.
"""
from __future__ import annotations
import logging
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any
import anyio
import anyio.abc
from mcp_types import CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, REQUEST_TIMEOUT, RequestId
from pydantic import ValidationError
from mcp.shared._compat import resync_tracer
from mcp.shared.dispatcher import (
CallOptions,
OnNotify,
OnNotifyIntercept,
OnRequest,
ProgressFnT,
coerce_request_id,
run_notify_intercept,
)
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.message import MessageMetadata
from mcp.shared.transport_context import TransportContext
logger = logging.getLogger(__name__)
__all__ = ["DirectDispatcher", "create_direct_dispatcher_pair"]
DIRECT_TRANSPORT_KIND = "direct"
_Request = Callable[[str, Mapping[str, Any] | None, CallOptions | None], Awaitable[dict[str, Any]]]
_Notify = Callable[[str, Mapping[str, Any] | None], Awaitable[None]]
@dataclass
class _DirectDispatchContext:
"""`DispatchContext` for an inbound request on a `DirectDispatcher`.
The back-channel callables target the *originating* side, so a handler's
`send_raw_request` reaches the peer that made the inbound request.
"""
transport: TransportContext
_back_request: _Request
_back_notify: _Notify
request_id: RequestId | None = None
"""The caller-supplied `CallOptions["request_id"]`, else 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
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
@property
def can_send_request(self) -> bool:
return self.transport.can_send_request
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
await self._back_notify(method, params)
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
if not self.can_send_request:
raise NoBackChannelError(method)
return await self._back_request(method, params, opts)
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
if self._on_progress is not None:
await self._on_progress(progress, total, message)
class DirectDispatcher:
"""A `Dispatcher` that calls a peer's handlers directly, in-process.
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, *, raise_handler_exceptions: bool = True):
self._transport_ctx = transport_ctx
self._raise_handler_exceptions = raise_handler_exceptions
self._peer: DirectDispatcher | None = None
self._on_request: OnRequest | None = None
self._on_notify: OnNotify | None = None
self._on_notify_intercept: OnNotifyIntercept | None = None
self._next_id = 0
self._in_flight_ids: set[RequestId] = set()
self._ready = anyio.Event()
self._close_event = anyio.Event()
self._running = False
self._closed = False
def connect_to(self, peer: DirectDispatcher) -> None:
self._peer = peer
async def send_raw_request(
self,
method: str,
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, opts: CallOptions | None = None) -> None:
"""Send a notification by invoking the peer's `on_notify` directly.
Fire-and-forget: usable before `run()` (delivery waits for the peer to
start), and after close it is silently dropped, matching
`JSONRPCDispatcher.notify`. `opts` is accepted for `Dispatcher`
conformance; there is no HTTP layer here so `headers` is ignored.
"""
if self._peer is None:
raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()")
if self._closed:
logger.debug("dropped notification %r on closed DirectDispatcher", method)
return
await self._peer._dispatch_notify(method, params)
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
on_notify_intercept: OnNotifyIntercept | None = None,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
"""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._on_notify_intercept = on_notify_intercept
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 = True
self._close_event.set()
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]:
opts = opts or {}
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
supplied_id = opts.get("request_id")
if supplied_id is not None:
request_id: RequestId = supplied_id
# Collisions use the same coerced domain as JSONRPCDispatcher's
# pending keys, so this in-memory stand-in raises for exactly
# the ids the wire dispatcher would; the context still sees
# the verbatim value.
in_flight_key = coerce_request_id(request_id)
if in_flight_key in self._in_flight_ids:
raise ValueError(f"request id {request_id!r} is already in flight")
else:
# Synthesize an id (the DispatchContext contract reserves None
# for notifications), minting past any key a supplied id
# occupies: the collision error is reserved for the caller
# who actually chose the id.
self._next_id += 1
while self._next_id in self._in_flight_ids:
self._next_id += 1
request_id = self._next_id
in_flight_key = request_id
self._in_flight_ids.add(in_flight_key)
dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=request_id)
try:
return await self._on_request(dctx, method, params)
except MCPError:
raise
except ValidationError as e:
# Same shape JSONRPCDispatcher writes, so runner-over-direct
# tests see what runner-over-JSONRPC would.
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") from e
except Exception as e:
# Single owner of the in-proc exception-to-error policy (mirrors
# JSONRPCDispatcher / `_streamable_http_modern._to_jsonrpc_response`
# for the wire paths). True chains the original for in-process
# debugging; False sanitizes to match the wire path's leak guard.
if self._raise_handler_exceptions:
raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e
logger.exception("request handler raised")
raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None
finally:
self._in_flight_ids.discard(in_flight_key)
except TimeoutError:
raise MCPError(
code=REQUEST_TIMEOUT,
message=f"Timed out after {opts.get('timeout')}s waiting for {method!r}",
) from None
finally:
await resync_tracer()
async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) -> None:
try:
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
if run_notify_intercept(self._on_notify_intercept, method, params):
return
assert self._on_notify is not None
dctx = self._make_context()
await self._on_notify(dctx, method, params)
def create_direct_dispatcher_pair(
*,
can_send_request: bool = True,
headers: Mapping[str, str] | None = None,
raise_handler_exceptions: bool = True,
) -> tuple[DirectDispatcher, DirectDispatcher]:
"""Create two `DirectDispatcher` instances wired to each other.
Args:
can_send_request: Sets `TransportContext.can_send_request` on both
sides. Pass `False` to simulate a transport with no back-channel.
headers: Sets `TransportContext.headers` on both sides.
raise_handler_exceptions: When `True` (the default - this is an
in-process debugging substrate), an unmapped handler exception
reaches the caller as `MCPError` with the original chained as
``__cause__``. When `False` it is sanitized to an opaque
`INTERNAL_ERROR` so the in-process path matches the wire.
Returns:
A `(client, server)` pair. The wiring is symmetric, so the roles
are conventional only.
"""
ctx = TransportContext(kind=DIRECT_TRANSPORT_KIND, can_send_request=can_send_request, headers=headers)
client = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions)
server = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions)
client.connect_to(server)
server.connect_to(client)
return client, server