Skip to content

tests/core/test_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 """McpEngine tests (Macro 4 Phase 7).16 17 Drives ``dispatch`` directly over a small plugged router (pydantic + channel +18 auth): initialize version negotiation, tools/list descriptors read from the19 neutral cached blocks, tools/call with both handler natures and both20 bad-argument escape paths (isError results, never JSON-RPC errors), the21 ``node.error`` string-code mapping and the message-shape rejections22 (batching, unknown method).23 """24 25 from __future__ import annotations26 27 import inspect28 import json29 from typing import Any30 31 import pytest32 from genro_routes import RoutingClass, route33 34 from genro_asgi import McpEngine, McpError35 from genro_asgi.mcp import (36     JSONRPC_INTERNAL_ERROR,37     JSONRPC_INVALID_REQUEST,38     JSONRPC_METHOD_NOT_FOUND,39     JSONRPC_NOT_AUTHORIZED,40 )41 42 43 class SubTools(RoutingClass):44     """Nested service proving the tool-name separator."""45 46     @route(channel_channels="mcp")47     def ping(self) -> dict:48         """Ping."""49         return {"ok": True}50 51 52 class ToolService(RoutingClass):53     """MCP-facing service: pydantic + channel + auth plugged."""54 55     def __init__(self) -> None:56         self.route.plug("pydantic")57         self.route.plug("channel")58         self.route.plug("auth")59         self.route.add_branches({"name": "sub", "instance": SubTools()})60 61     @route(channel_channels="mcp,rest")62     def add(self, x: int, y: int = 0) -> dict:63         """Add two numbers."""64         return {"sum": x + y}65 66     @route(channel_channels="mcp")67     async def greet(self, name: str) -> str:68         """Greet someone."""69         return f"hello {name}"70 71     @route(channel_channels="rest")72     def rest_only(self) -> dict:73         """REST-only endpoint, invisible on the mcp channel."""74         return {"rest": True}75 76     @route(channel_channels="mcp", auth_rule="admin")77     def secret(self) -> dict:78         """Admin-only tool."""79         return {"secret": True}80 81 82 @pytest.fixture83 def engine() -> McpEngine:84     return McpEngine(ToolService().route, name="test-server", version="9.9.9")85 86 87 async def call_tool(engine: McpEngine, name: str, arguments: dict, auth_tags: Any = None) -> dict:88     payload = {"method": "tools/call", "params": {"name": name, "arguments": arguments}}89     return await engine.dispatch(payload, auth_tags)90 91 92 class TestInitialize:93     async def test_echoes_a_supported_requested_version(self, engine: McpEngine) -> None:94         result = await engine.dispatch(95             {"method": "initialize", "params": {"protocolVersion": "2025-06-18"}}96         )97         assert result["protocolVersion"] == "2025-06-18"98 99     async def test_unknown_version_answers_latest(self, engine: McpEngine) -> None:100         result = await engine.dispatch(101             {"method": "initialize", "params": {"protocolVersion": "1999-01-01"}}102         )103         assert result["protocolVersion"] == "2025-11-25"104 105     async def test_missing_version_answers_latest(self, engine: McpEngine) -> None:106         result = await engine.dispatch({"method": "initialize"})107         assert result["protocolVersion"] == "2025-11-25"108 109     async def test_capabilities_and_server_info_shape(self, engine: McpEngine) -> None:110         result = await engine.dispatch({"method": "initialize", "params": {}})111         assert result["capabilities"] == {"tools": {}, "experimental": {"push": {}}}112         assert result["serverInfo"] == {"name": "test-server", "version": "9.9.9"}113 114 115 class TestToolsList:116     async def test_lists_only_the_mcp_channel_tools(self, engine: McpEngine) -> None:117         result = await engine.dispatch({"method": "tools/list"})118         names = {tool["name"] for tool in result["tools"]}119         # rest_only is off-channel; secret is auth-ruled and the anonymous120         # walk carries no tags, so the auth plugin denies it.121         assert names == {"add", "greet", "sub.ping"}122 123     async def test_nested_tool_name_uses_the_separator(self, engine: McpEngine) -> None:124         result = await engine.dispatch({"method": "tools/list"})125         by_name = {tool["name"]: tool for tool in result["tools"]}126         assert "sub.ping" in by_name127 128     async def test_input_schema_from_the_cached_request_schema(self, engine: McpEngine) -> None:129         result = await engine.dispatch({"method": "tools/list"})130         schema = {tool["name"]: tool for tool in result["tools"]}["add"]["inputSchema"]131         assert schema["type"] == "object"132         assert schema["properties"]["x"]["type"] == "integer"133         assert schema["required"] == ["x"]134 135     async def test_output_schema_from_the_cached_response_schema(self, engine: McpEngine) -> None:136         result = await engine.dispatch({"method": "tools/list"})137         by_name = {tool["name"]: tool for tool in result["tools"]}138         assert by_name["add"]["outputSchema"]["type"] == "object"139         assert by_name["greet"]["outputSchema"] == {"type": "string"}140 141     async def test_description_comes_from_the_docstring(self, engine: McpEngine) -> None:142         result = await engine.dispatch({"method": "tools/list"})143         by_name = {tool["name"]: tool for tool in result["tools"]}144         assert by_name["add"]["description"] == "Add two numbers."145 146     async def test_engine_without_router_lists_nothing(self) -> None:147         result = await McpEngine().dispatch({"method": "tools/list"})148         assert result == {"tools": []}149 150     def test_input_schema_fallback_assembles_from_fields(self, engine: McpEngine) -> None:151         info = {152             "params": {153                 "schema": None,154                 "fields": [155                     {"name": "x", "schema": {"type": "integer"}, "required": True, "kind": "pk"},156                     {"name": "y", "schema": {"type": "string"}, "required": False, "kind": "pk"},157                     {"name": "kwargs", "schema": None, "required": False, "kind": "var_keyword"},158                 ],159             }160         }161         schema = engine.mcp_dispatcher.tools._input_schema(info)162         assert schema == {163             "type": "object",164             "properties": {"x": {"type": "integer"}, "y": {"type": "string"}},165             "required": ["x"],166         }167 168     def test_input_schema_without_params_block_is_empty_object(self, engine: McpEngine) -> None:169         assert engine.mcp_dispatcher.tools._input_schema({}) == {"type": "object", "properties": {}}170 171 172 class TestToolsCall:173     async def test_sync_handler_dict_result_is_structured_and_text(174         self, engine: McpEngine175     ) -> None:176         result = await call_tool(engine, "add", {"x": 2, "y": 3})177         assert result["structuredContent"] == {"sum": 5}178         assert json.loads(result["content"][0]["text"]) == {"sum": 5}179         assert result["content"][0]["type"] == "text"180         assert "isError" not in result181 182     async def test_async_handler_scalar_result_is_text_only(self, engine: McpEngine) -> None:183         result = await call_tool(engine, "greet", {"name": "bob"})184         assert result["content"] == [{"type": "text", "text": "hello bob"}]185         assert "structuredContent" not in result186 187     async def test_nested_tool_resolves_through_the_separator(self, engine: McpEngine) -> None:188         result = await call_tool(engine, "sub.ping", {})189         assert result["structuredContent"] == {"ok": True}190 191     async def test_custom_async_invoke_callback(self) -> None:192         seen: list[dict] = []193 194         async def invoke(node: Any, arguments: dict) -> Any:195             seen.append(arguments)196             result = node(**arguments)197             if inspect.isawaitable(result):198                 result = await result199             return result200 201         engine = McpEngine(ToolService().route, invoke=invoke)202         result = await call_tool(engine, "greet", {"name": "eve"})203         assert result["content"][0]["text"] == "hello eve"204         assert seen == [{"name": "eve"}]205 206     async def test_invalid_annotated_argument_is_a_tool_error(self, engine: McpEngine) -> None:207         # pydantic.ValidationError escape path -> isError result, no JSON-RPC error.208         result = await call_tool(engine, "add", {"x": "not-a-number"})209         assert result["isError"] is True210         assert "Invalid tool arguments" in result["content"][0]["text"]211 212     async def test_unknown_extra_argument_is_a_tool_error(self, engine: McpEngine) -> None:213         # sig.bind TypeError escape path -> isError result, no JSON-RPC error.214         result = await call_tool(engine, "add", {"x": 1, "z": 9})215         assert result["isError"] is True216         assert "Invalid tool arguments" in result["content"][0]["text"]217 218     async def test_anonymous_call_of_ruled_tool_is_not_authorized(219         self, engine: McpEngine220     ) -> None:221         with pytest.raises(McpError) as excinfo:222             await call_tool(engine, "secret", {})223         assert excinfo.value.code == JSONRPC_NOT_AUTHORIZED224 225     async def test_wrong_tags_call_of_ruled_tool_is_not_authorized(226         self, engine: McpEngine227     ) -> None:228         with pytest.raises(McpError) as excinfo:229             await call_tool(engine, "secret", {}, auth_tags="user")230         assert excinfo.value.code == JSONRPC_NOT_AUTHORIZED231 232     async def test_matching_tags_call_of_ruled_tool_succeeds(self, engine: McpEngine) -> None:233         result = await call_tool(engine, "secret", {}, auth_tags=["admin", "user"])234         assert result["structuredContent"] == {"secret": True}235 236     async def test_unknown_tool_is_method_not_found(self, engine: McpEngine) -> None:237         with pytest.raises(McpError) as excinfo:238             await call_tool(engine, "nope", {})239         assert excinfo.value.code == JSONRPC_METHOD_NOT_FOUND240 241     async def test_off_channel_tool_is_method_not_found(self, engine: McpEngine) -> None:242         # not_available (channel mismatch) maps to unknown tool, same as not_found.243         with pytest.raises(McpError) as excinfo:244             await call_tool(engine, "rest_only", {})245         assert excinfo.value.code == JSONRPC_METHOD_NOT_FOUND246 247     async def test_engine_without_router_is_internal_error(self) -> None:248         with pytest.raises(McpError) as excinfo:249             await call_tool(McpEngine(), "add", {"x": 1})250         assert excinfo.value.code == JSONRPC_INTERNAL_ERROR251 252 253 class TestPing:254     async def test_ping_returns_empty_result(self, engine: McpEngine) -> None:255         # MCP 2025-11-25: servers MUST answer ping promptly with an empty result.256         assert await engine.dispatch({"method": "ping"}) == {}257 258 259 class TestMessageShape:260     async def test_unknown_method_is_method_not_found(self, engine: McpEngine) -> None:261         with pytest.raises(McpError) as excinfo:262             await engine.dispatch({"method": "resources/list"})263         assert excinfo.value.code == JSONRPC_METHOD_NOT_FOUND264 265     async def test_list_payload_is_rejected(self, engine: McpEngine) -> None:266         # JSON-RPC batching (added 2025-03-26, removed 2025-06-18) is not supported.267         with pytest.raises(McpError) as excinfo:268             await engine.dispatch([{"method": "tools/list"}])269         assert excinfo.value.code == JSONRPC_INVALID_REQUEST270 271     async def test_non_object_payload_is_rejected(self, engine: McpEngine) -> None:272         with pytest.raises(McpError) as excinfo:273             await engine.dispatch("tools/list")274         assert excinfo.value.code == JSONRPC_INVALID_REQUEST275 276     async def test_missing_method_is_rejected(self, engine: McpEngine) -> None:277         with pytest.raises(McpError) as excinfo:278             await engine.dispatch({"params": {}})279         assert excinfo.value.code == JSONRPC_INVALID_REQUEST280 281     async def test_non_object_params_are_rejected(self, engine: McpEngine) -> None:282         with pytest.raises(McpError) as excinfo:283             await engine.dispatch({"method": "tools/list", "params": []})284         assert excinfo.value.code == JSONRPC_INVALID_REQUEST285 286 287 class TestMcpError:288     def test_carries_code_and_message(self) -> None:289         error = McpError(JSONRPC_INTERNAL_ERROR, "boom")290         assert error.code == JSONRPC_INTERNAL_ERROR291         assert error.message == "boom"292         assert str(error) == "boom"