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"