Skip to content

src/genro_asgi/mcp/engine.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 16 """McpEngine — MCP (JSON-RPC 2.0) core over a genro-routes Router.17 18 The engine turns a Router's ``@route`` entries into MCP tools and serves the19 protocol methods. It is transport- and app-agnostic: it holds a Router and a20 channel to filter on and never touches HTTP concerns (headers, Origin,21 202-for-notifications belong to the host application). ``dispatch`` receives22 the parsed JSON-RPC message, validates the envelope, and resolves ``method``23 on a genro-routes tree of its own — :class:`McpDispatcher`, held as24 ``mcp_dispatcher`` — the same machinery the lane and the HTTP side use: no25 chain of ``if`` on the method name. A method nobody serves reads26 ``node.error`` (the stable genro-routes contract — resolution never raises)27 and becomes -32601 THERE, in one place. What ``dispatch`` returns is the28 RESULT object — envelope bookkeeping (``id``, ``jsonrpc``) stays with the29 transport; protocol failures raise :class:`McpError` carrying the JSON-RPC30 code for the transport to render. A list payload is rejected with -32600:31 JSON-RPC batching entered the MCP spec in 2025-03-26 and was removed in32 2025-06-18.33 34 The tree (protocol 2025-11-25, the current revision), every route taking the35 protocol signature ``(params, auth_tags)``:36 37 - ``ping`` answers an empty result (spec MUST).38 - ``initialize`` negotiates the version: the client's requested version is39   echoed when it appears in ``SUPPORTED_VERSIONS``, anything else is answered40   with the latest supported revision.41 - ``tools`` is a branch, :class:`~genro_asgi.mcp.tools.McpTools`: ``list``42   and ``call`` with everything that builds their answers. Each further family43   of the protocol (``prompts``, ``resources``, ``server``) is a branch of its44   own, a class of its own, attached the same way.45 """46 47 from __future__ import annotations48 49 import inspect50 from collections.abc import Callable51 from typing import TYPE_CHECKING, Any52 53 from genro_routes import RoutingClass, route54 55 from .jsonrpc import JSONRPC_INVALID_REQUEST, JSONRPC_METHOD_NOT_FOUND, McpError56 from .tools import McpTools57 58 if TYPE_CHECKING:59     from genro_routes import Router, RouterNode60 61 __all__ = ["McpDispatcher", "McpEngine"]62 63 64 class McpDispatcher(RoutingClass):65     """The root of the methods the engine serves: ``ping``, ``initialize``, ``tools/…``.66 67     The tree is the table: ``route.nodes()`` lists what the engine answers68     without a message being dispatched. The branches are kept as attributes so69     a host can attach its own family beside them.70 71     Args:72         engine: the engine whose identity and versions ``initialize`` answers.73     """74 75     def __init__(self, engine: McpEngine) -> None:76         self.engine = engine77         self.tools = McpTools(engine)78         self.add_branches([{"name": "tools", "instance": self.tools}])79 80     @route()81     def ping(self, params: dict, auth_tags: Any = None) -> dict:82         """The empty result the spec requires."""83         return {}84 85     @route()86     def initialize(self, params: dict, auth_tags: Any = None) -> dict:87         """Negotiate the protocol version and return the server capabilities.88 89         The client's requested version is echoed when supported; any other90         request is answered with the latest supported revision (spec91         negotiation rule).92         """93         engine = self.engine94         requested = params.get("protocolVersion")95         version = requested if requested in engine.SUPPORTED_VERSIONS else engine.SUPPORTED_VERSIONS[0]96         return {97             "protocolVersion": version,98             # experimental.push: the host transport's SSE progress channel99             # (GET + Mcp-Session-Id); the engine itself stays transport-blind.100             "capabilities": {"tools": {}, "experimental": {"push": {}}},101             "serverInfo": {"name": engine.name, "version": engine.version},102         }103 104 105 class McpEngine:106     """MCP JSON-RPC core over a router.107 108     Args:109         router: The genro-routes Router whose entries are exposed as tools.110         name / version: server identity returned by ``initialize``.111         tool_separator: joins router/method segments into a flat tool name.112         channel: channel to filter entries on (visibility per channel).113         invoke: callback ``(node, arguments) -> result`` running a resolved114             node; ``tools/call`` awaits an awaitable result. Host applications115             pass their own to interpose parameter adaptation (e.g.116             ``spread_over_params``) and pool dispatch for sync handlers; the117             default calls the node directly.118     """119 120     SUPPORTED_VERSIONS: tuple[str, ...] = ("2025-11-25", "2025-06-18", "2025-03-26")121 122     def __init__(123         self,124         router: Router | None = None,125         *,126         name: str = "genro-mcp",127         version: str = "1.0.0",128         tool_separator: str = ".",129         channel: str = "mcp",130         invoke: Callable[[Any, dict], Any] | None = None,131     ) -> None:132         self.router = router133         self.name = name134         self.version = version135         self.tool_separator = tool_separator136         self.channel = channel137         self.invoke = invoke or self._default_invoke138         self.mcp_dispatcher = McpDispatcher(self)139 140     def _default_invoke(self, node: RouterNode, arguments: dict) -> Any:141         """Raw invocation, no parameter adaptation; ``tools/call`` awaits it."""142         return node(**arguments)143 144     async def dispatch(self, payload: Any, auth_tags: Any = None) -> dict:145         """Validate the envelope, resolve ``method`` on the tree, answer.146 147         Returns the JSON-RPC RESULT object; the transport owns the envelope.148 149         Raises:150             McpError: invalid message shape (-32600, batching included) or151                 unknown method (-32601); ``tools/call`` resolution failures152                 bubble up from :meth:`McpTools.call`.153         """154         if isinstance(payload, list):155             raise McpError(JSONRPC_INVALID_REQUEST, "JSON-RPC batching is not supported")156         if not isinstance(payload, dict):157             raise McpError(JSONRPC_INVALID_REQUEST, "Invalid JSON-RPC message")158         method = payload.get("method")159         if not isinstance(method, str):160             raise McpError(JSONRPC_INVALID_REQUEST, "Missing method")161         params = payload.get("params")162         if params is None:163             params = {}164         if not isinstance(params, dict):165             raise McpError(JSONRPC_INVALID_REQUEST, "params must be an object")166         node = self.mcp_dispatcher.route.node(method)167         if node.error:168             raise McpError(JSONRPC_METHOD_NOT_FOUND, f"Method not found: {method}")169         result = node(params, auth_tags)170         if inspect.isawaitable(result):171             result = await result172         return result