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