"""MCP stub server mock for M2 actor + tool source smoke tests. Simulates MCP tool discovery and invocation in-process without requiring a real MCP adapter. Returns deterministic tool descriptors and execution results for Behave-level testing. This mock lives in ``features/mocks/`` per ADR-022 (no mocks in src/). """ from __future__ import annotations from typing import Any, ClassVar class McpStubTool: """Descriptor for a single tool exposed by the MCP stub server.""" def __init__( self, name: str, description: str, input_schema: dict[str, Any] | None = None, output_schema: dict[str, Any] | None = None, ) -> None: self.name = name self.description = description self.input_schema = input_schema or { "type": "object", "properties": {"query": {"type": "string"}}, } self.output_schema = output_schema or { "type": "object", "properties": {"result": {"type": "string"}}, } class McpStubServer: """In-process MCP stub server for deterministic testing. Provides ``discover()`` and ``invoke()`` methods that mirror the future MCP adapter interface (#159) without requiring network I/O. Usage:: server = McpStubServer() server.start() tools = server.discover() result = server.invoke("mcp/search", {"query": "hello"}) server.stop() """ #: Default tools returned by ``discover()``. DEFAULT_TOOLS: ClassVar[list[McpStubTool]] = [ McpStubTool( name="mcp/search", description="Stub MCP search tool", input_schema={ "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, output_schema={ "type": "object", "properties": { "results": {"type": "array", "items": {"type": "string"}} }, }, ), McpStubTool( name="mcp/fetch", description="Stub MCP fetch tool", input_schema={ "type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"], }, output_schema={ "type": "object", "properties": { "content": {"type": "string"}, "status": {"type": "integer"}, }, }, ), McpStubTool( name="mcp/transform", description="Stub MCP transform tool", input_schema={ "type": "object", "properties": { "data": {"type": "string"}, "format": {"type": "string"}, }, "required": ["data"], }, output_schema={ "type": "object", "properties": {"transformed": {"type": "string"}}, }, ), ] def __init__(self, tools: list[McpStubTool] | None = None) -> None: self._tools = tools or list(self.DEFAULT_TOOLS) self._running = False self._invocation_log: list[dict[str, Any]] = [] @property def is_running(self) -> bool: """Whether the stub server is currently started.""" return self._running @property def invocation_log(self) -> list[dict[str, Any]]: """Log of all invocations since the last ``start()``.""" return list(self._invocation_log) def start(self) -> None: """Start the stub server (in-process, no network).""" self._running = True self._invocation_log = [] def stop(self) -> None: """Stop the stub server.""" self._running = False def discover(self) -> list[McpStubTool]: """Return the list of available tools. Raises: RuntimeError: If the server is not running. """ if not self._running: msg = "McpStubServer is not running. Call start() first." raise RuntimeError(msg) return list(self._tools) def invoke(self, tool_name: str, params: dict[str, Any]) -> dict[str, Any]: """Invoke a stub tool by name with deterministic results. Raises: RuntimeError: If the server is not running. KeyError: If the tool name is not found. """ if not self._running: msg = "McpStubServer is not running. Call start() first." raise RuntimeError(msg) tool = self._find_tool(tool_name) if tool is None: msg = f"Tool '{tool_name}' not found in MCP stub server." raise KeyError(msg) # Generate deterministic result based on tool name result = self._generate_result(tool, params) self._invocation_log.append( { "tool_name": tool_name, "params": params, "result": result, } ) return result def _find_tool(self, name: str) -> McpStubTool | None: """Find a tool by name.""" for tool in self._tools: if tool.name == name: return tool return None def _generate_result( self, tool: McpStubTool, params: dict[str, Any] ) -> dict[str, Any]: """Generate a deterministic result for a tool invocation.""" if tool.name == "mcp/search": query = params.get("query", "") return { "results": [f"result_1_for_{query}", f"result_2_for_{query}"], } if tool.name == "mcp/fetch": url = params.get("url", "") return { "content": f"stub content for {url}", "status": 200, } if tool.name == "mcp/transform": data = params.get("data", "") fmt = params.get("format", "upper") transformed = data.upper() if fmt == "upper" else data.lower() return {"transformed": transformed} # Generic fallback return {"result": f"stub result from {tool.name}"}