diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index ef6eb25a33..b30872f5d7 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -96,6 +96,40 @@ jobs: if: runner.os != 'Windows' run: uv run --frozen --no-sync strict-no-cover + transport-examples: + name: transport examples (${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.14"] + env: + UV_PROJECT_ENVIRONMENT: examples/transports/.venv + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: true + version: 0.9.5 + - name: Install transport dependencies + run: uv sync --frozen --package mcp-transport-examples --group dev --python ${{ matrix.python-version }} + - name: Run all adapter regressions + run: >- + uv run --frozen --no-sync --package mcp-transport-examples --group dev + pytest -c examples/transports/pyproject.toml examples/transports/tests --record-mode=none + --junitxml=transport-results.xml + - name: Retain adapter test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: transport-results-${{ matrix.python-version }} + path: transport-results.xml + - name: Check adapter types + run: uv run --frozen --no-sync --package mcp-transport-examples --group dev pyright --project examples/transports + readme-snippets: runs-on: ubuntu-latest steps: diff --git a/examples/transports/README.md b/examples/transports/README.md new file mode 100644 index 0000000000..c784942b12 --- /dev/null +++ b/examples/transports/README.md @@ -0,0 +1,65 @@ +# Reference custom transports + +This package contains experimental adapters for the public MCP transport API. Installing `mcp` does not install their dependencies. The adapters are not production-ready transports or official MCP wire bindings. + +## Native gRPC + +```bash +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv sync --frozen --package mcp-transport-examples --group dev +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples python examples/transports/demo_grpc.py +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples python examples/transports/demo_grpc_features.py +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples --group dev pytest -c examples/transports/pyproject.toml examples/transports/tests --record-mode=none +uv run --frozen pyright --project examples/transports +``` + +Run these commands from the repository root. The programs start their own loopback gRPC listener. No broker is needed. `grpc_client(channel)` and `grpc_server(listener)` return `DispatcherTransport` objects for the existing client and server runtime APIs. You own the channel and listener; runtime shutdown stops MCP handlers without taking ownership of other gRPC services on the listener. + +Register the binding through `runtime.connect(grpc_server(listener))` before starting the listener. Use one MCP binding per gRPC server. The server adapter rejects excess work at `max_requests`, which defaults to 64, rather than queuing unlimited waiting handlers. + +The binding serves modern per-request MCP envelopes. It has no legacy initialize handshake. Each MCP request is a native server-streaming RPC on `/mcp.transport.example.MCP/Call`; gRPC correlates calls and provides deadlines and cancellation. The auxiliary request ID supports MCP subscription correlation and stays local to each client's calls. Native progress uses the protobuf `report_progress` opt-in from `CallOptions["on_progress"]`; `_meta.progressToken` alone does not enable it. Progress carries the auxiliary ID for notification observers, while the originating RPC selects the callback without token-based demultiplexing. + +`mcp_transport_examples/rpc.proto` defines protobuf envelopes with JSON-encoded parameters, results, and error data. There is no JSON-RPC envelope. JSON payloads preserve arbitrary extension fields and integer precision, which protobuf `Struct` would otherwise lose through its floating-point number representation. This is an example binding, not compatibility with another project's gRPC schema. + +Notifications precede one terminal result or error, followed by end-of-stream. Progress, subscription acknowledgments, and change events use the originating RPC's response stream. Unsolicited notifications without a request channel are unsupported. Ordinary MCP errors preserve their code, message, and data. Native deadline failures become `REQUEST_TIMEOUT`; other gRPC failures become `CONNECTION_CLOSED` with the original status exception as their cause. + +The examples and regression tests check both server APIs, progress, subscriptions, multi-round-trip results, concurrent clients with colliding request IDs, caller cancellation, deadlines, runtime shutdown, borrowed-channel closure, and client shutdown during a blocked callback. Cancellation is signalled before handler cleanup begins. Active handlers and callbacks are joined before their owning resources close, including shielded cleanup that takes longer than five seconds. Code that ignores cancellation indefinitely can therefore hold shutdown indefinitely; enforce a hard process deadline in your supervisor rather than closing resources under running code. The SDK's five-second transport and application cleanup deadlines do not replace this join. + +### Generate the protobuf bindings + +```bash +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples --group dev python -m grpc_tools.protoc --proto_path=examples/transports --python_out=examples/transports --pyi_out=examples/transports examples/transports/mcp_transport_examples/rpc.proto +``` + +Use the pinned compiler. Generated implementation code is excluded from adapter coverage; regeneration checks its provenance. + +### TLS and peer identity + +```bash +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples --group dev pytest -c examples/transports/pyproject.toml examples/transports/tests/test_grpc_tls.py --record-mode=none +``` + +These tests create temporary certificate authorities and real local TLS endpoints. They check mutual TLS, server-only TLS, and plaintext connections. Missing or untrusted client certificates cannot reach MCP middleware when the listener requires client authentication. Forged MCP client information and gRPC invocation metadata do not change the verified identity. + +Configure TLS through your gRPC channel and listener credentials. Set `require_client_auth=True` on `grpc.ssl_server_credentials()` when clients must present a certificate. Handlers receive `GRPCContext.peer_identity_key` and `peer_identities` from gRPC's native authentication context. Without client authentication, these are `None` and an empty tuple, including on encrypted server-only TLS connections. + +Certificate validation is not application authorization. The identity values do not identify their issuing authority. If you trust independent authorities that can issue the same common name or subject alternative name, do not treat that name as a globally unique principal. Choose a trust-domain namespace and certificate-issuance policy before using these values with `RequestStateSecurity.bind_principal`. An issuer-name string or untrusted invocation metadata cannot supply that trust boundary. + +### Event-loop lifetime + +```bash +UV_PROJECT_ENVIRONMENT=examples/transports/.venv uv run --frozen --package mcp-transport-examples python examples/transports/reproduce_grpc_loop_shutdown.py +``` + +This diagnostic intentionally fails when a native completion targets a closed loop. It reproduces the limitation without importing MCP. The failure was observed with `grpcio==1.84.0` on macOS and Python 3.14.6; the assertion includes the runtime versions. + +Keep one long-lived asyncio event loop per process or test worker. Create, use, and close all gRPC resources on that loop. Repeated `anyio.run()` or `asyncio.run()` lifetimes are outside this adapter's current support. gRPC's process-wide completion queue can deliver cancelled connectivity-watch callbacks after `channel.close()` returns. Joining MCP handlers does not drain those native callbacks. The adapter tests keep one AnyIO runner alive for the session; they do not suppress loop errors or claim an upstream correction. + +### Validation boundaries + +The dedicated CI job runs the adapter suite on Python 3.10 and 3.14 and retains JUnit results. Final compatibility review and cross-platform validation remain open gates. + +The gRPC cassette tests record real calls with `cassetter` and replay with `--record-mode=none`. They check payload fidelity, progress, and application errors. They also compare serialized requests with the recording: the current matcher matches only the RPC method, which is insufficient for a generic MCP binding. Each cassette contains one RPC to avoid replaying a newly recorded call as the response to a different request during recording. + +The lifecycle, capacity, and malformed-frame regression tests own a gRPC server inside the test process. That server is the software under test, not an external service; replaying its outputs would bypass the behavior being checked. Cassette tests separately compare recorded native results with the current in-process MCP handler. `cassetter` lacks parts of the streaming-call cancellation interface, so it is not used to stand in for live lifecycle checks. + +Binary protobuf payloads are not pattern-scrubbed. Inspect new cassettes before committing them; the checked-in recordings contain only public test data. diff --git a/examples/transports/demo_grpc.py b/examples/transports/demo_grpc.py new file mode 100644 index 0000000000..ad14db06de --- /dev/null +++ b/examples/transports/demo_grpc.py @@ -0,0 +1,71 @@ +"""Exercise the native gRPC binding over a real loopback connection.""" + +from contextlib import AsyncExitStack + +import anyio +import grpc.aio +from mcp import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent, Tool + +from mcp_transport_examples.grpc import grpc_client, grpc_server +from mcp_transport_examples.grpc_context import GRPCContext + + +async def verify(highlevel: bool, mode: str) -> None: + if highlevel: + server = MCPServer("Native gRPC") + + @server.tool() + async def echo(value: str, ctx: Context) -> str: + assert isinstance(ctx.transport, GRPCContext) + assert not ctx.transport.can_send_request + await ctx.report_progress(1, 2, "halfway") + return value + + else: + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "echo" + assert isinstance(ctx.transport, GRPCContext) + assert params.arguments is not None + await ctx.session.report_progress(1, 2, "halfway") + return CallToolResult(content=[TextContent(text=str(params.arguments["value"]))]) + + server = Server("Native gRPC", on_list_tools=list_tools, on_call_tool=call_tool) + + updates: list[tuple[float, float | None, str | None]] = [] + + async def progress(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + async with AsyncExitStack() as stack: + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + stack.push_async_callback(listener.stop, 0) + runtime = await stack.enter_async_context(server.serve()) + await runtime.connect(grpc_server(listener)) + await listener.start() + channel = await stack.enter_async_context(grpc.aio.insecure_channel(f"127.0.0.1:{port}")) + client = await stack.enter_async_context(Client(grpc_client(channel), mode=mode)) + value = "MCP without a JSON-RPC envelope" + result = await client.call_tool("echo", {"value": value}, progress_callback=progress) + content = result.content[0] + assert isinstance(content, TextContent) + assert content.text == value + assert updates == [(1, 2, "halfway")] + + +async def main() -> None: + for highlevel in (False, True): + for mode in ("auto", "2026-07-28"): + with anyio.fail_after(5): + await verify(highlevel, mode) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/transports/demo_grpc_features.py b/examples/transports/demo_grpc_features.py new file mode 100644 index 0000000000..7418ecaaba --- /dev/null +++ b/examples/transports/demo_grpc_features.py @@ -0,0 +1,91 @@ +"""Live checks for subscriptions and multi-round-trip results over native gRPC.""" + +from contextlib import AsyncExitStack + +import anyio +import grpc.aio +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.client.subscriptions import ToolsListChanged +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitRequestParams, ElicitResult, InputRequiredResult + +from mcp_transport_examples.grpc import grpc_client, grpc_server + + +async def verify() -> None: + server = MCPServer("native features") + entered = {"alice": anyio.Event(), "bob": anyio.Event()} + + @server.tool() + async def overlap(label: str, ctx: Context) -> str: + assert ctx.request_context.request_id == 0 + entered[label].set() + await entered["bob" if label == "alice" else "alice"].wait() + return label + + @server.tool() + async def announce(ctx: Context) -> str: + await ctx.notify_tools_changed() + return "announced" + + @server.tool() + async def confirm(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is not None: + assert ctx.request_state == "awaiting confirmation" + answer = ctx.input_responses["confirm"] + assert isinstance(answer, ElicitResult) + assert answer.action == "accept" + return "confirmed" + return InputRequiredResult( + input_requests={ + "confirm": ElicitRequest( + params=ElicitRequestFormParams( + message="Confirm?", requested_schema={"type": "object", "properties": {}} + ) + ) + }, + request_state="awaiting confirmation", + ) + + async def elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={}) + + async with AsyncExitStack() as stack: + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + stack.push_async_callback(listener.stop, 0) + runtime = await stack.enter_async_context(server.serve()) + await runtime.connect(grpc_server(listener)) + await listener.start() + channel = await stack.enter_async_context(grpc.aio.insecure_channel(f"127.0.0.1:{port}")) + client = await stack.enter_async_context(Client(grpc_client(channel), elicitation_callback=elicit)) + async with client.listen(tools_list_changed=True) as subscription: + result = await client.call_tool("announce") + assert result.structured_content == {"result": "announced"} + event = await anext(subscription) + assert isinstance(event, ToolsListChanged) + result = await client.call_tool("confirm") + assert result.structured_content == {"result": "confirmed"} + + clients: dict[str, Client] = {} + for label in entered: + peer_channel = await stack.enter_async_context(grpc.aio.insecure_channel(f"127.0.0.1:{port}")) + clients[label] = await stack.enter_async_context(Client(grpc_client(peer_channel), mode="2026-07-28")) + + async def call(label: str, client: Client) -> None: + result = await client.call_tool("overlap", {"label": label}) + assert result.structured_content == {"result": label} + + async with anyio.create_task_group() as tg: + for label, peer in clients.items(): + tg.start_soon(call, label, peer) + + +async def main() -> None: + with anyio.fail_after(5): + await verify() + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/transports/mcp_transport_examples/__init__.py b/examples/transports/mcp_transport_examples/__init__.py new file mode 100644 index 0000000000..a9a2c5b3bb --- /dev/null +++ b/examples/transports/mcp_transport_examples/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/examples/transports/mcp_transport_examples/_grpc_codec.py b/examples/transports/mcp_transport_examples/_grpc_codec.py new file mode 100644 index 0000000000..16209d55d4 --- /dev/null +++ b/examples/transports/mcp_transport_examples/_grpc_codec.py @@ -0,0 +1,46 @@ +"""JSON payloads inside the experimental protobuf binding.""" + +from __future__ import annotations + +import json +import math +from typing import Any, NoReturn, cast + +MAX_PAYLOAD_SIZE = 4 * 1024 * 1024 +RPC_METHOD = "/mcp.transport.example.MCP/Call" + + +def encode_json(value: Any) -> bytes: + payload = json.dumps(value, allow_nan=False, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + if len(payload) > MAX_PAYLOAD_SIZE: + raise ValueError("Payload exceeds the gRPC binding's size limit") + return payload + + +def decode_json(payload: bytes) -> Any: + if len(payload) > MAX_PAYLOAD_SIZE: + raise ValueError("Payload exceeds the gRPC binding's size limit") + + def finite_float(value: str) -> float: + number = float(value) + if not math.isfinite(number): + raise ValueError("Non-finite JSON number") + return number + + try: + return json.loads(payload, parse_constant=reject_constant, parse_float=finite_float) + except RecursionError as exc: + raise ValueError("JSON payload is too deeply nested") from exc + + +def decode_object(payload: bytes, *, nullable: bool = False) -> dict[str, Any] | None: + value = decode_json(payload) + if isinstance(value, dict): + return cast("dict[str, Any]", value) + if nullable and value is None: + return None + raise ValueError("Expected a JSON object") + + +def reject_constant(value: str) -> NoReturn: + raise ValueError(f"Invalid JSON constant: {value}") diff --git a/examples/transports/mcp_transport_examples/grpc.py b/examples/transports/mcp_transport_examples/grpc.py new file mode 100644 index 0000000000..4f353a1af2 --- /dev/null +++ b/examples/transports/mcp_transport_examples/grpc.py @@ -0,0 +1,38 @@ +"""Factories for the experimental native protobuf transport.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import grpc.aio +from mcp.shared.dispatcher import Dispatcher +from mcp.shared.transport import DispatcherTransport, TransportContext + +from mcp_transport_examples.grpc_client import GRPCClientDispatcher +from mcp_transport_examples.grpc_server import GRPCServerDispatcher + + +def grpc_client(channel: grpc.aio.Channel) -> DispatcherTransport: + """Use a borrowed channel with `Client`; the caller owns TLS, credentials, and channel closure.""" + + @asynccontextmanager + async def connection() -> AsyncIterator[Dispatcher[TransportContext]]: + yield GRPCClientDispatcher(channel) + + return DispatcherTransport(connection()) + + +def grpc_server(server: grpc.aio.Server, *, max_requests: int = 64) -> DispatcherTransport: + """Attach one MCP binding to a borrowed server before starting its listener. + + Connect this transport to `ServerRuntime`, then start the gRPC server. + The caller owns the listener and other registered gRPC services. Runtime + shutdown cancels MCP handlers without stopping unrelated services. + """ + + @asynccontextmanager + async def connection() -> AsyncIterator[Dispatcher[TransportContext]]: + yield GRPCServerDispatcher(server, max_requests=max_requests) + + return DispatcherTransport(connection()) diff --git a/examples/transports/mcp_transport_examples/grpc_client.py b/examples/transports/mcp_transport_examples/grpc_client.py new file mode 100644 index 0000000000..1824d2c5a0 --- /dev/null +++ b/examples/transports/mcp_transport_examples/grpc_client.py @@ -0,0 +1,139 @@ +"""A native gRPC dispatcher that reuses the SDK's high-level client.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +import anyio +import anyio.abc +import grpc +import grpc.aio +from mcp.shared.dispatcher import ( + CallOptions, + OnNotify, + OnNotifyIntercept, + OnRequest, + coerce_request_id, +) +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.types import CONNECTION_CLOSED, REQUEST_TIMEOUT, ErrorData, RequestId + +from mcp_transport_examples._grpc_codec import RPC_METHOD, decode_object, encode_json +from mcp_transport_examples.grpc_context import GRPCContext, GRPCDispatchContext +from mcp_transport_examples.grpc_response import PendingCall, receive_response +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest + + +class GRPCClientDispatcher: + """Run MCP calls on a borrowed gRPC channel, with one response stream per request.""" + + def __init__(self, channel: grpc.aio.Channel) -> None: + self._channel = channel + self._rpc = channel.unary_stream( + RPC_METHOD, request_serializer=CallRequest.SerializeToString, response_deserializer=CallEvent.FromString + ) + self._on_notify: OnNotify | None = None + self._intercept: OnNotifyIntercept | None = None + self._calls: dict[RequestId, PendingCall] = {} + self._next_id = 0 + self._closed = False + + 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: + """Enable requests and cancel active RPCs when the client session exits.""" + self._on_notify = on_notify + self._intercept = on_notify_intercept + task_status.started() + try: + state = self._channel.get_state() + while state != grpc.ChannelConnectivity.SHUTDOWN: + await self._channel.wait_for_state_change(state) + state = self._channel.get_state() + finally: + self._closed = True + self._on_notify = None + pending = tuple(self._calls.values()) + for request in pending: + request.scope.cancel() + request.call.cancel() + with anyio.CancelScope(shield=True): + for request in pending: + await request.done.wait() + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + """Send a native RPC and route its notifications before returning the final result. + + Raises: + MCPError: A peer error, request timeout, or closed connection. + """ + if self._closed or self._channel.get_state() == grpc.ChannelConnectivity.SHUTDOWN: + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") + on_notify = self._on_notify + if on_notify is None: + raise RuntimeError("GRPCClientDispatcher.run() has not started") + opts = opts or {} + request_id = opts.get("request_id") + if request_id is None: + while self._next_id in self._calls: + self._next_id += 1 + request_id = self._next_id + self._next_id += 1 + key = coerce_request_id(request_id) + if key in self._calls: + raise ValueError(f"Request id {request_id!r} is already in flight") + request = CallRequest( + method=method, + params_json=encode_json(params), + request_id_json=encode_json(request_id), + report_progress="on_progress" in opts, + ) + call = self._rpc(request, timeout=opts.get("timeout")) + pending = PendingCall(call) + self._calls[key] = pending + complete = False + terminal: CallEvent | None = None + dctx = GRPCDispatchContext(GRPCContext(kind="grpc", can_send_request=False, peer="server"), None, self.notify) + try: + with pending.scope, anyio.fail_after(opts.get("timeout")): + terminal = await receive_response(call, dctx, opts, on_notify, self._intercept) + complete = True + if terminal is None: + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") + if terminal.WhichOneof("payload") == "error_json": + raise MCPError.from_error_data(ErrorData.model_validate(decode_object(terminal.error_json))) + result = decode_object(terminal.result_json) + assert result is not None + return result + except grpc.aio.AioRpcError as exc: + code = REQUEST_TIMEOUT if exc.code() == grpc.StatusCode.DEADLINE_EXCEEDED else CONNECTION_CLOSED + raise MCPError( + code=code, message="gRPC request timed out" if code == REQUEST_TIMEOUT else "gRPC connection failed" + ) from exc + except ValueError as exc: + raise MCPError(code=CONNECTION_CLOSED, message="Invalid gRPC response") from exc + except TimeoutError as exc: + raise MCPError(code=REQUEST_TIMEOUT, message="gRPC request timed out") from exc + except asyncio.CancelledError: + if self._closed or self._channel.get_state() == grpc.ChannelConnectivity.SHUTDOWN: + raise MCPError(code=CONNECTION_CLOSED, message="Connection closed") from None + raise + finally: + self._calls.pop(key) + if not complete: + call.cancel() + pending.done.set() + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """The modern native binding uses structural cancellation, not client notifications.""" + if not self._closed and self._channel.get_state() != grpc.ChannelConnectivity.SHUTDOWN: + raise NoBackChannelError(method) diff --git a/examples/transports/mcp_transport_examples/grpc_context.py b/examples/transports/mcp_transport_examples/grpc_context.py new file mode 100644 index 0000000000..7f93be868a --- /dev/null +++ b/examples/transports/mcp_transport_examples/grpc_context.py @@ -0,0 +1,64 @@ +"""Request-scoped metadata and notifications for the native gRPC binding.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + +import anyio +from mcp.shared.dispatcher import CallOptions +from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.transport import MessageMetadata, TransportContext +from mcp.types import RequestId + + +@dataclass(kw_only=True, frozen=True) +class GRPCContext(TransportContext): + """gRPC peer information, including identities verified by the configured transport. + + Invocation metadata is untrusted. Peer identities are empty on insecure + connections; the application decides which verified identities to authorize. + """ + + peer: str + metadata: tuple[tuple[str, str | bytes], ...] = () + peer_identity_key: str | None = None + peer_identities: tuple[bytes, ...] = () + + +@dataclass +class GRPCDispatchContext: + """Notifications are scoped to one RPC; server-initiated requests are unavailable.""" + + transport: GRPCContext + request_id: RequestId | None + send_notification: Callable[[str, Mapping[str, Any] | None], Awaitable[None]] + report_progress: bool = False + message_metadata: MessageMetadata = None + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + @property + def can_send_request(self) -> bool: + """The modern binding has no server-initiated request channel.""" + return False + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + """Reject requests on this request-scoped channel.""" + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """Deliver a notification on the originating RPC.""" + await self.send_notification(method, params) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + """Send progress only when the caller requested it.""" + if self.report_progress: + params: dict[str, Any] = {"progressToken": self.request_id, "progress": progress} + if total is not None: + params["total"] = total + if message is not None: + params["message"] = message + await self.notify("notifications/progress", params) diff --git a/examples/transports/mcp_transport_examples/grpc_response.py b/examples/transports/mcp_transport_examples/grpc_response.py new file mode 100644 index 0000000000..b143ae7d7b --- /dev/null +++ b/examples/transports/mcp_transport_examples/grpc_response.py @@ -0,0 +1,61 @@ +"""Consume native response streams and deliver request-scoped notifications.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterable +from dataclasses import dataclass, field + +import anyio +import grpc.aio +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, run_notify_intercept +from mcp.types import ProgressNotificationParams + +from mcp_transport_examples._grpc_codec import decode_object +from mcp_transport_examples.grpc_context import GRPCDispatchContext +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PendingCall: + """Track both the native RPC and callbacks executing in its caller's task.""" + + call: grpc.aio.UnaryStreamCall[CallRequest, CallEvent] + scope: anyio.CancelScope = field(default_factory=anyio.CancelScope) + done: anyio.Event = field(default_factory=anyio.Event) + + +async def receive_response( + events: AsyncIterable[CallEvent], + context: GRPCDispatchContext, + opts: CallOptions, + on_notify: OnNotify, + intercept: OnNotifyIntercept | None, +) -> CallEvent: + """Deliver notifications in receive order, then require exactly one terminal event followed by EOF.""" + terminal: CallEvent | None = None + async for event in events: + kind = event.WhichOneof("payload") + if terminal is not None or kind is None: + raise ValueError("Invalid gRPC response sequence") + if kind != "notification": + terminal = event + continue + notification = event.notification + data = decode_object(notification.params_json, nullable=True) + if notification.method == "notifications/progress" and "on_progress" in opts: + progress = ProgressNotificationParams.model_validate(data, by_name=False, strict=True) + try: + await opts["on_progress"](progress.progress, progress.total, progress.message) + except Exception: + logger.exception("Progress callback failed") + if not run_notify_intercept(intercept, notification.method, data): + try: + await on_notify(context, notification.method, data) + except Exception: + logger.exception("Notification handler failed for %r", notification.method) + if terminal is None: + raise ValueError("gRPC call ended without an MCP result") + return terminal diff --git a/examples/transports/mcp_transport_examples/grpc_server.py b/examples/transports/mcp_transport_examples/grpc_server.py new file mode 100644 index 0000000000..239cd63730 --- /dev/null +++ b/examples/transports/mcp_transport_examples/grpc_server.py @@ -0,0 +1,154 @@ +"""Serve native protobuf RPCs through the SDK's dispatcher interface.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any, cast + +import anyio +import anyio.abc +import grpc +import grpc.aio +from mcp.server.runner import modern_error_data +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest, as_request_id +from mcp.shared.exceptions import NoBackChannelError + +from mcp_transport_examples._grpc_codec import decode_json, decode_object, encode_json +from mcp_transport_examples.grpc_context import GRPCContext, GRPCDispatchContext +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest, Notification + + +class GRPCServerDispatcher: + """Attach MCP to a borrowed gRPC server without taking ownership of its listener. + + Register before starting the server. The runtime starts this dispatcher; + you start and stop the gRPC server. Each RPC has independent MCP metadata + and a response stream. Shutdown cancels and joins active request handlers. + """ + + def __init__(self, server: grpc.aio.Server, *, max_requests: int = 64) -> None: + if max_requests < 1: + raise ValueError("max_requests must be positive") + self._limit = anyio.CapacityLimiter(max_requests) + self._handler: OnRequest | None = None + self._requests: dict[anyio.CancelScope, anyio.Event] = {} + self._stopped = anyio.Event() + handler = grpc.unary_stream_rpc_method_handler( + self.handle, request_deserializer=CallRequest.FromString, response_serializer=CallEvent.SerializeToString + ) + server.add_generic_rpc_handlers( + [grpc.method_handlers_generic_handler("mcp.transport.example.MCP", {"Call": handler})] + ) + + 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: + """Install the MCP handler and wait until runtime shutdown.""" + self._handler = on_request + task_status.started() + try: + await self._stopped.wait() + finally: + self._handler = None + self._stopped.set() + requests = tuple(self._requests.items()) + for scope, _ in requests: + scope.cancel() + with anyio.CancelScope(shield=True): + for _, done in requests: + await done.wait() + + async def handle(self, request: CallRequest, context: grpc.aio.ServicerContext[CallRequest, CallEvent]) -> None: + """Handle one gRPC call, with native cancellation and request-scoped notification delivery.""" + handler = self._handler + if handler is None: + await context.abort(grpc.StatusCode.UNAVAILABLE, "MCP dispatcher is not running") + try: + self._limit.acquire_nowait() + except anyio.WouldBlock: + await context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, "MCP request capacity exhausted") + scope = anyio.CancelScope() + done = anyio.Event() + self._requests[scope] = done + lock = anyio.Lock() + notifications_open = True + + async def notify(method: str, params: Mapping[str, Any] | None) -> None: + async with lock: + if not notifications_open: + return + await context.write( + CallEvent(notification=Notification(method=method, params_json=encode_json(params))) + ) + + try: + try: + params = decode_object(request.params_json, nullable=True) + request_id = as_request_id(decode_json(request.request_id_json)) + if request_id is None: + raise ValueError("Invalid request id") + except (ValueError, UnicodeError): + await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "Invalid MCP binding payload") + dctx = GRPCDispatchContext( + transport=GRPCContext( + kind="grpc", + can_send_request=False, + peer=context.peer(), + metadata=cast("tuple[tuple[str, str | bytes], ...]", tuple(context.invocation_metadata() or ())), + peer_identity_key=context.peer_identity_key(), + peer_identities=tuple(context.peer_identities() or ()), + ), + request_id=request_id, + send_notification=notify, + report_progress=request.report_progress, + ) + + response: CallEvent | None = None + ready = anyio.Event() + + async def invoke() -> None: + nonlocal response, notifications_open + try: + result = await handler(dctx, request.method, params) + response = CallEvent(result_json=encode_json(result)) + except Exception as exc: + response = CallEvent(error_json=encode_json(modern_error_data(exc).model_dump(by_alias=True))) + finally: + notifications_open = False + ready.set() + + with scope: + async with anyio.create_task_group() as tg: + tg.start_soon(invoke) + try: + await ready.wait() + except asyncio.CancelledError: + if not scope.cancel_called and not tg.cancel_scope.cancel_called: + dctx.cancel_requested.set() + raise + if response is None: + await context.abort(grpc.StatusCode.CANCELLED, "MCP handler ended without a result") + async with lock: + await context.write(response) + if scope.cancelled_caught: + await context.abort(grpc.StatusCode.UNAVAILABLE, "MCP dispatcher closed") + finally: + self._requests.pop(scope) + done.set() + self._limit.release() + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + """Reject server-initiated requests in the modern binding.""" + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + """Reject notifications without an originating RPC; use its DispatchContext instead.""" + raise NoBackChannelError(method) diff --git a/examples/transports/mcp_transport_examples/py.typed b/examples/transports/mcp_transport_examples/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/transports/mcp_transport_examples/rpc.proto b/examples/transports/mcp_transport_examples/rpc.proto new file mode 100644 index 0000000000..9f660545f0 --- /dev/null +++ b/examples/transports/mcp_transport_examples/rpc.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package mcp.transport.example; + +service MCP { + rpc Call(CallRequest) returns (stream CallEvent); +} + +message CallRequest { + string method = 1; + bytes params_json = 2; + bytes request_id_json = 3; + bool report_progress = 4; +} + +message CallEvent { + oneof payload { + bytes result_json = 1; + bytes error_json = 2; + Notification notification = 3; + } +} + +message Notification { + string method = 1; + bytes params_json = 2; +} diff --git a/examples/transports/mcp_transport_examples/rpc_pb2.py b/examples/transports/mcp_transport_examples/rpc_pb2.py new file mode 100644 index 0000000000..c2d2b563a5 --- /dev/null +++ b/examples/transports/mcp_transport_examples/rpc_pb2.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: mcp_transport_examples/rpc.proto +# Protobuf Python Version: 6.33.5 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 5, + '', + 'mcp_transport_examples/rpc.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n mcp_transport_examples/rpc.proto\x12\x15mcp.transport.example\"d\n\x0b\x43\x61llRequest\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\x13\n\x0bparams_json\x18\x02 \x01(\x0c\x12\x17\n\x0frequest_id_json\x18\x03 \x01(\x0c\x12\x17\n\x0freport_progress\x18\x04 \x01(\x08\"\x80\x01\n\tCallEvent\x12\x15\n\x0bresult_json\x18\x01 \x01(\x0cH\x00\x12\x14\n\nerror_json\x18\x02 \x01(\x0cH\x00\x12;\n\x0cnotification\x18\x03 \x01(\x0b\x32#.mcp.transport.example.NotificationH\x00\x42\t\n\x07payload\"3\n\x0cNotification\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\x13\n\x0bparams_json\x18\x02 \x01(\x0c\x32U\n\x03MCP\x12N\n\x04\x43\x61ll\x12\".mcp.transport.example.CallRequest\x1a .mcp.transport.example.CallEvent0\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'mcp_transport_examples.rpc_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CALLREQUEST']._serialized_start=59 + _globals['_CALLREQUEST']._serialized_end=159 + _globals['_CALLEVENT']._serialized_start=162 + _globals['_CALLEVENT']._serialized_end=290 + _globals['_NOTIFICATION']._serialized_start=292 + _globals['_NOTIFICATION']._serialized_end=343 + _globals['_MCP']._serialized_start=345 + _globals['_MCP']._serialized_end=430 +# @@protoc_insertion_point(module_scope) diff --git a/examples/transports/mcp_transport_examples/rpc_pb2.pyi b/examples/transports/mcp_transport_examples/rpc_pb2.pyi new file mode 100644 index 0000000000..cc41d17add --- /dev/null +++ b/examples/transports/mcp_transport_examples/rpc_pb2.pyi @@ -0,0 +1,36 @@ +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class CallRequest(_message.Message): + __slots__ = ("method", "params_json", "request_id_json", "report_progress") + METHOD_FIELD_NUMBER: _ClassVar[int] + PARAMS_JSON_FIELD_NUMBER: _ClassVar[int] + REQUEST_ID_JSON_FIELD_NUMBER: _ClassVar[int] + REPORT_PROGRESS_FIELD_NUMBER: _ClassVar[int] + method: str + params_json: bytes + request_id_json: bytes + report_progress: bool + def __init__(self, method: _Optional[str] = ..., params_json: _Optional[bytes] = ..., request_id_json: _Optional[bytes] = ..., report_progress: _Optional[bool] = ...) -> None: ... + +class CallEvent(_message.Message): + __slots__ = ("result_json", "error_json", "notification") + RESULT_JSON_FIELD_NUMBER: _ClassVar[int] + ERROR_JSON_FIELD_NUMBER: _ClassVar[int] + NOTIFICATION_FIELD_NUMBER: _ClassVar[int] + result_json: bytes + error_json: bytes + notification: Notification + def __init__(self, result_json: _Optional[bytes] = ..., error_json: _Optional[bytes] = ..., notification: _Optional[_Union[Notification, _Mapping]] = ...) -> None: ... + +class Notification(_message.Message): + __slots__ = ("method", "params_json") + METHOD_FIELD_NUMBER: _ClassVar[int] + PARAMS_JSON_FIELD_NUMBER: _ClassVar[int] + method: str + params_json: bytes + def __init__(self, method: _Optional[str] = ..., params_json: _Optional[bytes] = ...) -> None: ... diff --git a/examples/transports/pyproject.toml b/examples/transports/pyproject.toml new file mode 100644 index 0000000000..88250a5199 --- /dev/null +++ b/examples/transports/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "mcp-transport-examples" +version = "0.1.0" +description = "Reference native gRPC adapter for the MCP transport API" +requires-python = ">=3.10" +dependencies = [ + "grpcio>=1.71", + "mcp", + "protobuf>=6.33.5", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_transport_examples"] + +[dependency-groups] +dev = [ + "pytest>=8.4.0", + "coverage[toml]>=7.10.7", + "pyright>=1.1.400", + "ruff>=0.8.5", + "grpcio-tools==1.81.1", + "types-protobuf>=7.35.1.20260906", + "cassetter[grpc]>=0.11.0", + "cryptography>=50.0.0", +] + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers" +testpaths = ["tests"] +filterwarnings = ["error"] +xfail_strict = true + +[tool.coverage.run] +branch = true +source_pkgs = ["mcp_transport_examples", "tests"] +# Protoc output is verified by regenerating it, not by testing protobuf internals. +omit = ["*/rpc_pb2.py"] + +[tool.coverage.report] +fail_under = 100 +show_missing = true +exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError", "@overload"] + +[tool.pyright] +typeCheckingMode = "strict" +include = ["mcp_transport_examples", "tests", "*.py"] +# Protoc emits unparameterized Mapping annotations in its generated stubs. +ignore = ["mcp_transport_examples/rpc_pb2.py", "mcp_transport_examples/rpc_pb2.pyi"] +venvPath = "." +venv = ".venv" +reportUnusedFunction = false + +[tool.ruff] +line-length = 120 +target-version = "py310" +extend-exclude = ["rpc_pb2.py", "rpc_pb2.pyi"] + +[tool.ruff.lint] +select = ["E", "F", "I", "FA", "UP", "RUF100"] + +[tool.ruff.lint.isort] +combine-as-imports = true diff --git a/examples/transports/reproduce_grpc_loop_shutdown.py b/examples/transports/reproduce_grpc_loop_shutdown.py new file mode 100644 index 0000000000..2307e97e18 --- /dev/null +++ b/examples/transports/reproduce_grpc_loop_shutdown.py @@ -0,0 +1,37 @@ +"""Reproduce late gRPC connectivity completions without importing the MCP SDK.""" + +import asyncio +import sys + +import anyio +import anyio.abc +import grpc +import grpc.aio + + +def main() -> None: + """Exit unsuccessfully when a native completion targets an earlier, closed loop.""" + channels: list[grpc.aio.Channel] = [] + errors: list[dict[str, object]] = [] + + async def run() -> None: + asyncio.get_running_loop().set_exception_handler(lambda loop, context: errors.append(context)) + channel = grpc.aio.insecure_channel("127.0.0.1:1") + channels.append(channel) + + async def watch(*, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None: + task_status.started() + await channel.wait_for_state_change(channel.get_state()) + + async with anyio.create_task_group() as tg: + await tg.start(watch) + tg.cancel_scope.cancel() + await channel.close() + + for _ in range(10): + anyio.run(run) + assert not errors, (grpc.__version__, sys.version, errors) + + +if __name__ == "__main__": + main() diff --git a/examples/transports/tests/__init__.py b/examples/transports/tests/__init__.py new file mode 100644 index 0000000000..a9a2c5b3bb --- /dev/null +++ b/examples/transports/tests/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/examples/transports/tests/cassettes/test_grpc/test_native_error_keeps_code_message_and_data.yaml b/examples/transports/tests/cassettes/test_grpc/test_native_error_keeps_code_message_and_data.yaml new file mode 100644 index 0000000000..11fd028a43 --- /dev/null +++ b/examples/transports/tests/cassettes/test_grpc/test_native_error_keeps_code_message_and_data.yaml @@ -0,0 +1,19 @@ +version: 1 +interactions: [] +grpc_interactions: + - request: + method: /mcp.transport.example.MCP/Call + metadata: {} + body: + type: binary + content: >- + 0a0e6578616d706c652f72656675736512b8017b225f6d657461223a7b22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f70726f746f636f6c56657273696f6e223a22323032362d30372d3238222c22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f636c69656e74496e666f223a7b226e616d65223a226d6370222c2276657273696f6e223a22302e312e30227d2c22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f636c69656e744361706162696c6974696573223a7b7d7d7d1a0130 + response: + status_code: 0 + status_message: OK + metadata: {} + body: + type: binary + content: >- + 0000007912777b22636f6465223a2d313039393531313632373737362c226d657373616765223a226170706c69636174696f6e207265667573616c222c2264617461223a7b2276656e646f722f726561736f6e223a226361706163697479222c226c61726765223a393232333337323033363835343737353830397d7d + recorded_at: 2026-09-16T16:13:04.953959+00:00 diff --git a/examples/transports/tests/cassettes/test_grpc/test_native_payload_keeps_large_integers_and_extension_fields.yaml b/examples/transports/tests/cassettes/test_grpc/test_native_payload_keeps_large_integers_and_extension_fields.yaml new file mode 100644 index 0000000000..ef9d397814 --- /dev/null +++ b/examples/transports/tests/cassettes/test_grpc/test_native_payload_keeps_large_integers_and_extension_fields.yaml @@ -0,0 +1,19 @@ +version: 1 +interactions: [] +grpc_interactions: + - request: + method: /mcp.transport.example.MCP/Call + metadata: {} + body: + type: binary + content: >- + 0a0c6578616d706c652f6563686f128a027b2276616c7565223a7b226c61726765223a393232333337323033363835343737353830392c2276656e646f722f6669656c64223a5b6e756c6c2c7b226c6162656c223a226361665c7530306539227d5d7d2c225f6d657461223a7b22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f70726f746f636f6c56657273696f6e223a22323032362d30372d3238222c22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f636c69656e74496e666f223a7b226e616d65223a226d6370222c2276657273696f6e223a22302e312e30227d2c22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f636c69656e744361706162696c6974696573223a7b7d7d7d1a0130 + response: + status_code: 0 + status_message: OK + metadata: {} + body: + type: binary + content: >- + 000000bc0ab9017b2276616c7565223a7b226c61726765223a393232333337323033363835343737353830392c2276656e646f722f6669656c64223a5b6e756c6c2c7b226c6162656c223a226361665c7530306539227d5d7d2c22726573756c7454797065223a22636f6d706c657465222c225f6d657461223a7b22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f736572766572496e666f223a7b226e616d65223a226e6174697665222c2276657273696f6e223a22227d7d7d + recorded_at: 2026-09-16T16:13:04.939254+00:00 diff --git a/examples/transports/tests/cassettes/test_grpc/test_native_progress_reaches_the_client_before_the_result.yaml b/examples/transports/tests/cassettes/test_grpc/test_native_progress_reaches_the_client_before_the_result.yaml new file mode 100644 index 0000000000..2b57775b97 --- /dev/null +++ b/examples/transports/tests/cassettes/test_grpc/test_native_progress_reaches_the_client_before_the_result.yaml @@ -0,0 +1,19 @@ +version: 1 +interactions: [] +grpc_interactions: + - request: + method: /mcp.transport.example.MCP/Call + metadata: {} + body: + type: binary + content: >- + 0a0a746f6f6c732f63616c6c12ee017b226e616d65223a226563686f222c22617267756d656e7473223a7b2276616c7565223a226e61746976652070726f6772657373227d2c225f6d657461223a7b22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f70726f746f636f6c56657273696f6e223a22323032362d30372d3238222c22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f636c69656e74496e666f223a7b226e616d65223a226d6370222c2276657273696f6e223a22302e312e30227d2c22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f636c69656e744361706162696c6974696573223a7b7d7d7d1a01302001 + response: + status_code: 0 + status_message: OK + metadata: {} + body: + type: binary + content: >- + 0000005a1a580a166e6f74696669636174696f6e732f70726f6772657373123e7b2270726f6772657373546f6b656e223a302c2270726f6772657373223a312c22746f74616c223a322c226d657373616765223a2268616c66776179227d000000e90ae6017b22636f6e74656e74223a5b7b2274657874223a226e61746976652070726f6772657373222c2274797065223a2274657874227d5d2c2269734572726f72223a66616c73652c22726573756c7454797065223a22636f6d706c657465222c2273747275637475726564436f6e74656e74223a7b22726573756c74223a226e61746976652070726f6772657373227d2c225f6d657461223a7b22696f2e6d6f64656c636f6e7465787470726f746f636f6c2f736572766572496e666f223a7b226e616d65223a226e61746976652d70726f6772657373222c2276657273696f6e223a22227d7d7d + recorded_at: 2026-09-16T16:13:04.948671+00:00 diff --git a/examples/transports/tests/conftest.py b/examples/transports/tests/conftest.py new file mode 100644 index 0000000000..c4f226d6db --- /dev/null +++ b/examples/transports/tests/conftest.py @@ -0,0 +1,14 @@ +from collections.abc import AsyncIterator + +import pytest + + +@pytest.fixture(scope="session") +def anyio_backend() -> str: + return "asyncio" + + +@pytest.fixture(scope="session", autouse=True) +async def grpc_event_loop(anyio_backend: str) -> AsyncIterator[None]: + """Keep gRPC's process-wide completion queue on one loop, including late connectivity callbacks.""" + yield diff --git a/examples/transports/tests/test_grpc.py b/examples/transports/tests/test_grpc.py new file mode 100644 index 0000000000..972f01b22c --- /dev/null +++ b/examples/transports/tests/test_grpc.py @@ -0,0 +1,185 @@ +import json +from collections.abc import AsyncIterator, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Any + +import anyio +import grpc.aio +import pytest +from cassetter import Cassette, Cassetter +from mcp import Client, MCPError +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import ( + CONNECTION_CLOSED, + CallToolRequest, + CallToolRequestParams, + CallToolResult, + Request, + RequestParams, + Result, +) + +from mcp_transport_examples.grpc import grpc_client, grpc_server +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest + + +@pytest.fixture(scope="module") +def vcr_config() -> Cassetter: + return Cassetter(intercept=["grpc"]) + + +@asynccontextmanager +async def connected( + server: Server[Any] | MCPServer[Any], cassette: Cassette, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Client]: + async with AsyncExitStack() as stack: + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + stack.push_async_callback(listener.stop, 0) + runtime = await stack.enter_async_context(server.serve()) + await runtime.connect(grpc_server(listener)) + await listener.start() + channel = await stack.enter_async_context(grpc.aio.insecure_channel(f"127.0.0.1:{port}")) + requests: list[CallRequest] = [] + unary_stream = channel.unary_stream + + def capture( + method: str, + request_serializer: Callable[[CallRequest], bytes] | None = None, + response_deserializer: Callable[[bytes], CallEvent] | None = None, + ) -> grpc.aio.UnaryStreamMultiCallable[CallRequest, CallEvent]: + assert request_serializer is not None + + def serialize(request: CallRequest) -> bytes: + payload = request_serializer(request) + requests.append(CallRequest.FromString(payload)) + return payload + + return unary_stream(method, request_serializer=serialize, response_deserializer=response_deserializer) + + monkeypatch.setattr(channel, "unary_stream", capture) + client = await stack.enter_async_context(Client(grpc_client(channel), mode="2026-07-28")) + yield client + assert requests + assert len(cassette.grpc_interactions) == 1 + payload = cassette.grpc_interactions[0].request.body.content + assert isinstance(payload, bytes) + recorded = CallRequest.FromString(payload) + for request in requests: + # cassetter currently matches gRPC methods, not request bodies. + assert request.method == recorded.method + assert json.loads(request.params_json) == json.loads(recorded.params_json) + assert request.request_id_json == recorded.request_id_json + assert request.report_progress == recorded.report_progress + + +@pytest.mark.anyio +@pytest.mark.vcr +async def test_native_payload_keeps_large_integers_and_extension_fields( + cassette: Cassette, monkeypatch: pytest.MonkeyPatch +) -> None: + """A recorded real RPC preserves arbitrary MCP payload fields without protobuf Struct's float conversion.""" + + class EchoParams(RequestParams): + value: dict[str, Any] + + class EchoResult(Result): + value: dict[str, Any] + + async def echo(ctx: ServerRequestContext, params: EchoParams) -> EchoResult: + assert ctx.method == "example/echo" + return EchoResult(value=params.value) + + server = Server("native") + server.add_request_handler("example/echo", EchoParams, echo) + payload = {"large": 2**63 + 1, "vendor/field": [None, {"label": "café"}]} + with anyio.fail_after(5): + async with connected(server, cassette, monkeypatch) as client: + result = await client.session.send_request( + Request(method="example/echo", params=EchoParams(value=payload)), EchoResult + ) + assert result.value == payload + async with Client(server, mode="2026-07-28") as local: + expected = await local.session.send_request( + Request(method="example/echo", params=EchoParams(value=payload)), EchoResult + ) + assert result == expected + + +@pytest.mark.anyio +@pytest.mark.vcr +async def test_native_progress_reaches_the_client_before_the_result( + cassette: Cassette, monkeypatch: pytest.MonkeyPatch +) -> None: + """A recorded response stream routes progress through the SDK callback, isolated from tools/list schema fetching.""" + server = MCPServer("native-progress") + + @server.tool() + async def echo(value: str, ctx: Context) -> str: + await ctx.report_progress(1, 2, "halfway") + return value + + updates: list[tuple[float, float | None, str | None]] = [] + + async def progress(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + value = "native progress" + with anyio.fail_after(5): + async with connected(server, cassette, monkeypatch) as client: + result = await client.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="echo", arguments={"value": value})), + CallToolResult, + progress_callback=progress, + ) + assert result.structured_content == {"result": value} + wire_updates = updates.copy() + updates.clear() + async with Client(server, mode="2026-07-28") as local: + expected = await local.session.send_request( + CallToolRequest(params=CallToolRequestParams(name="echo", arguments={"value": value})), + CallToolResult, + progress_callback=progress, + ) + assert result == expected + assert updates == wire_updates == [(1, 2, "halfway")] + + +@pytest.mark.anyio +async def test_request_immediately_after_channel_close_reports_mcp_connection_closed() -> None: + """An idle borrowed channel closes without a network request or a scheduling opportunity for its watcher.""" + with anyio.fail_after(5): + async with grpc.aio.insecure_channel("unused.invalid:50051") as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + await channel.close() + with pytest.raises(MCPError) as exc: + await client.list_tools() + assert exc.value.code == CONNECTION_CLOSED + + +@pytest.mark.anyio +@pytest.mark.vcr +async def test_native_error_keeps_code_message_and_data(cassette: Cassette, monkeypatch: pytest.MonkeyPatch) -> None: + """Native application errors retain all MCP fields, including codes outside signed int32.""" + code = -(2**40) + message = "application refusal" + data = {"vendor/reason": "capacity", "large": 2**63 + 1} + + async def refuse(ctx: ServerRequestContext, params: RequestParams) -> Result: + assert ctx.method == "example/refuse" + raise MCPError(code=code, message=message, data=data) + + server = Server("native-errors") + server.add_request_handler("example/refuse", RequestParams, refuse) + with anyio.fail_after(5): + async with connected(server, cassette, monkeypatch) as client: + with pytest.raises(MCPError) as exc: + await client.session.send_request(Request(method="example/refuse", params=RequestParams()), Result) + assert exc.value.code == code + assert exc.value.message == message + assert exc.value.data == data + async with Client(server, mode="2026-07-28") as local: + with pytest.raises(MCPError) as expected: + await local.session.send_request(Request(method="example/refuse", params=RequestParams()), Result) + assert exc.value.error == expected.value.error diff --git a/examples/transports/tests/test_grpc_cancel_signal.py b/examples/transports/tests/test_grpc_cancel_signal.py new file mode 100644 index 0000000000..2bc9e9e6e9 --- /dev/null +++ b/examples/transports/tests/test_grpc_cancel_signal.py @@ -0,0 +1,72 @@ +"""Peer cancellation must be visible before the handler's cleanup runs.""" + +from collections.abc import Mapping +from typing import Any + +import anyio +import anyio.abc +import grpc.aio +import pytest +from mcp import Client +from mcp.shared.dispatcher import DispatchContext +from mcp.shared.transport import TransportContext +from mcp.types import Request, RequestParams, Result + +from mcp_transport_examples.grpc import grpc_client +from mcp_transport_examples.grpc_server import GRPCServerDispatcher + + +async def verify() -> None: + entered = anyio.Event() + done = anyio.Event() + observed: list[bool] = [] + + async def handle( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + assert method == "example/wait" + entered.set() + try: + await anyio.sleep_forever() + finally: + observed.append(ctx.cancel_requested.is_set()) + done.set() + raise NotImplementedError + + async def notify(ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: + raise NotImplementedError + + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + dispatcher = GRPCServerDispatcher(listener) + try: + async with anyio.create_task_group() as tg: + await tg.start(dispatcher.run, handle, notify) + await listener.start() + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + + async def call(*, task_status: anyio.abc.TaskStatus[anyio.CancelScope]) -> None: + with anyio.CancelScope() as scope: + task_status.started(scope) + await client.session.send_request( + Request(method="example/wait", params=RequestParams()), Result + ) + + async with anyio.create_task_group() as calls: + scope = await calls.start(call) + await entered.wait() + scope.cancel() + await done.wait() + assert observed == [True] + tg.cancel_scope.cancel() + finally: + with anyio.move_on_after(5, shield=True): + await listener.stop(0) + + +@pytest.mark.anyio +async def test_peer_cancellation_is_signalled_before_handler_cleanup() -> None: + """Read cancel_requested during the live handler's cleanup, not after RPC completion.""" + with anyio.fail_after(5): + await verify() diff --git a/examples/transports/tests/test_grpc_client.py b/examples/transports/tests/test_grpc_client.py new file mode 100644 index 0000000000..74dffd6f65 --- /dev/null +++ b/examples/transports/tests/test_grpc_client.py @@ -0,0 +1,107 @@ +from contextlib import AsyncExitStack +from typing import Any + +import anyio +import grpc.aio +import pytest +from mcp.client import ClientSession +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.types import CLIENT_CAPABILITIES_META_KEY, CONNECTION_CLOSED, PROTOCOL_VERSION_META_KEY, CallToolRequestParams + +from mcp_transport_examples.grpc import grpc_client, grpc_server + + +@pytest.mark.anyio +@pytest.mark.parametrize("closed_channel", [False, True], ids=["session-exit", "closed-channel-startup"]) +async def test_unstarted_and_closed_dispatchers_never_issue_an_rpc(closed_channel: bool) -> None: + """Public dispatcher guards reject requests before startup and drop notifications after closure without dialing.""" + with anyio.fail_after(5): + async with ( + grpc.aio.insecure_channel("unused.invalid:50051") as channel, + grpc_client(channel).connection as dispatcher, + ): + with pytest.raises(RuntimeError): + await dispatcher.send_raw_request("example/test", None) + with pytest.raises(NoBackChannelError): + await dispatcher.notify("example/event", None) + if closed_channel: + await channel.close() + await dispatcher.notify("example/event", None) + async with ClientSession(dispatcher=dispatcher): + pass + await dispatcher.notify("example/event", None) + with pytest.raises(MCPError) as exc: + await dispatcher.send_raw_request("example/test", None) + assert exc.value.code == CONNECTION_CLOSED + + +@pytest.mark.anyio +@pytest.mark.parametrize("value", ["a" * (4 * 1024 * 1024), float("nan")], ids=["oversized", "nonfinite"]) +async def test_invalid_outgoing_payload_fails_before_dialing(value: str | float) -> None: + """The raw dispatcher rejects invalid JSON; the typed client normalizes nonfinite values before this boundary.""" + with anyio.fail_after(5): + async with ( + grpc.aio.insecure_channel("unused.invalid:50051") as channel, + grpc_client(channel).connection as dispatcher, + ClientSession(dispatcher=dispatcher), + ): + with pytest.raises(ValueError): + await dispatcher.send_raw_request("example/test", {"value": value}) + assert channel.get_state() == grpc.ChannelConnectivity.IDLE + + +@pytest.mark.anyio +async def test_request_ids_preserve_spelling_and_reject_in_flight_collisions() -> None: + """Exercise the public dispatcher option that the high-level client normally supplies for subscriptions.""" + entered = anyio.Event() + release = anyio.Event() + server = MCPServer("request IDs") + + @server.tool() + async def identify(hold: bool, ctx: Context) -> str | int | None: + if hold: + entered.set() + await release.wait() + return ctx.request_context.request_id + + waiting = CallToolRequestParams( + name="identify", + arguments={"hold": True}, + _meta={PROTOCOL_VERSION_META_KEY: "2026-07-28", CLIENT_CAPABILITIES_META_KEY: {}}, + ).model_dump(by_alias=True, exclude_none=True) + immediate = CallToolRequestParams( + name="identify", + arguments={"hold": False}, + _meta={PROTOCOL_VERSION_META_KEY: "2026-07-28", CLIENT_CAPABILITIES_META_KEY: {}}, + ).model_dump(by_alias=True, exclude_none=True) + results: list[dict[str, Any]] = [] + + with anyio.fail_after(5): + async with AsyncExitStack() as stack: + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + stack.push_async_callback(listener.stop, 0) + runtime = await stack.enter_async_context(server.serve()) + await runtime.connect(grpc_server(listener)) + await listener.start() + channel = await stack.enter_async_context(grpc.aio.insecure_channel(f"127.0.0.1:{port}")) + dispatcher = await stack.enter_async_context(grpc_client(channel).connection) + await stack.enter_async_context(ClientSession(dispatcher=dispatcher)) + + async def first() -> None: + results.append(await dispatcher.send_raw_request("tools/call", waiting, {"request_id": 0})) + + async with anyio.create_task_group() as tg: + tg.start_soon(first) + try: + await entered.wait() + with pytest.raises(ValueError): + await dispatcher.send_raw_request("tools/call", immediate, {"request_id": "0"}) + minted = await dispatcher.send_raw_request("tools/call", immediate) + assert minted["structuredContent"] == {"result": 1} + finally: + release.set() + assert results[0]["structuredContent"] == {"result": 0} + reused = await dispatcher.send_raw_request("tools/call", immediate, {"request_id": "0"}) + assert reused["structuredContent"] == {"result": "0"} diff --git a/examples/transports/tests/test_grpc_client_shutdown.py b/examples/transports/tests/test_grpc_client_shutdown.py new file mode 100644 index 0000000000..bf941c0540 --- /dev/null +++ b/examples/transports/tests/test_grpc_client_shutdown.py @@ -0,0 +1,94 @@ +"""Client shutdown must interrupt callbacks as well as gRPC socket reads.""" + +import anyio +import anyio.abc +import grpc.aio +import pytest +from mcp import Client, MCPError +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import CONNECTION_CLOSED + +from mcp_transport_examples.grpc import grpc_client, grpc_server + + +async def verify(*, shield_cleanup: bool = False) -> None: + cleanup_started = anyio.Event() + release_cleanup = anyio.Event() + callback_entered = anyio.Event() + callback_cancelled = anyio.Event() + close_client = anyio.Event() + client_closed = anyio.Event() + call_finished = anyio.Event() + server_cancelled = anyio.Event() + server = MCPServer("client shutdown") + + @server.tool() + async def wait(ctx: Context) -> str: + try: + await ctx.report_progress(1, 2) + await anyio.sleep_forever() + finally: + server_cancelled.set() + raise NotImplementedError + + async def progress(progress: float, total: float | None, message: str | None) -> None: + callback_entered.set() + try: + await anyio.sleep_forever() + finally: + if shield_cleanup: + with anyio.CancelScope(shield=True): + cleanup_started.set() + await release_cleanup.wait() + callback_cancelled.set() + + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + try: + async with server.serve() as runtime: + await runtime.connect(grpc_server(listener)) + await listener.start() + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: + + async def own_client(*, task_status: anyio.abc.TaskStatus[Client]) -> None: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + task_status.started(client) + await close_client.wait() + client_closed.set() + + async def call(client: Client) -> None: + with pytest.raises(MCPError) as exc: + await client.call_tool("wait", progress_callback=progress) + assert exc.value.code == CONNECTION_CLOSED + call_finished.set() + + async with anyio.create_task_group() as tg: + client = await tg.start(own_client) + tg.start_soon(call, client) + try: + await callback_entered.wait() + close_client.set() + if shield_cleanup: + await cleanup_started.wait() + # Shutdown must wait for this callback, not abandon it after five seconds. + with anyio.move_on_after(5.1) as window: + await client_closed.wait() + assert window.cancelled_caught + finally: + release_cleanup.set() + await client_closed.wait() + await call_finished.wait() + await server_cancelled.wait() + assert callback_cancelled.is_set() + finally: + with anyio.move_on_after(5, shield=True): + await listener.stop(0) + + +@pytest.mark.anyio +@pytest.mark.parametrize("shield_cleanup", [False, True]) +async def test_client_shutdown_joins_blocked_callbacks(shield_cleanup: bool) -> None: + """The client must wait for callback cleanup before relinquishing its session resources.""" + # The shielded case deliberately exceeds the former five-second join deadline. + with anyio.fail_after(10): + await verify(shield_cleanup=shield_cleanup) diff --git a/examples/transports/tests/test_grpc_context.py b/examples/transports/tests/test_grpc_context.py new file mode 100644 index 0000000000..9fba9c4dc4 --- /dev/null +++ b/examples/transports/tests/test_grpc_context.py @@ -0,0 +1,51 @@ +from collections.abc import Mapping +from typing import Any + +import anyio +import grpc.aio +import pytest +from mcp.shared.exceptions import NoBackChannelError + +from mcp_transport_examples.grpc import grpc_server +from mcp_transport_examples.grpc_context import GRPCContext, GRPCDispatchContext + + +@pytest.mark.anyio +async def test_modern_binding_refuses_server_requests_and_unscoped_notifications() -> None: + """The public dispatcher and context refuse channels that the native modern binding does not provide.""" + listener = grpc.aio.server() + + async def notify(method: str, params: Mapping[str, Any] | None) -> None: + raise NotImplementedError + + context = GRPCDispatchContext(GRPCContext(kind="grpc", can_send_request=False, peer="test"), 1, notify) + with anyio.fail_after(5): + with pytest.raises(NoBackChannelError): + await context.send_raw_request("example/request", None) + async with grpc_server(listener).connection as dispatcher: + with pytest.raises(NoBackChannelError): + await dispatcher.send_raw_request("example/request", None) + with pytest.raises(NoBackChannelError): + await dispatcher.notify("example/event", None) + assert not context.can_send_request + + +@pytest.mark.anyio +@pytest.mark.parametrize("report_progress", [False, True]) +async def test_context_progress_is_opt_in_and_omits_absent_fields(report_progress: bool) -> None: + """Progress without an opt-in is a no-op; supplied values are forwarded without inventing total or message.""" + notifications: list[tuple[str, Mapping[str, Any] | None]] = [] + + async def notify(method: str, params: Mapping[str, Any] | None) -> None: + notifications.append((method, params)) + + context = GRPCDispatchContext( + GRPCContext(kind="grpc", can_send_request=False, peer="test"), + "request", + notify, + report_progress=report_progress, + ) + await context.progress(1) + assert notifications == ( + [("notifications/progress", {"progressToken": "request", "progress": 1})] if report_progress else [] + ) diff --git a/examples/transports/tests/test_grpc_dispatcher.py b/examples/transports/tests/test_grpc_dispatcher.py new file mode 100644 index 0000000000..4e82b47679 --- /dev/null +++ b/examples/transports/tests/test_grpc_dispatcher.py @@ -0,0 +1,167 @@ +from collections.abc import AsyncIterator, Mapping +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Any + +import anyio +import grpc.aio +import pytest +from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest +from mcp.shared.exceptions import MCPError +from mcp.shared.transport import TransportContext +from mcp.types import CONNECTION_CLOSED, INTERNAL_ERROR + +from mcp_transport_examples.grpc import grpc_client, grpc_server + + +@asynccontextmanager +async def connected(on_request: OnRequest, on_notify: OnNotify) -> AsyncIterator[Dispatcher[TransportContext]]: + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + try: + async with AsyncExitStack() as stack: + server = await stack.enter_async_context(grpc_server(listener).connection) + channel = await stack.enter_async_context(grpc.aio.insecure_channel(f"127.0.0.1:{port}")) + client = await stack.enter_async_context(grpc_client(channel).connection) + async with anyio.create_task_group() as tg: + await tg.start(server.run, on_request, on_notify) + await tg.start(client.run, on_request, on_notify) + await listener.start() + try: + yield client + finally: + tg.cancel_scope.cancel() + finally: + with anyio.fail_after(5, shield=True): + await listener.stop(0) + + +@pytest.mark.anyio +async def test_dispatcher_contains_notification_handler_errors(caplog: pytest.LogCaptureFixture) -> None: + """The raw dispatcher boundary must contain callbacks even without ClientSession's own containment.""" + + async def handler( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> dict[str, Any]: + assert method == "example/callback" + await ctx.notify("example/event", None) + return {"completed": True} + + async def notification( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> None: + assert method == "example/event" + raise RuntimeError("notification failed") + + with anyio.fail_after(5): + async with connected(handler, notification) as client: + result = await client.send_raw_request("example/callback", None) + assert result == {"completed": True} + records = [record for record in caplog.records if record.name == "mcp_transport_examples.grpc_response"] + assert len(records) == 1 + assert records[0].exc_info is not None + + +@pytest.mark.anyio +async def test_dispatcher_sanitizes_unmapped_request_errors() -> None: + """Test the raw OnRequest boundary because ServerRuntime already maps ordinary handler failures itself.""" + secret = "private handler details" + + async def handler( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> dict[str, Any]: + assert method == "example/failure" + raise RuntimeError(secret) + + async def notification( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> None: + raise NotImplementedError + + with anyio.fail_after(5): + async with connected(handler, notification) as client: + with pytest.raises(MCPError) as exc: + await client.send_raw_request("example/failure", None) + assert exc.value.code == INTERNAL_ERROR + assert secret not in exc.value.message + assert exc.value.data is None + + +@pytest.mark.anyio +async def test_request_context_drops_notifications_after_handler_return() -> None: + """A retained public DispatchContext must not write beyond its RPC's terminal event.""" + contexts: list[DispatchContext[TransportContext]] = [] + notifications: list[str] = [] + + async def handler( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> dict[str, Any]: + assert method == "example/context" + contexts.append(ctx) + await ctx.notify("example/before", None) + return {"completed": True} + + async def notification( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> None: + notifications.append(method) + + with anyio.fail_after(5): + async with connected(handler, notification) as client: + result = await client.send_raw_request("example/context", None) + await contexts[0].notify("example/after", None) + assert result == {"completed": True} + assert notifications == ["example/before"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("field", ["progressToken", "progress", None]) +async def test_progress_validation_precedes_callbacks(field: str | None) -> None: + """A raw peer can send invalid booleans; valid progress reaches its callback and the notification observer once.""" + updates: list[float] = [] + observed: list[str] = [] + + async def handler( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> dict[str, Any]: + assert method == "example/progress" + data: dict[str, Any] = {"progressToken": "token", "progress": 1} + if field is not None: + data[field] = True + await ctx.notify("notifications/progress", data) + return {} + + async def notification( + ctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, + ) -> None: + observed.append(method) + + async def progress(progress: float, total: float | None, message: str | None) -> None: + updates.append(progress) + + with anyio.fail_after(5): + async with connected(handler, notification) as client: + if field is None: + result = await client.send_raw_request("example/progress", None, {"on_progress": progress}) + assert result == {} + else: + with pytest.raises(MCPError) as exc: + await client.send_raw_request("example/progress", None, {"on_progress": progress}) + assert exc.value.code == CONNECTION_CLOSED + assert updates == ([1] if field is None else []) + assert observed == (["notifications/progress"] if field is None else []) diff --git a/examples/transports/tests/test_grpc_lifecycle.py b/examples/transports/tests/test_grpc_lifecycle.py new file mode 100644 index 0000000000..e9193a2fd6 --- /dev/null +++ b/examples/transports/tests/test_grpc_lifecycle.py @@ -0,0 +1,95 @@ +"""Live cancellation and shutdown checks that require the current gRPC server to execute.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import anyio +import anyio.abc +import grpc.aio +import pytest +from mcp import Client, MCPError +from mcp.server.mcpserver import MCPServer +from mcp.types import CONNECTION_CLOSED, REQUEST_TIMEOUT + +from mcp_transport_examples.grpc import grpc_client, grpc_server + + +async def verify(cause: str) -> None: + entered = anyio.Event() + cancelled = anyio.Event() + stop = anyio.Event() + stopped = anyio.Event() + finished = anyio.Event() + errors: list[int] = [] + + @asynccontextmanager + async def lifespan(server: MCPServer[None]) -> AsyncIterator[None]: + try: + yield None + finally: + assert cancelled.is_set() + + server = MCPServer("gRPC cancellation", lifespan=lifespan) + + @server.tool() + async def wait() -> str: + entered.set() + try: + await anyio.sleep_forever() + finally: + cancelled.set() + raise NotImplementedError + + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + + async def run_server(*, task_status: anyio.abc.TaskStatus[None]) -> None: + try: + async with server.serve() as runtime: + await runtime.connect(grpc_server(listener)) + await listener.start() + task_status.started() + await stop.wait() + finally: + with anyio.move_on_after(5, shield=True): + await listener.stop(0) + stopped.set() + + async with anyio.create_task_group() as tg: + await tg.start(run_server) + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + + async def call(*, task_status: anyio.abc.TaskStatus[anyio.CancelScope]) -> None: + with anyio.CancelScope() as scope: + task_status.started(scope) + try: + # A real deadline is the behavior under test, not a synchronization delay. + await client.call_tool("wait", read_timeout_seconds=0.5 if cause == "timeout" else None) + except MCPError as exc: + errors.append(exc.code) + finished.set() + + async with anyio.create_task_group() as calls: + scope = await calls.start(call) + await entered.wait() + if cause == "caller": + scope.cancel() + elif cause == "runtime": + stop.set() + elif cause == "channel": + await channel.close() + await finished.wait() + await cancelled.wait() + expected = [] if cause == "caller" else [REQUEST_TIMEOUT if cause == "timeout" else CONNECTION_CLOSED] + assert errors == expected + stop.set() + await stopped.wait() + + +@pytest.mark.anyio +@pytest.mark.parametrize("cause", ["caller", "timeout", "runtime", "channel"]) +async def test_native_cancellation_finishes_the_request_and_handler(cause: str) -> None: + """Exercise this process's gRPC server, not a recorded response or an external service.""" + with anyio.fail_after(5): + await verify(cause) diff --git a/examples/transports/tests/test_grpc_response.py b/examples/transports/tests/test_grpc_response.py new file mode 100644 index 0000000000..40b3c63337 --- /dev/null +++ b/examples/transports/tests/test_grpc_response.py @@ -0,0 +1,89 @@ +from collections.abc import AsyncIterator + +import anyio +import grpc +import grpc.aio +import pytest +from mcp import Client, MCPError +from mcp.types import CONNECTION_CLOSED, Request, RequestParams, Result + +from mcp_transport_examples.grpc import grpc_client +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest, Notification + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "events", + [ + [], + [CallEvent()], + [CallEvent(result_json=b"{}"), CallEvent(result_json=b"{}")], + [CallEvent(result_json=b"[]")], + [CallEvent(result_json=b'{"value": Infinity}')], + [CallEvent(result_json=b'{"value": 1e400}')], + [CallEvent(result_json=b"[" * 10_000 + b"0" + b"]" * 10_000)], + [CallEvent(notification=Notification(method="example/event", params_json=b"[]"))], + [ + CallEvent( + notification=Notification( + method="notifications/progress", params_json=b'{"progressToken":0,"progress":1}' + ) + ) + ], + [CallEvent(result_json=b'{"padding":"' + b"a" * (4 * 1024 * 1024) + b'"}')], + ], + ids=[ + "missing", + "empty-event", + "duplicate", + "array-result", + "nonfinite", + "overflow", + "deep", + "bad-notification", + "notification-only", + "oversized", + ], +) +async def test_invalid_response_frames_fail_the_mcp_request(events: list[CallEvent]) -> None: + """A typed SDK server cannot produce these invalid frames, so a local gRPC peer sends them explicitly.""" + + async def reply( + request: CallRequest, context: grpc.aio.ServicerContext[CallRequest, CallEvent] + ) -> AsyncIterator[CallEvent]: + assert request.method == "example/response" + for event in events: + yield event + + listener = grpc.aio.server() + listener.add_generic_rpc_handlers( + [ + grpc.method_handlers_generic_handler( + "mcp.transport.example.MCP", + { + "Call": grpc.unary_stream_rpc_method_handler( + reply, + request_deserializer=CallRequest.FromString, + response_serializer=CallEvent.SerializeToString, + ) + }, + ) + ] + ) + port = listener.add_insecure_port("127.0.0.1:0") + with anyio.fail_after(5): + try: + await listener.start() + async with grpc.aio.insecure_channel( + f"127.0.0.1:{port}", options=[("grpc.max_receive_message_length", 8 * 1024 * 1024)] + ) as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + with pytest.raises(MCPError) as exc: + await client.session.send_request( + Request(method="example/response", params=RequestParams()), Result + ) + assert exc.value.code == CONNECTION_CLOSED + assert isinstance(exc.value.__cause__, ValueError) + finally: + with anyio.fail_after(5, shield=True): + await listener.stop(0) diff --git a/examples/transports/tests/test_grpc_server.py b/examples/transports/tests/test_grpc_server.py new file mode 100644 index 0000000000..1d57efef55 --- /dev/null +++ b/examples/transports/tests/test_grpc_server.py @@ -0,0 +1,222 @@ +import json +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager +from typing import Any + +import anyio +import grpc +import grpc.aio +import pytest +from mcp import Client, MCPError +from mcp.client.subscriptions import ToolsListChanged +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import CONNECTION_CLOSED, INVALID_PARAMS, Request, RequestParams, Result +from pydantic import BaseModel + +from mcp_transport_examples.grpc import grpc_client, grpc_server +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest + + +@asynccontextmanager +async def serving(server: Server[Any] | MCPServer[Any], *, max_requests: int = 64) -> AsyncIterator[str]: + async with AsyncExitStack() as stack: + listener = grpc.aio.server(options=[("grpc.max_receive_message_length", 8 * 1024 * 1024)]) + port = listener.add_insecure_port("127.0.0.1:0") + stack.push_async_callback(listener.stop, 0) + runtime = await stack.enter_async_context(server.serve()) + await runtime.connect(grpc_server(listener, max_requests=max_requests)) + await listener.start() + yield f"127.0.0.1:{port}" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("params", "request_id"), + [ + (b"[]", b"0"), + (b'{"number": NaN}', b"0"), + (b'{"number": 1e400}', b"0"), + (b"[" * 10_000 + b"0" + b"]" * 10_000, b"0"), + (b"\xff", b"0"), + (b"{}", b"true"), + (b"{}", b"null"), + (b'{"padding":"' + b"a" * (4 * 1024 * 1024) + b'"}', b"0"), + ], + ids=["array", "nonfinite", "overflow", "deep", "invalid-utf8", "boolean-id", "null-id", "oversized"], +) +async def test_invalid_binding_payload_is_rejected_before_mcp_dispatch(params: bytes, request_id: bytes) -> None: + """The typed MCP client cannot emit malformed protobuf-binding input, so send it over a real raw gRPC call.""" + with anyio.fail_after(5): + async with serving(Server("validation")) as target, grpc.aio.insecure_channel(target) as channel: + call = channel.unary_stream( + "/mcp.transport.example.MCP/Call", + request_serializer=CallRequest.SerializeToString, + response_deserializer=CallEvent.FromString, + ) + with pytest.raises(grpc.aio.AioRpcError) as exc: + async for _ in call( + CallRequest(method="example/invalid", params_json=params, request_id_json=request_id) + ): + raise NotImplementedError + assert exc.value.code() == grpc.StatusCode.INVALID_ARGUMENT + + +@pytest.mark.anyio +async def test_native_capacity_rejects_excess_work_and_recovers() -> None: + """A saturated binding refuses another request without preventing the admitted one from completing.""" + entered = anyio.Event() + release = anyio.Event() + server = MCPServer("capacity") + + @server.tool() + async def hold() -> str: + entered.set() + await release.wait() + return "released" + + with anyio.fail_after(5): + async with serving(server, max_requests=1) as target, grpc.aio.insecure_channel(target) as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + + async def first() -> None: + result = await client.call_tool("hold") + assert result.structured_content == {"result": "released"} + + async with anyio.create_task_group() as tg: + tg.start_soon(first) + try: + await entered.wait() + with pytest.raises(MCPError) as exc: + await client.list_tools() + assert exc.value.code == CONNECTION_CLOSED + cause = exc.value.__cause__ + assert isinstance(cause, grpc.aio.AioRpcError) + assert cause.code() == grpc.StatusCode.RESOURCE_EXHAUSTED + finally: + release.set() + tools = await client.list_tools() + assert [tool.name for tool in tools.tools] == ["hold"] + + +@pytest.mark.anyio +async def test_closed_runtime_refuses_calls_on_a_borrowed_listener() -> None: + """Runtime shutdown closes the MCP binding while leaving the caller's gRPC listener under its ownership.""" + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + with anyio.fail_after(5): + try: + async with Server("closed runtime").serve() as runtime: + await runtime.connect(grpc_server(listener)) + await listener.start() + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + with pytest.raises(MCPError) as exc: + await client.list_tools() + assert exc.value.code == CONNECTION_CLOSED + cause = exc.value.__cause__ + assert isinstance(cause, grpc.aio.AioRpcError) + assert cause.code() == grpc.StatusCode.UNAVAILABLE + finally: + await listener.stop(0) + + +@pytest.mark.anyio +@pytest.mark.parametrize("failure", ["mcp", "validation", "self-cancel"]) +async def test_handler_failures_settle_the_native_call(failure: str) -> None: + """Run the current server's failure paths; a replayed response would not exercise handler lifetime or conversion.""" + + class IntegerValue(BaseModel): + value: int + + async def handler(ctx: ServerRequestContext, params: RequestParams) -> Result: + assert ctx.method == "example/fail" + if failure == "mcp": + raise MCPError(code=12345, message="refused", data={"reason": "application"}) + if failure == "self-cancel": + raise anyio.get_cancelled_exc_class()() + IntegerValue.model_validate({"value": "not an integer"}) + raise NotImplementedError + + server = Server("failures") + server.add_request_handler("example/fail", RequestParams, handler) + with anyio.fail_after(5): + async with serving(server) as target, grpc.aio.insecure_channel(target) as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + with pytest.raises(MCPError) as exc: + await client.session.send_request(Request(method="example/fail", params=RequestParams()), Result) + assert ( + exc.value.code + == {"mcp": 12345, "validation": INVALID_PARAMS, "self-cancel": CONNECTION_CLOSED}[failure] + ) + + +@pytest.mark.anyio +async def test_null_parameters_reach_mcp_envelope_validation() -> None: + """Null is valid in the binding but lacks the MCP envelope, which the typed client normally always supplies.""" + with anyio.fail_after(5): + async with serving(Server("envelope")) as target, grpc.aio.insecure_channel(target) as channel: + call = channel.unary_stream( + "/mcp.transport.example.MCP/Call", + request_serializer=CallRequest.SerializeToString, + response_deserializer=CallEvent.FromString, + ) + events = [ + event + async for event in call(CallRequest(method="example/test", params_json=b"null", request_id_json=b"0")) + ] + assert len(events) == 1 + assert events[0].WhichOneof("payload") == "error_json" + assert json.loads(events[0].error_json)["code"] == INVALID_PARAMS + + +@pytest.mark.anyio +async def test_progress_callback_failure_does_not_abort_the_request(caplog: pytest.LogCaptureFixture) -> None: + """A client callback failure is isolated from the server result and logged with its traceback.""" + server = MCPServer("callback isolation") + + @server.tool() + async def ready(ctx: Context) -> str: + await ctx.report_progress(1.5, 2, "working") + return "ready" + + async def progress(progress: float, total: float | None, message: str | None) -> None: + raise RuntimeError("callback failed") + + with anyio.fail_after(5): + async with serving(server) as target, grpc.aio.insecure_channel(target) as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + result = await client.call_tool("ready", progress_callback=progress) + assert result.structured_content == {"result": "ready"} + records = [record for record in caplog.records if record.name == "mcp_transport_examples.grpc_response"] + assert len(records) == 1 + assert records[0].exc_info is not None + + +@pytest.mark.anyio +async def test_subscription_acknowledgment_and_events_use_the_original_rpc() -> None: + """A live listen RPC remains open while a separate tool request publishes a typed change event.""" + server = MCPServer("subscriptions") + + @server.tool() + async def announce(ctx: Context) -> str: + await ctx.notify_tools_changed() + return "announced" + + with anyio.fail_after(5): + async with serving(server) as target, grpc.aio.insecure_channel(target) as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + async with client.listen(tools_list_changed=True) as subscription: + result = await client.call_tool("announce") + assert result.structured_content == {"result": "announced"} + event = await anext(subscription) + assert isinstance(event, ToolsListChanged) + + +@pytest.mark.anyio +async def test_invalid_capacity_fails_before_registering_the_binding() -> None: + """Invalid configuration fails locally, without creating an RPC or binding a listening socket.""" + listener = grpc.aio.server() + with anyio.fail_after(5), pytest.raises(ValueError): + async with grpc_server(listener, max_requests=0).connection: + raise NotImplementedError diff --git a/examples/transports/tests/test_grpc_shutdown_order.py b/examples/transports/tests/test_grpc_shutdown_order.py new file mode 100644 index 0000000000..c2f934d235 --- /dev/null +++ b/examples/transports/tests/test_grpc_shutdown_order.py @@ -0,0 +1,93 @@ +"""Lifespan resources must outlive a handler performing shielded cleanup.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import anyio +import anyio.abc +import grpc.aio +import pytest +from mcp import Client, MCPError +from mcp.server.mcpserver import MCPServer +from mcp.types import CONNECTION_CLOSED + +from mcp_transport_examples.grpc import grpc_client, grpc_server + + +async def verify() -> None: + entered = anyio.Event() + cleanup_started = anyio.Event() + release_cleanup = anyio.Event() + cleanup_finished = anyio.Event() + lifespan_closed = anyio.Event() + stop = anyio.Event() + + @asynccontextmanager + async def lifespan(server: MCPServer[None]) -> AsyncIterator[None]: + try: + yield None + finally: + lifespan_closed.set() + assert cleanup_finished.is_set() + + server = MCPServer("shutdown order", lifespan=lifespan) + + @server.tool() + async def wait() -> str: + entered.set() + try: + await anyio.sleep_forever() + finally: + with anyio.CancelScope(shield=True): + cleanup_started.set() + await release_cleanup.wait() + assert not lifespan_closed.is_set() + cleanup_finished.set() + raise NotImplementedError + + listener = grpc.aio.server() + port = listener.add_insecure_port("127.0.0.1:0") + + async def run_server(*, task_status: anyio.abc.TaskStatus[None]) -> None: + try: + async with server.serve() as runtime: + await runtime.connect(grpc_server(listener)) + await listener.start() + task_status.started() + await stop.wait() + finally: + with anyio.move_on_after(5, shield=True): + await listener.stop(0) + + async with anyio.create_task_group() as tg: + await tg.start(run_server) + async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: + async with Client(grpc_client(channel), mode="2026-07-28") as client: + + async def call() -> None: + with pytest.raises(MCPError) as exc: + await client.call_tool("wait") + assert exc.value.code == CONNECTION_CLOSED + + async with anyio.create_task_group() as calls: + calls.start_soon(call) + try: + await entered.wait() + stop.set() + await cleanup_started.wait() + # The old five-second join timeout closed lifespan while this cleanup still ran. + with anyio.move_on_after(5.1) as window: + await lifespan_closed.wait() + assert window.cancelled_caught + finally: + release_cleanup.set() + await lifespan_closed.wait() + assert cleanup_finished.is_set() + + +@pytest.mark.anyio +async def test_runtime_keeps_lifespan_alive_through_shielded_handler_cleanup() -> None: + """The live handler must finish using application state before lifespan releases it.""" + # This check intentionally holds cleanup past the old five-second deadline. + with anyio.fail_after(10): + await verify() diff --git a/examples/transports/tests/test_grpc_tls.py b/examples/transports/tests/test_grpc_tls.py new file mode 100644 index 0000000000..ef66ec69ce --- /dev/null +++ b/examples/transports/tests/test_grpc_tls.py @@ -0,0 +1,177 @@ +import ipaddress +from contextlib import AsyncExitStack +from datetime import datetime, timedelta, timezone +from typing import Literal + +import anyio +import grpc +import grpc.aio +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from mcp import Client, MCPError +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, + PROTOCOL_VERSION_META_KEY, + CallToolRequestParams, + CallToolResult, + Implementation, +) + +from mcp_transport_examples.grpc import grpc_client, grpc_server +from mcp_transport_examples.grpc_context import GRPCContext +from mcp_transport_examples.rpc_pb2 import CallEvent, CallRequest + + +def certificate( + name: str, + key: ec.EllipticCurvePrivateKey, + issuer: x509.Name, + issuer_key: ec.EllipticCurvePrivateKey, + *, + ca: bool = False, + server: bool = False, +) -> bytes: + now = datetime.now(timezone.utc) + builder = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension(x509.BasicConstraints(ca=ca, path_length=None), critical=True) + ) + if not ca: + builder = builder.add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH if server else ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + if server: + builder = builder.add_extension( + x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False + ) + return builder.sign(issuer_key, hashes.SHA256()).public_bytes(serialization.Encoding.PEM) + + +def private_bytes(key: ec.EllipticCurvePrivateKey) -> bytes: + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption() + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("security", ["insecure", "tls", "mtls", "missing-certificate", "untrusted-certificate"]) +async def test_peer_identity_comes_from_tls_not_caller_claims( + security: Literal["insecure", "tls", "mtls", "missing-certificate", "untrusted-certificate"], +) -> None: + """SDK-defined: native identity ignores caller claims; rejected TLS peers never reach middleware. + + Steps: 1. Make a typed client call. 2. Check authenticated or anonymous identity. + 3. Inject identity-looking RPC metadata, which the typed client cannot supply, and check identity again. + """ + root_key = ec.generate_private_key(ec.SECP256R1()) + root_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test root")]) + root_cert = certificate("test root", root_key, root_name, root_key, ca=True) + server_key = ec.generate_private_key(ec.SECP256R1()) + server_cert = certificate("test server", server_key, root_name, root_key, server=True) + client_key = ec.generate_private_key(ec.SECP256R1()) + issuer_key = ec.generate_private_key(ec.SECP256R1()) if security == "untrusted-certificate" else root_key + client_cert = certificate("alice", client_key, root_name, issuer_key) + client_info = Implementation(name="bob", version="1").model_dump(by_alias=True, exclude_none=True) + reached: list[str] = [] + claims: list[str | bytes | None] = [] + + async def observe(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + assert isinstance(ctx.transport, GRPCContext) + assert ctx.params is not None + assert ctx.params["_meta"][CLIENT_INFO_META_KEY] == client_info + reached.append(ctx.method) + claims.append(dict(ctx.transport.metadata).get("x509_common_name")) + return await call_next(ctx) + + server = MCPServer("TLS", middleware=[observe]) + + @server.tool() + async def identity(ctx: Context) -> dict[str, str | list[str] | None]: + assert ctx.request_context.method == "tools/call" + assert isinstance(ctx.transport, GRPCContext) + return { + "key": ctx.transport.peer_identity_key, + "identities": [value.decode("utf-8") for value in ctx.transport.peer_identities], + } + + with anyio.fail_after(5): + async with AsyncExitStack() as stack: + listener = grpc.aio.server() + credentials = grpc.ssl_server_credentials( + [(private_bytes(server_key), server_cert)], + root_certificates=root_cert, + require_client_auth=security != "tls", + ) + port = ( + listener.add_insecure_port("127.0.0.1:0") + if security == "insecure" + else listener.add_secure_port("127.0.0.1:0", credentials) + ) + stack.push_async_callback(listener.stop, 0) + runtime = await stack.enter_async_context(server.serve()) + await runtime.connect(grpc_server(listener)) + await listener.start() + present_certificate = security in ("mtls", "untrusted-certificate") + channel_credentials = grpc.ssl_channel_credentials( + root_certificates=root_cert, + private_key=private_bytes(client_key) if present_certificate else None, + certificate_chain=client_cert if present_certificate else None, + ) + channel = await stack.enter_async_context( + grpc.aio.insecure_channel(f"127.0.0.1:{port}") + if security == "insecure" + else grpc.aio.secure_channel(f"127.0.0.1:{port}", channel_credentials) + ) + client = await stack.enter_async_context( + Client(grpc_client(channel), mode="2026-07-28", client_info=Implementation(name="bob", version="1")) + ) + if security in ("missing-certificate", "untrusted-certificate"): + with pytest.raises(MCPError) as exc: + await client.call_tool("identity") + assert exc.value.code == CONNECTION_CLOSED + assert reached == [] + else: + result = await client.call_tool("identity") + assert result.structured_content == { + "key": "x509_common_name" if security == "mtls" else None, + "identities": ["alice"] if security == "mtls" else [], + } + assert "tools/call" in reached + assert all(claim is None for claim in claims) + rpc = channel.unary_stream( + "/mcp.transport.example.MCP/Call", + request_serializer=CallRequest.SerializeToString, + response_deserializer=CallEvent.FromString, + ) + params = CallToolRequestParams( + name="identity", + _meta={ + PROTOCOL_VERSION_META_KEY: "2026-07-28", + CLIENT_CAPABILITIES_META_KEY: {}, + CLIENT_INFO_META_KEY: client_info, + }, + ) + request = CallRequest( + method="tools/call", + params_json=params.model_dump_json(by_alias=True).encode("utf-8"), + request_id_json=b"1", + ) + events = [event async for event in rpc(request, metadata=(("x509_common_name", "mallory"),))] + assert len(events) == 1 + forged = CallToolResult.model_validate_json(events[0].result_json) + assert forged.structured_content == result.structured_content + assert claims[-1] == "mallory" diff --git a/pyproject.toml b/pyproject.toml index b2f26da55f..446d95455e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -245,7 +245,7 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] +members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets", "examples/transports"] [tool.uv.sources] mcp = { workspace = true } @@ -254,6 +254,8 @@ mcp-types = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } [tool.pytest.ini_options] +# Optional transport examples use their own test environment. +testpaths = ["tests"] log_cli = true xfail_strict = true # tests/docs/ imports the docs tooling, top-level modules under scripts/docs/. diff --git a/uv.lock b/uv.lock index 40b563e974..5112a7eb6f 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ members = [ "mcp-sse-polling-client", "mcp-sse-polling-demo", "mcp-structured-output-lowlevel", + "mcp-transport-examples", "mcp-types", ] build-constraints = [ @@ -166,6 +167,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/c5/092e631bc1fba86f0a822be65c137c90a71b71ba0a0865e7e9a21f6ca05e/blockbuster-1.5.27-py3-none-any.whl", hash = "sha256:f0acf153d22a791bf5f142935332ef8530960ec215541b48a6037e6cea0a8645", size = 13517, upload-time = "2026-08-17T23:53:14.625Z" }, ] +[[package]] +name = "cassetter" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/8f/e934ce0b21f7045181b786412b5326d6f89b4f7f58303d4460054bc93872/cassetter-0.11.0.tar.gz", hash = "sha256:7578203459c08623f8f27e6e9179774359ceb101b9f207308274fdaae9e4d589", size = 102607, upload-time = "2026-09-10T12:06:54.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/5f/cb2d42df62d26b10c8f9e0ec227c08e992eff64f3a16b709f4823fda8fce/cassetter-0.11.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:af6aab0015cce3eadeebec7fbb15612c457aa482664170f6c7f7966fdd03b8ea", size = 1980907, upload-time = "2026-09-10T12:05:48.859Z" }, + { url = "https://files.pythonhosted.org/packages/31/45/25563caa90b94766a0bcd1473444aeccdddf6ee682e789fd3a24286eb5b2/cassetter-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae0ba29a86fd290c2b0e19f39c598ec9176544d005de53a40a0c7a2eec332a49", size = 1857688, upload-time = "2026-09-10T12:05:50.531Z" }, + { url = "https://files.pythonhosted.org/packages/45/23/03e1902699ba6c9d6c849a50dacfe6e6a6937ce08589c0ee95b785a58b65/cassetter-0.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ff5729b084a201c0ff789272846b4876f4bd008b47d0929ce233727ac13fbdf", size = 1926524, upload-time = "2026-09-10T12:05:52.058Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fe/9bc4776fb99ed57878e790c15c76e46532041cecc8d3587ae1537647a0cb/cassetter-0.11.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d5b9bfdeae7179ae79560180c5938754722d74b12b1c6f5a5845166da0ed2c5", size = 2070007, upload-time = "2026-09-10T12:05:53.701Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/405f4eabd04c122074618883a6810bbbe30f3e45dbc8da367b38390aa906/cassetter-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c20f1a62a9394a9ddf2b09966a45698aa7d231a5016e6409cdace6d8157a019", size = 2112086, upload-time = "2026-09-10T12:05:55.421Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bbfda42e4756945d9a4796bb37b7852c90248dbc73a7812f34f64239c00a/cassetter-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8feab7f50cd9ef59860f3d68883a8cc729ca780589d3e1ab6bee5da20e316c9a", size = 2297899, upload-time = "2026-09-10T12:05:57.111Z" }, + { url = "https://files.pythonhosted.org/packages/29/e7/74aa131e5d63d9b96e2e5d83bad908257e0d808668132bb41428f3b36784/cassetter-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:136964f2486aa8dad45e39517c4b43ad7faee30cf104f16736669d72ece73c4d", size = 2019629, upload-time = "2026-09-10T12:05:58.496Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e3/e2be9ba63fa2720541e931332b31c7af2874caee2d368b4d8ed277ade158/cassetter-0.11.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:421178c75b31101c8c0af6b9b36a3ecdb55858dadc7767e498e9f7791a6d9f95", size = 1980953, upload-time = "2026-09-10T12:05:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/ebe6dfc0d32c9b033f154a7a336d66eb93f4456d7335340e621b75129244/cassetter-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52db9ee46971ffb68c3cf0df384dec15f6026df4352305ebe0bdf603d0532ee3", size = 1857850, upload-time = "2026-09-10T12:06:01.383Z" }, + { url = "https://files.pythonhosted.org/packages/79/49/b9f8f250c3d5817f165659561a5a007f6e3cf8e3984f3275ab6c8ca69c21/cassetter-0.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37de99fc2e71dd0b6231fb6087b363bf8c244ba5a095a0166768e14c233c7511", size = 1926553, upload-time = "2026-09-10T12:06:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/87/c3/88f91278457a07a8186d65cf9aae9166ec1eb8a939ca18e23a9d9294b06c/cassetter-0.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5a2a77aaf2adb80a0f5c7860536d423d0e10fb579a3703f64e27eaf71111e75", size = 2070025, upload-time = "2026-09-10T12:06:04.069Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7f/fc850ebdaad4467f103ae9d5939295be812447e0c6a72de15378e3663615/cassetter-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e39eab159142285b7b743a5ad64046bc4e3c38dba0383797737e45ecc3323b4f", size = 2111806, upload-time = "2026-09-10T12:06:05.7Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/94f3b7f6119878a45e8d193f64aead700776dd98183880f0e8067db38671/cassetter-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b03bed6a5842a26347d32bcfb0c1f4a87cc81ef2324e8d7677584036fa676706", size = 2297752, upload-time = "2026-09-10T12:06:07.519Z" }, + { url = "https://files.pythonhosted.org/packages/35/3b/6f7688fc36c40b20d6c2c6e81373117fa79cd4e705bae5ea98d488919180/cassetter-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:498eb6718c401746e432e365ba322920351bbb2f82260956b30b29e3b5a82ba4", size = 2019574, upload-time = "2026-09-10T12:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/12/96/efe27ef22c195539e76c079391a39aff0b9791b98881cefc8c2680ff89e9/cassetter-0.11.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e37ba7df95d7132a109d126889955541176902a830ac386a6061b716efc74f1", size = 1992016, upload-time = "2026-09-10T12:06:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/46/82/7ca66ce3d58069692ce948437940fc657954c0047d63c57e8d1ebe21bc96/cassetter-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e035f81fc4096ba809b33d7dadf56233d8b77b6738f7458e52937fbc796d92aa", size = 1852366, upload-time = "2026-09-10T12:06:12.696Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cc/8375f65d6f1385194570097c45018a01655023e35fbe457df09cfb61f492/cassetter-0.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c905cbd42dd322ed67bf2127c8b7b23ace222232f935427b79b6b902f470aff", size = 1924201, upload-time = "2026-09-10T12:06:14.283Z" }, + { url = "https://files.pythonhosted.org/packages/db/6a/d4094c2cd57d2f180e61cab004c6f78d3563f70002722e2aa0fbfc566215/cassetter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c36109d40ceadb60024a011413efc9ab9f71ffb42fcd2c37bb8bb4dcf82d13", size = 2069606, upload-time = "2026-09-10T12:06:15.681Z" }, + { url = "https://files.pythonhosted.org/packages/df/04/c5b3e374d92427ed5a124a418a5e31e7bdb52932d9ca65fbe89d90bedc05/cassetter-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b3086a2c180d854562c3ae2d455d114751c90783db81ccf83f63d1bed699722", size = 2110256, upload-time = "2026-09-10T12:06:16.996Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/7daa996190ac39686021d6df7d5c734e6762d8e444c77e29b5702fff4353/cassetter-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c605d865c07b2bcb328cbf1d380d543a1703f1384a279fdae853e092d437cab", size = 2297399, upload-time = "2026-09-10T12:06:18.398Z" }, + { url = "https://files.pythonhosted.org/packages/d9/33/c0245b72c375c7e08ea4528ad53078b7b21053b1f665d7cf20252f131930/cassetter-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:c2f2967c91d87af8798c2f4ec88dd2be7b190b34d3ba20732bf612ebc35faf93", size = 2016727, upload-time = "2026-09-10T12:06:19.691Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/571036d38c0c98109200459ba2deb7bfe3db2354f481c8b9f559d5a99c71/cassetter-0.11.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:074242e9febdfdc8733d8b1ee3635d80af17c0ce68e501249b09c113ce6cdeee", size = 1992011, upload-time = "2026-09-10T12:06:21.247Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/3f7d5d52e183eab67da5dad1403bd7ca3e27b4df179dec07439a6e1479b3/cassetter-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7a65cd317af3ebe7eb329e178bccabc56ce06cc6c40e023fc34bcbf5a5ff317e", size = 1852584, upload-time = "2026-09-10T12:06:22.691Z" }, + { url = "https://files.pythonhosted.org/packages/86/9a/363567977feb6797d394bd5267057f7db5e67e6d9a615150f104b70cd9a6/cassetter-0.11.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0225a7264649a1db81d81e86f779e3d0fcc189006109f446bdfd6bb31f890464", size = 1924781, upload-time = "2026-09-10T12:06:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/8b910cb80abe925494f4dcb40cd61bf66ba1fb96f98d45bbd21860cc64db/cassetter-0.11.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0e40aff3a75ed10d309dc4e05d750bfa1873c4a6d8efd57eb1027b1fd415072", size = 2069427, upload-time = "2026-09-10T12:06:26.114Z" }, + { url = "https://files.pythonhosted.org/packages/76/fa/9dad53bf7b94add575e936d985998c18e4445d6d826426841e1084b2df13/cassetter-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:70803d4a3f3f0b631c648fc0c07cde2947b91f9694345a7a5172d0a60567d51c", size = 2109980, upload-time = "2026-09-10T12:06:27.805Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ab/b15e1a3ce73a4cbef98a101940c344a681716a9cdf348ed2bdf0b8f0ede6/cassetter-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f6a1ccb97b70a3db83ffddf1fab5d6e2978f2837d800acc08c2079a57dfef2b", size = 2297450, upload-time = "2026-09-10T12:06:29.524Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/0c3b62801c1787c345e23168f8b08383b09fe612a5df3559a14522479ac3/cassetter-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:6bef0417a2babf8dd5b39a3187466bf647591d2bbef6a0db57d5717edd23d911", size = 2016955, upload-time = "2026-09-10T12:06:31.146Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/cc8e13ff2446653a609faed97aca79329c0164033286167dcb1eee740f3c/cassetter-0.11.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ace65b75f1f79a57ebf86037521a8c6050393a1cf933222405f6effe7ca6ff39", size = 1993017, upload-time = "2026-09-10T12:06:32.543Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/79d95436827031edb0cd5fec82536f0765079cd4f20bb55f7021ea1eb293/cassetter-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:86196ca7af873231a8339616e11ae0b6159657e22c7b148c152d0998c2461a7b", size = 1854089, upload-time = "2026-09-10T12:06:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/0dac2e31cde85601fb6c757a4398fddb04dc816f5f807bcd829ea0f62ef0/cassetter-0.11.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:002b8bb2a9cb3250a9ad9c751278d32500a74548fefa5759f475eee86451d3d3", size = 1925672, upload-time = "2026-09-10T12:06:35.745Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/e78e26fd90df1caef9070ffb74ad92e34390aa5e2a08160b836c22fefdbe/cassetter-0.11.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f70b6a46a8d89731a40baa6ca5042d944aeb201d89a56318f2c647b24de09bad", size = 2070668, upload-time = "2026-09-10T12:06:37.46Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/763366bc70c0394b3b677648e0a217b7b017bc52ba51365eef0e8005d32e/cassetter-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:13214fc29329b21202259848cc85522ff1482e582fddd76683bac884afdf8545", size = 2110732, upload-time = "2026-09-10T12:06:38.903Z" }, + { url = "https://files.pythonhosted.org/packages/37/5f/ad7745cea2df68936942da76df3c176a0a581378c6171db91c2ed6a88271/cassetter-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7808afd0a4312b6b23b5befd2a1b50b22e5575bfda0340d13249e38200ef9a35", size = 2298322, upload-time = "2026-09-10T12:06:40.866Z" }, + { url = "https://files.pythonhosted.org/packages/89/57/383494395c4a4bdc27d9bcfb03e0c7fabd9cf2e8fdbc573d280c0352db38/cassetter-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:071c31f1a96de84cd8c6f699c6da19a2b38cf74d27bfa61d051327db1e994e8e", size = 2017296, upload-time = "2026-09-10T12:06:42.284Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/d100a48e7fcd065b80aca95eadf28531dcd6c686f818cf99f3e402a5ed85/cassetter-0.11.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3872fba7fde12ccc860aff7d13cc3e6aefdc4dbe65bf6c9de8b73dcf8330e0a5", size = 1988047, upload-time = "2026-09-10T12:06:43.743Z" }, + { url = "https://files.pythonhosted.org/packages/b6/de/c2c175b5c09a1143cbd472d3270a464836d7c9b0b209f64cac8f2402f258/cassetter-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c602d42f992d95c9eea42171bc7844aca0531c94e385ae70afc218d7abc8ae1", size = 1848022, upload-time = "2026-09-10T12:06:45.227Z" }, + { url = "https://files.pythonhosted.org/packages/e1/86/34936390872c5f5f24f09a2d96df878942807905e3b6a2551da136572d8f/cassetter-0.11.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:467562e1d0b8a19ee0eebb294397ea0973a064d1143bbc987d716ec83ffc2a69", size = 1918666, upload-time = "2026-09-10T12:06:46.77Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/cdc849e32bf111e7e5613554f1b6b5b3eb9e8133043d3ec3ac5c1260da0a/cassetter-0.11.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57020ab03f7cda137310081628af2336347c0be3f64709455570f5bfebc4f411", size = 2065273, upload-time = "2026-09-10T12:06:48.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/66/3740f408774d3c9f7d950d62a968530b468aba7320945ebdc931e94f97e1/cassetter-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aedd8c76cc4ca3302c8ed4953b82737b64479e5d15c80d186e5051f5aaa3fea9", size = 2104326, upload-time = "2026-09-10T12:06:50.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/a4/9b22abd9365e1e6f1b0208882b19a606347158e7c7e61fd21e25561e5a0a/cassetter-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72fd743caadef19b0ddeac453fa1acff62c746998e81656706654a5f51cb1048", size = 2294473, upload-time = "2026-09-10T12:06:51.516Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/634e5d897e03beaf808978c337d2b80dc02905570afce0cfec0d71b99910/cassetter-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18997a61a6da1598d348506502849d5a3129727715d8eed599b8b451789b432b", size = 2016101, upload-time = "2026-09-10T12:06:53.313Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + [[package]] name = "certifi" version = "2025.8.3" @@ -637,6 +693,140 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] +[[package]] +name = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/4b/a0dc421d049b743093eae90caeb5dd92ced7226cd4919dc4de34c81455b6/grpcio-1.84.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:71fd60e6e426d293d0a2f685115ad0a0845117602cf13605a4be7524fb5f7bba", size = 6450049, upload-time = "2026-09-14T06:56:48.72Z" }, + { url = "https://files.pythonhosted.org/packages/f7/41/90292bf55af7aa09de0e3ec928d1b8c56d477f85244f7928d2231630b781/grpcio-1.84.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8e1a45d174b6b8589f51dce1cea804aa6c1f72c9c80cba91ae2caabeb6d90540", size = 12344932, upload-time = "2026-09-14T06:56:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/e9/68/b6c0248266a378b1bde08e4de7d69f3cc08ee6f5937a5dce7d3c3ba0fe1d/grpcio-1.84.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efb29f8633bf6630dc89de4fe0353ac3d7e4b70ef7b6e29fb40f00e68c127fa5", size = 7030162, upload-time = "2026-09-14T06:56:54.412Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/0a2a2cbcf48847f83eb51fb982116d0965f2fe068e73f28b2d17facf30a4/grpcio-1.84.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d0fdd25faece8a1f95e8a3a8006e29701b5cf8dadb4a8132e68f3134637004a5", size = 7781546, upload-time = "2026-09-14T06:56:56.571Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7c/da97476f3c2e90e9f00bfb19def7cbb5f841b7661e3cd09c6a89beaa5b98/grpcio-1.84.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:393d8a78bff6731ecc5ad2151a821f8fbc1709b137ebb9c25a4ef399fbdcc914", size = 7186279, upload-time = "2026-09-14T06:56:58.817Z" }, + { url = "https://files.pythonhosted.org/packages/14/16/27fa3aed1ee6fdcbb978a1bd4bce255dc0122b90179535177a96543d14fc/grpcio-1.84.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fc66cb50c93554b86db0b6625ab5c6e9051dbf8847c08d93c84918e02e413fb7", size = 7731191, upload-time = "2026-09-14T06:57:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/4c/78/75644af37af85afb381376aef99cad92da8bc2d56ba3e5ae070a7cb59682/grpcio-1.84.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:455ed6083353b8e938f1d58c765eab2fbb165731e5b507be30fee344915a2a11", size = 8790443, upload-time = "2026-09-14T06:57:04.623Z" }, + { url = "https://files.pythonhosted.org/packages/95/4d/ce57fa986e93c06ef867f64e1ebe419e924fdc2395115607f0725f4855e4/grpcio-1.84.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d6a82c4fc6c85f2fb7572c86bdb86f84c97b6580e5f6599f711800bac48a5d8", size = 8138068, upload-time = "2026-09-14T06:57:07.348Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a6/22a73111c4f75da9450bf0481fac805396ec9bf6f949a90bb2969de07cf4/grpcio-1.84.0-cp310-cp310-win32.whl", hash = "sha256:8e3f508d0e9e6236ba2f08d56e33355e434e785e813149a1b8477d3edf69779d", size = 4496545, upload-time = "2026-09-14T06:57:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/31/ff/dc048bc3d8ebd8d4b7f6f6803c76142a9a5ca1e1e9fa34e79597f0f9ed77/grpcio-1.84.0-cp310-cp310-win_amd64.whl", hash = "sha256:ed2c1493c44d0932f1e55fdb5d1ead658c68288ec5d51b8c4928422d98633ef9", size = 5258144, upload-time = "2026-09-14T06:57:11.403Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b9/46146728b3f4a5c7e34c17d0ab724d58b5456b116e76dc77d3ef4e79b135/grpcio-1.84.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:4aaeceeb7fa7d824c322d1ec3208c8495c88478a927295553235435fc49043ad", size = 6454572, upload-time = "2026-09-14T06:57:14.651Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/5d668b4102637410d700153fd12d6a798e3ff8308bd9dcbaeae93f191060/grpcio-1.84.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:06619ba1515e5ee69fb2a514e95dd8be05ce74cb3928d5b34f87f87c86fe3c27", size = 12359529, upload-time = "2026-09-14T06:57:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/18/2a/52e29c02047a493f15a78c0502bde4d3fab7c19c7813944d367cd501811c/grpcio-1.84.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:158c1c11cfb61b4849c3caf4d52de6f5ecd376e14446feb4a90dc95a90d616f5", size = 7029927, upload-time = "2026-09-14T06:57:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/0a/11/9962b313553647abb091943e0721e4a1662ecc63cdfe930abf00abcce47a/grpcio-1.84.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a9383401d9f116f98cacd4eba6c505a6edb80ba65badfc8e8ed8ae64983bcc44", size = 7782268, upload-time = "2026-09-14T06:57:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14a9413cb7d4b2e782b4f79c81a918610caedf55138ab5916f5fdd4b002f/grpcio-1.84.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd8ea8eb3817b226057cc1c0e7ec4b378dcda52043b972b6ff12b1152178967d", size = 7187959, upload-time = "2026-09-14T06:57:24.686Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3b/6cc8e6aed8f23be40f52af341e5d4595ec3ec8d7572271a692b5c1212178/grpcio-1.84.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:756ea5c2da00fa65c930284892d2a9706828704ca3ba40b4c51c4834eb39fcfd", size = 7737554, upload-time = "2026-09-14T06:57:27.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6f61002a01802ca9675e1b3599c9b0f9f3cf168ded94ebacc02199309f88/grpcio-1.84.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28d2609691da93051e998495108bbddd2a9f7a561253bae94828d81290f30c15", size = 8792681, upload-time = "2026-09-14T06:57:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/8bec1ae7e6732a9b435a394ddfdfffde46c2620ae0109823f7cce1a54455/grpcio-1.84.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:27b8b36200a9fbee6e120246f4a8a41657549107ef19fb2c819c4b2fd524f39a", size = 8145493, upload-time = "2026-09-14T06:57:32.672Z" }, + { url = "https://files.pythonhosted.org/packages/59/84/c8c7bd210d657288f18af06522f150f61e81ea14fd3c7c135beed697c5fd/grpcio-1.84.0-cp311-cp311-win32.whl", hash = "sha256:465eef3d17e59ad22a556fc0138f7c7c799df426734344daec42c797d49fda99", size = 4495596, upload-time = "2026-09-14T06:57:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/da99356b3b573af357d059753a47fba54f1ca1a9c0e4deccd0210cb7f4ba/grpcio-1.84.0-cp311-cp311-win_amd64.whl", hash = "sha256:f9a456bdbed52a01c9ab8423bdebab04a5363c78676edc55ab9b58bd13bdf9e1", size = 5259900, upload-time = "2026-09-14T06:57:37.067Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/4c9a2e0e6b0aaf02781404cad2f79211f989f2c827cf672a4a48d1604d3e/grpcio-1.84.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:b5c6f20d657ae09ae4e30d9d3a21edd13f1219d58cc6f999b9d1bb63be9c1baa", size = 6415756, upload-time = "2026-09-14T06:57:39.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/131e7007bdee9acb77a8dbe8a16fa9fef75f88c1695242d8ee0993ac2d3d/grpcio-1.84.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:406583b4e8fb2282ebd392e12b963e601c1f82e07125a8c2cb5b144e7e024796", size = 12339195, upload-time = "2026-09-14T06:57:42.373Z" }, + { url = "https://files.pythonhosted.org/packages/db/d1/a7b7cda98fcab9b3d2916204a872d87371158a7a34e41768f524584fb64d/grpcio-1.84.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbdbcd06986ede3ce584083b1dc2afe6808e8943e5cf50ad11183c03aceda25a", size = 6984468, upload-time = "2026-09-14T06:57:45.035Z" }, + { url = "https://files.pythonhosted.org/packages/19/81/c5be83e3ac9416f73c4c51fe1ea9c41a0c42fc3509e3505faa46f5046abe/grpcio-1.84.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:23e6e8e8a75cff88e0a793bfd3becea03a13e2763ae90c1ff573bc19ca5b429a", size = 7749432, upload-time = "2026-09-14T06:57:47.395Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bf/258cd7c0a7ed92745dc93c31666d462d05b702807a689744bd49fb833bde/grpcio-1.84.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b44f0a0fc7bc6677d38cc80bca1a32814ce6c8f200fb8b3c1a61c9d77eaefbf3", size = 7156115, upload-time = "2026-09-14T06:57:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4b/7f829418dbfcf91b875e55e2973f1059a95decb4f081313416317ef04ec1/grpcio-1.84.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:210e4c32f907045eb8158273e60c6ab69a3947697df6245dbda381f26c59485b", size = 7708010, upload-time = "2026-09-14T06:57:52.496Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/9932e2fec6a04205f8bf3f8f4d2020479dcdac88feb6f93822ed31bf0eba/grpcio-1.84.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a71d24f40b0cc6798feaa978c7411dc1135b7018e9fc0442db611c139bf58344", size = 8759980, upload-time = "2026-09-14T06:57:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5c/b67407c6dbc480dfc0715f6eccdb1061e7c88d85f9a330a241d357a538c5/grpcio-1.84.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6c972474ce691aca74e58d17625450cef153dc4760364cadeb167983ea6d589", size = 8124904, upload-time = "2026-09-14T06:57:58.569Z" }, + { url = "https://files.pythonhosted.org/packages/02/37/2bfdae2df8dfcfc0df619b628e0c7153ce703adae827243f44720322ccc1/grpcio-1.84.0-cp312-cp312-win32.whl", hash = "sha256:0d532ade4486dad9b302ffa4d4683d67561051c26d17c4023322845e9fa10140", size = 4478915, upload-time = "2026-09-14T06:58:00.714Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/309268b7b39f6deb2342f634841e105623a0b67982e8b10ec516782ff1c6/grpcio-1.84.0-cp312-cp312-win_amd64.whl", hash = "sha256:49717e857899f4136d7657bf5aded61ac479110a075438290923a4d86af7cd02", size = 5253534, upload-time = "2026-09-14T06:58:03.336Z" }, + { url = "https://files.pythonhosted.org/packages/5d/51/40f99701adb01d4e5316a2aaf13838da1a24d5c879cd8c95156d7c364454/grpcio-1.84.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:209414080da8c20af94df1395b635da52dd57b5edc9e917e1deca0dc1c4bb55e", size = 6427619, upload-time = "2026-09-14T06:58:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/ed8e22a1237e6b2be6ef4f221d074a5b0e0dd8a0da8c944c04aea731f0eb/grpcio-1.84.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e41c3993eee896c617dbd8a505085d28b6e84a0445ed9a1f40f95808473cf678", size = 12336549, upload-time = "2026-09-14T06:58:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/00165b05cd73f45996748ea67ce9e55d08936f2fea94a7fd8541cc2d0e54/grpcio-1.84.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fff5ef3fe1bba7d6147e5f19e01e5e122ac2c076486887ddcb8d42e663400fbe", size = 6989458, upload-time = "2026-09-14T06:58:11.884Z" }, + { url = "https://files.pythonhosted.org/packages/26/38/d0486230e684d916f97429a53041db88410e662a38f2a8d09e2d90375840/grpcio-1.84.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8c62888c3e49debf37ad9773e3c02f77b0c1e811f8fb0962f2b6c3bbab5b97a", size = 7757778, upload-time = "2026-09-14T06:58:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/da/56/548a643decb059ca244499c675ae2c13a15f523ba94592c2774bd80a13c1/grpcio-1.84.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:986e9751d416d7a6eaa2fecdac38da63153d63a4b340ba7d624889c490451500", size = 7159572, upload-time = "2026-09-14T06:58:17.87Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/42caac81a79ec680f1f7a8eaf7ca90d2f93936ce0c3a073141ba96757f77/grpcio-1.84.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5933a052946873d01a42119a05420d669bdca436aeba2d1851988ccb12b421c0", size = 7710547, upload-time = "2026-09-14T06:58:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/57/a4/828ad990b2410fee0a55cc73aa1bf98eb5b911c54847374ef4f24b9e877b/grpcio-1.84.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e094dd21f077af8194923fc263cad872eaa1802bb0156fd7e5ae18e99cd86715", size = 8761519, upload-time = "2026-09-14T06:58:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1f91af098919eaf5d80d5a61126ad9fae074e5190c25a3014ce1d8d0d890/grpcio-1.84.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08735e3d08d24ab3132cf87e2e5dea8746cabcc7d676c2b0b7362f195feef9d9", size = 8121424, upload-time = "2026-09-14T06:58:27.006Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8f/77fd4a7a913b636785479922349c4cb98d94d05d15652e556b3ca0df6663/grpcio-1.84.0-cp313-cp313-win32.whl", hash = "sha256:70bb4ce8be0c5606bec259cbd7152374470396413b7863a658a08c849e6b29ff", size = 4477974, upload-time = "2026-09-14T06:58:29.528Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/1fa59ddbfc8898e5518d1447e46f771f387f0ed6132ad531395338e51a5c/grpcio-1.84.0-cp313-cp313-win_amd64.whl", hash = "sha256:b61692f0069b3eee2fc8a3a1b7f6c044df9e03fede6ce69b3ca832e1c39f26c5", size = 5255326, upload-time = "2026-09-14T06:58:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/26/6f/e25ca89ca5b0b7b95464c907a5c21a77c0ac8c4ee1dca164c4dd8f153ddb/grpcio-1.84.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:026d757df86c5b7a41de8200b9a2cda454aaa5004cb0c7e3374c66eb82f61499", size = 6428207, upload-time = "2026-09-14T06:58:34.401Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b4/6b76b429f3f9b901cdbc306c81364d708bc957f847a05cbd1046cd2d05d8/grpcio-1.84.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3de427b05f244ba2c2a9bdc67e7a6731c8340811524ecc4435466549f8af1d17", size = 12342420, upload-time = "2026-09-14T06:58:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/af/64/ac86d638ba7f73bee0dccb608ba551d4f63adf75151f00d2c43e46d3979e/grpcio-1.84.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e90e3bdf7b5eac005fef631adae9cafde16f922def207b80a7c46b253c18ad20", size = 6998396, upload-time = "2026-09-14T06:58:40.535Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/fa12e9ec9d7ebf8cc3e81428fa9e1ca0d30d22d546ce2baa4c64bc917cbc/grpcio-1.84.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e88d304f094f4937bc27ec6a435e218a084168f11ec630c8d5d39b431d08d81d", size = 7757538, upload-time = "2026-09-14T06:58:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/21/d7/94240c7fae121ff1f116dcf04a3b7ee0216a06832c704310363f72638d4c/grpcio-1.84.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:57dc36a5ab0e676f5f6e171de2917fd0aef73f32a9aaf23956bfe19997a30bd1", size = 7161480, upload-time = "2026-09-14T06:58:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/c9/7033e95d4b344969818b09185721c7608b47fc2498d97b5e4eec4995dbf3/grpcio-1.84.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5deda5b4bf62769eb98c119cca43d40e1231e34846b19db5cdea821d446a2253", size = 7720191, upload-time = "2026-09-14T06:58:48.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/b45df2deba81d55069076859480bae7109c9eec02bce5515c799530cc2aa/grpcio-1.84.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9bab4cf571653a8afffb83ce21aa27b51dfe629b526b7b6adec35491fe1fc2ea", size = 8762792, upload-time = "2026-09-14T06:58:51.068Z" }, + { url = "https://files.pythonhosted.org/packages/de/c4/3e1c3d6155c16b8737cc31d5b477d6cf1fc7cdd10d58320cf0ec9b446f42/grpcio-1.84.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5559b492007dc09b4de9b95dab05f0b5e53547aad230cf07e46c7dd017a3be5", size = 8123299, upload-time = "2026-09-14T06:58:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/f4864de5b815e5ba18858771f99381a398fac14117f89ef5291ed43d3c4e/grpcio-1.84.0-cp314-cp314-win32.whl", hash = "sha256:2c024da73b296f040b8360e60bd73a659b230093684a438da0e1260f34cc724e", size = 4562560, upload-time = "2026-09-14T06:58:56.894Z" }, + { url = "https://files.pythonhosted.org/packages/44/03/640811d4d8c84f5e603995c5a9bab725223aa472cad9ca4286c3bbf1c3e3/grpcio-1.84.0-cp314-cp314-win_amd64.whl", hash = "sha256:800b7e00d92553313c0463c200087930aa78678ec1d528193aeb50906f55989b", size = 5394092, upload-time = "2026-09-14T06:58:59.61Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/9e3d2c9f005f680f03308fa894b1db91d4ab3f0fe65ff630c69561e91e95/grpcio-1.84.0-cp315-cp315-linux_armv7l.whl", hash = "sha256:47ecf0d9b81d981f07b61bd89eced9d2582f5eaacc3aaa36ad27f81aef70a27f", size = 6428252, upload-time = "2026-09-14T06:59:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/77/34/0bc9f52ebf091311651eeab3a452fb557985604a3088cb5406f4d6df85d3/grpcio-1.84.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:61386101ecaa096b694d0dd278caf99a56aeec78440cc17e918eef0b50f2d567", size = 12359488, upload-time = "2026-09-14T06:59:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/c31052712f241cb6ecae9c226fabd519b7f8c64a7a40bac27e9ca0405b78/grpcio-1.84.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6d178ba6dc8e82976c184b65fddde172d054c17237993a3e083efe4f134d55b", size = 7019339, upload-time = "2026-09-14T06:59:08.76Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/b9b33ea4f1eb4cad28833cade604febf357385b5ebb0c9c7562d020e167a/grpcio-1.84.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:15bb76489e337fc492685c9758e2fd4d4ab516b901ad830dc5a91987decf00be", size = 7107974, upload-time = "2026-09-14T06:59:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9e/799d4c45db91bbdcd8c54b3982932dbcf3d059f7ce67dca3e8540faa1ece/grpcio-1.84.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82da34ae4f639c73ac46e521e00c0a49bf86f717b9fb1f405f133e98731e38dc", size = 7200036, upload-time = "2026-09-14T06:59:14.401Z" }, + { url = "https://files.pythonhosted.org/packages/45/dc/dcfdd13ada41aff9098f0c2c6f260eb7debbc88b84b7e5fcbd085165427d/grpcio-1.84.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b73836ba0e16fcbb57c31cf6cbc2907c8d8c790b83679df454b74bd15e0be04", size = 7742281, upload-time = "2026-09-14T06:59:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/75eab2ec77b80804bc5e21cec99b57598e726fca6484cd3e8920a97639d5/grpcio-1.84.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:42959bd50dd660ffc3f2a9bec15a6da4f9aaa0dda555d59ff2d2e80b908456a8", size = 8113629, upload-time = "2026-09-14T06:59:20.584Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/fdcf6bdc1df9ca11679a1187bef8e6b81df31a2baae69497e17344f05ea3/grpcio-1.84.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:659728f20fc7a0933ed7b1945435e31014b97ab8a5a7edcbaa70da4794aeb191", size = 8152972, upload-time = "2026-09-14T06:59:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cf/6720e720bfa80fcb1ace873f66724eb3c8b03bba2fa078a30c12cab3212e/grpcio-1.84.0-cp315-cp315-win32.whl", hash = "sha256:edb6f87fc60ff438557291501b3e16c7a77c3b01a52d782cf276dccc7c5dd89c", size = 4561981, upload-time = "2026-09-14T06:59:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/69d8a709df225bc2e06e028e9465166b174c24b3da07cc72d9a5ddc63194/grpcio-1.84.0-cp315-cp315-win_amd64.whl", hash = "sha256:4119efa6519871719ad81f33bc95ab87857dcb1c5801f30a6e592f2c41164169", size = 5394757, upload-time = "2026-09-14T06:59:30.118Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b3/1c5951352d6777fd7f99a0ccee04617fdfd8a5dbf2918a1f58c8b2b280b8/grpcio_tools-1.81.1.tar.gz", hash = "sha256:a22a3870180927fdd84e2b27d079ef5b7f5f8c6110181b6736afc17a463481f1", size = 6236155, upload-time = "2026-06-11T12:51:21.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/e1/1fcf884902ae7255d8da224cfa638ea88a46d50f62a33d06d35c8960b029/grpcio_tools-1.81.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:9b6ba8a72cfda576508701a7c0bbeebe6f6f9843320d4f12e74efd19ddccd965", size = 2586261, upload-time = "2026-06-11T12:49:21.447Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d7/1815110b2d40ec99dbb0a7e6d7eafd591cd1f1e9bf9d3858cd9cf3ffacbd/grpcio_tools-1.81.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac47a9ea1224df8b653072614e6f0207e9fbfe63fdabaa5918a60ca5fc931b88", size = 5817509, upload-time = "2026-06-11T12:49:25.958Z" }, + { url = "https://files.pythonhosted.org/packages/23/e8/af99579842b5a555312fa782f32ce0f99bd35b2b7a1243294b2755468857/grpcio_tools-1.81.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eac4bb645ceff0c147cc720a40ae68f97427eaafb4968e866dd8fcc20d3d4831", size = 2634112, upload-time = "2026-06-11T12:49:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/235ad56ac728c49c17e9218c4daccd5831e6ec7af94236bec0cc66c71c68/grpcio_tools-1.81.1-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cc410b621dd85193766c12dca2e238696199a27a65d2b31b6f0a4c6c0043ff26", size = 2957950, upload-time = "2026-06-11T12:49:29.619Z" }, + { url = "https://files.pythonhosted.org/packages/77/3e/9103e8b4610597bf89db49eb112091c91bf5d63ddef2a951e11a4be05f2b/grpcio_tools-1.81.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b62d254c214faa3773eac709376ae25cf7abff1a76ba5fc4dbcd7b14fc4e4ae6", size = 2697765, upload-time = "2026-06-11T12:49:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/3c/86/beb2a43fbb93570a2305696083f6736566301d957869f463308ec6839f95/grpcio_tools-1.81.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd0b68dc76b10b3384b9b6e9f59202b83dcaafd8098eb644759a69316686acf8", size = 3147588, upload-time = "2026-06-11T12:49:33.748Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/b0182d9948631cd837a372b6625cf59d6e335d4aab0f425d4b7306619074/grpcio_tools-1.81.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a28d231455ab6e3558299f7d831a73c8be8ee6b7ec614ecf39eb50c0ed15767f", size = 3708798, upload-time = "2026-06-11T12:49:35.979Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/f452a189d399051d85cf82fe2f27a070efaa52512a2c5e3ae6ef1ae99a1f/grpcio_tools-1.81.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82740248eb6f3b6a38988cb5e64adb7303af9ea5cb4197c8ed08c1fabc767440", size = 3366969, upload-time = "2026-06-11T12:49:37.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/48/0075cb4f6ae7db280f461de2dbba700b22ae62e351ae13e6e461cd6804de/grpcio_tools-1.81.1-cp310-cp310-win32.whl", hash = "sha256:801d9d8ab5cddf8f8e064225292f0713427011252a07828a6b54e2ed64d534de", size = 1008713, upload-time = "2026-06-11T12:49:39.791Z" }, + { url = "https://files.pythonhosted.org/packages/17/bd/7692bc698259e5645b68720e77e7b176d376f6ae0c9db8b5b750a02f1958/grpcio_tools-1.81.1-cp310-cp310-win_amd64.whl", hash = "sha256:3c8611d6e4e859ac5373422ef27c4b7540cf98c9991c9abc6722613ef72b13aa", size = 1174752, upload-time = "2026-06-11T12:49:41.43Z" }, + { url = "https://files.pythonhosted.org/packages/18/76/14ff87090199a36f914388299a1148d0734a20cea1b0ca8480bae1f373f1/grpcio_tools-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:8161f398f957a376cae7385ea7c8684f439d460ef702b528912da3bcb31fc515", size = 2586251, upload-time = "2026-06-11T12:49:43.514Z" }, + { url = "https://files.pythonhosted.org/packages/87/a8/d5aa99de9d8b2dd2a8192c1779796eda8b0d0f1dd915422e0a8a61b80391/grpcio_tools-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:53ef76cc3b0493ff734a5e8c39d5b519e1822236fcccdfe7677c5e1efd767761", size = 5818063, upload-time = "2026-06-11T12:49:45.975Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/2e9a6dbc6a514dd3cd264fb3bf9217937453a4d45dbc3ca6ca4ee34ba1a7/grpcio_tools-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:690e6dcaa8b8a7886ce206ba344e2127211597e1a1ddab73df9f3d80c8f6707e", size = 2634061, upload-time = "2026-06-11T12:49:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/2ccd1a929e6c8ad84a0aa8d66ad9f615b4a8e79d9927373d86aa36b4ba2e/grpcio_tools-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ad7a997c07bd345e84842e60561e7e2cc090ce6c4e1d2f0407e31b85b40fc49a", size = 2958029, upload-time = "2026-06-11T12:49:50.466Z" }, + { url = "https://files.pythonhosted.org/packages/e7/67/2da8cd312edc348f44f26f82096b25cdb7d2905cd786acc6bf777b169502/grpcio_tools-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b6bd163ece4535726e5292b845ed80ae9b2cae73ba091c7d6c66033c430e3857", size = 2698031, upload-time = "2026-06-11T12:49:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ba/ad1680fbdf9317c4f1e54c37c96d1f422370df66ac9adbd175c7cb3531d7/grpcio_tools-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2baa7e735f35b2a648144c03348a126097b13e101d3c242d5edb6ac91437ccbe", size = 3147541, upload-time = "2026-06-11T12:49:54.43Z" }, + { url = "https://files.pythonhosted.org/packages/57/c1/57cd08eef293d713cb8935295e4f08d8f0013480b2ba3aad1af0271eb7ba/grpcio_tools-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d602b410b2b2addc434cace9ce4fe2035974a3078228f98ffa049a5c90acc2f", size = 3708524, upload-time = "2026-06-11T12:49:56.544Z" }, + { url = "https://files.pythonhosted.org/packages/52/31/01ea8ca9c82fe2c79b5b594c3ae427d56699bc106b2d91caca129add8b10/grpcio_tools-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f8cb64f87c45ccca8234fa47e6b21f09e43801ff11b556deecb461b3b3e9f292", size = 3367022, upload-time = "2026-06-11T12:49:59.608Z" }, + { url = "https://files.pythonhosted.org/packages/7d/35/8140cd175602df3d17215cfb28a7ea55b7a67e2b872be76e1ee4af5c4df9/grpcio_tools-1.81.1-cp311-cp311-win32.whl", hash = "sha256:87b25ca0e27373a4a32a629a4ba976f5764b9887dd50d6fe017d38009a0363e8", size = 1008980, upload-time = "2026-06-11T12:50:01.422Z" }, + { url = "https://files.pythonhosted.org/packages/be/86/1bd29ab3c52457702b96536f1f208ab27695322d855f95c9666dfb713019/grpcio_tools-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:204de03b539a4b08772c6553b92bcc112cbc965e0ac22f909f6d133b8ac33a8c", size = 1174840, upload-time = "2026-06-11T12:50:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8a/824a9ca20bcdce8a568bb8c9f98bfeb7fad62129235e6d2ae7576fd1250a/grpcio_tools-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:353b1fafcc739c31ed42271052709595b340d34f27c459beeb78a32938305bb5", size = 2585927, upload-time = "2026-06-11T12:50:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/2f/35/e5f9f671378b1b89a896150d3e4fa2c6ec61a5e1e9e5107ce4c140ccc931/grpcio_tools-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:768f584c2423cbeb6cb6867817a39365b987ff16b8259a3adbc6546b9e303a4e", size = 5815665, upload-time = "2026-06-11T12:50:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/c6/02/631b628e4072e988c669bd8f1b2406ef3c9a4cfcb2625bbf2a308a07b71d/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1680b35a84f4694401819ac4acac42dda6dbc7bb8fc74112fd1a60425a07adf4", size = 2635518, upload-time = "2026-06-11T12:50:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/de/7c/2e3537e3ea3d1c0ddd6766cf6a7c62b487d89fb005713df2781d5f21483a/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f64e665c8ec639278ecf009beb92cbdcc5994f617c1af3d58036e1f70b1423ec", size = 2958252, upload-time = "2026-06-11T12:50:12.677Z" }, + { url = "https://files.pythonhosted.org/packages/35/68/14013cb2942bdac354746b643b4c37dd91906da8dce00f41c616e88bf33d/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f1ae82ad199f43448995715445cc623fb20d3882382e4be61f0da8ccb3f0e", size = 2698439, upload-time = "2026-06-11T12:50:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/000c14c0338a7ad36054b9f17ea41842deb7841c05c067dd36cc831bc0f4/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7b6d1e986d5923751bfe2b5cca9c4cb3d5653446e4fa4aacd438033e2dc360a", size = 3152160, upload-time = "2026-06-11T12:50:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/41/97/881930ca3967d2c8a95649bea8ebc991a7cf2331bc96679fd3600450dccc/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f208c207aca639dcb34648d3826c38d7cf3485118fb2065117e9fc4827406b3", size = 3710468, upload-time = "2026-06-11T12:50:19.479Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b5/67baeba7366162652cdc1dbd962289accde07241bc8f42f6f02b305efcc6/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724ecb69af63d2f6d4ccea3e6fa0ca110ed9c5824d48c2f887c631bbb03c1c3c", size = 3370797, upload-time = "2026-06-11T12:50:21.501Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5d/34f2dce2125ccb107e32b57f5a9c1257edcc0793b0d2fef1e8b13a6bac3c/grpcio_tools-1.81.1-cp312-cp312-win32.whl", hash = "sha256:895a6782cec86beac71ccebb4b9848259c6f04a3028b8e42fa8d40cfe5146593", size = 1008453, upload-time = "2026-06-11T12:50:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/09da8256ec8d2a5ce8a1acc51cbbc4ca52a462d78ed3412778440a56502e/grpcio_tools-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:0265fd1386b7458302f79542558345880d484f8fa92ae196c0c0268242c5f23a", size = 1174857, upload-time = "2026-06-11T12:50:25.685Z" }, + { url = "https://files.pythonhosted.org/packages/76/90/5faa8b26e03495e5117f93bef8293cbada4af136362745dad7d1813ef0b0/grpcio_tools-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3d604b4fd114b79ebb9f865bf3e04fd3ae93c704e1fad96f7fd03b0865c263b7", size = 2586071, upload-time = "2026-06-11T12:50:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/e8/9a/85dc589fa6ae2439451eaa81a1578de31e29c676980d38bef7549b8a1f45/grpcio_tools-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3389e705460efa3f3758141ba5520e6743b131c9576197c944fb9cbe49048126", size = 5813299, upload-time = "2026-06-11T12:50:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/77/fd/c53994e58a837e6eefe48f53eb3492afc04f2b8af255df4adb37d14378f8/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8a17d8ceeb6a855fadf39f5171c80a382d97c4db98d5943eca553497fdebf84b", size = 2634668, upload-time = "2026-06-11T12:50:33.938Z" }, + { url = "https://files.pythonhosted.org/packages/34/32/de988e86688686a2117e7ce6ce9eff4f638c929bb55b0afe60d6fbd2e45c/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:43baf71dc60fd653062da2e95e95c73b35dd130be8f9fa3d544c3af3f808a290", size = 2957930, upload-time = "2026-06-11T12:50:36.726Z" }, + { url = "https://files.pythonhosted.org/packages/72/97/3f18a0ea32b5f809d21961dbd0bc382b589a4c3d501e3d67c345d5456ed3/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136e90906af0df51ad929713244ba812d0dbb1844b4f467d5d86bdb054698f90", size = 2697760, upload-time = "2026-06-11T12:50:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/49/c0/dbf5cbc877290ff7504a59959a8af4fdcfdaa1e84237948405ccf1aa82a6/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd6c3bf3ea6a61eb58c54368d72ada591f2a270f3a31a32e8536e773337e76d9", size = 3151456, upload-time = "2026-06-11T12:50:41.983Z" }, + { url = "https://files.pythonhosted.org/packages/de/ea/16fe2dc83140a59e5c0a0b9dc2693dd36bfaa6bd835724b4ec66a68eab7b/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c306c307f8f74cddc4056fdbb6f1da55de087a21120efbd02bd915daa5a52fd", size = 3710469, upload-time = "2026-06-11T12:50:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/22/7d/df987d7d81e7ad2f7516d9e9d56ff29c54dbc6d8587e425688dca9a28e49/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bdbdc927be2e0ea13c32564a72ee31d712a716fb6f8c0d53d37a77d8277c272c", size = 3370488, upload-time = "2026-06-11T12:50:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c5/5a63444d694ea47bf670138208f71830cc1759c402c8818092b28ab2dc5f/grpcio_tools-1.81.1-cp313-cp313-win32.whl", hash = "sha256:9d383724bcd67244b6def9e9164c640ee9380c0b7534ee7545a6fb0022a59afe", size = 1008229, upload-time = "2026-06-11T12:50:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/00/75/3945e26d5c94ae6ed9be5caef73d4d66c47dc8cfdd7b4995efaf942754e0/grpcio_tools-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:f3eb15849979ca7bb864ce81a74d68b0f225a7f111ed3fe212bfc08cf9812b10", size = 1174523, upload-time = "2026-06-11T12:50:51.755Z" }, + { url = "https://files.pythonhosted.org/packages/0d/08/e581ad42ae517a61172285047e4d710e2ac75f2f1915f7c91f284254e6d5/grpcio_tools-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:7d168ea26390717d0462c0d0408331dc98a60fc7f7e6118afac9b73f5a66d87c", size = 2585944, upload-time = "2026-06-11T12:50:54.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/c8/200d90ebad685af7eea5ff7e0360c504dd01ec053fe0f1f9c4abe3ea2d5a/grpcio_tools-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:43c528655b226375013036692d8db4cd59060c1f41dd62c77f4d17b69f6ce828", size = 5813492, upload-time = "2026-06-11T12:50:57.291Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/60da2a1af37aa8eb47308cec24d9f7709a8976fdec3a53fd35b56b358326/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9c6fcc68c9d5a208967bfe4fd3224d3c3be9a950c3e827e8f4b17e15c2dc555", size = 2634991, upload-time = "2026-06-11T12:50:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7f/dede28b579ae9bf9079ba1aa913e8088d1dc0cdbe21c85caa22f0790cad2/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a987c85dcbe1b32066d7acd46266d1a428aecbd629331bf5b853e74c835bf876", size = 2957913, upload-time = "2026-06-11T12:51:02.31Z" }, + { url = "https://files.pythonhosted.org/packages/4c/38/4de2118adb58ec7ffba65ec623b5836db769665c192517cbf187db3f6145/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a882382507bb5ec6d7edc9648053dfd3bc8f9285cde56a6fa9b9a83b4bd07f1c", size = 2697709, upload-time = "2026-06-11T12:51:05.016Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e1/762ced51059e4f694fd337ecae491581d42a4e61dcb0415d8c5c60e6ddcb/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7746e508d4239a02f7e93638be5bc0ebb0120ddb796f7506aaae9d47a4599d97", size = 3151884, upload-time = "2026-06-11T12:51:07.593Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/9823090dc801e7229944874e7429c3b98e741ac778d8dc373f60240e1c43/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3d2a41a7a4467fa03b391394fffada9291fe8feebc8679b526f6bc36942b25", size = 3710404, upload-time = "2026-06-11T12:51:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/64/4e/4eae98d02148cb6f9f452f09942afba407afa6851e6c1fddc5ae9ec0b4ed/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:21bb3ba90e6d8df1ff663d4ee39a4e5b25a64e8ed4902476ca9ded0954d3917a", size = 3370525, upload-time = "2026-06-11T12:51:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3e/2206e597a128da6a03a6106d2eaf2c3e72c7d80843d4be933e3a3d10d02a/grpcio_tools-1.81.1-cp314-cp314-win32.whl", hash = "sha256:3dca56016d90a710c4d9861bae793dc089c1430a90c79ce672e948ddb65fa539", size = 1030582, upload-time = "2026-06-11T12:51:14.906Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f2/bbeef86c687225b7bbc7c0acdfbd25c8bcaa3f5b1c941db053e5c3d9e859/grpcio_tools-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:cb08172b7b629e75cb33866928d319a3196540a725eaab628ba721007140f1af", size = 1207490, upload-time = "2026-06-11T12:51:17.598Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1577,6 +1767,47 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "mcp" }] +[[package]] +name = "mcp-transport-examples" +version = "0.1.0" +source = { editable = "examples/transports" } +dependencies = [ + { name = "grpcio" }, + { name = "mcp" }, + { name = "protobuf" }, +] + +[package.dev-dependencies] +dev = [ + { name = "cassetter", extra = ["grpc"] }, + { name = "coverage", extra = ["toml"] }, + { name = "cryptography" }, + { name = "grpcio-tools" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-protobuf" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.71" }, + { name = "mcp" }, + { name = "protobuf", specifier = ">=6.33.5" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "cassetter", extras = ["grpc"], specifier = ">=0.11.0" }, + { name = "coverage", extras = ["toml"], specifier = ">=7.10.7" }, + { name = "cryptography", specifier = ">=50.0.0" }, + { name = "grpcio-tools", specifier = "==1.81.1" }, + { name = "pyright", specifier = ">=1.1.400" }, + { name = "pytest", specifier = ">=8.4.0" }, + { name = "ruff", specifier = ">=0.8.5" }, + { name = "types-protobuf", specifier = ">=7.35.1.20260906" }, +] + [[package]] name = "mcp-types" source = { editable = "src/mcp-types" } @@ -2602,6 +2833,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, ] +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + [[package]] name = "shellingham" version = "1.5.4" @@ -2779,6 +3019,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, ] +[[package]] +name = "types-protobuf" +version = "7.35.1.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/6c/e3e5b3e10bc328126a39637c138f9ebfd734bf14342b9f3540039b4ab995/types_protobuf-7.35.1.20260906.tar.gz", hash = "sha256:efd1a3862d4c967dad5512ef8d56b1530ac84f182c41735b94004756518c4998", size = 69895, upload-time = "2026-09-06T06:35:28.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/4e/f63e826c68f77ef875506d72f225918800346545ee99847bc28f3394f18d/types_protobuf-7.35.1.20260906-py3-none-any.whl", hash = "sha256:5155e48569e0dabff303fdf578db96cd31ea9a4a63b18018a4ceac6b0ae17462", size = 86419, upload-time = "2026-09-06T06:35:27.247Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"