Files
cleveragents-core/features/mocks/mcp_stub_server.py
brent.edwards 093a74953f
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 16s
CI / lint (pull_request) Successful in 21s
CI / quality (pull_request) Successful in 27s
CI / security (pull_request) Successful in 56s
CI / typecheck (pull_request) Successful in 1m2s
CI / coverage (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
test(e2e): add M2 actor + tool source smoke suite
Add comprehensive E2E test suite for M2 (Actor Graphs + Tool Sources) epic:

- Behave BDD: 10 scenarios covering actor YAML loading, skill registry,
  tool lifecycle (discover/activate/execute/deactivate), and MCP stub
- Robot Framework: 6 integration tests via CLI helper script
- ASV benchmarks: 12 benchmarks for actor loading, skill registry,
  tool lifecycle, and MCP stub performance baselines
- MCP stub server mock: in-process fake with 3 tools (search/fetch/transform)
- Fixtures: hierarchical graph actor YAML + skill pack with tool refs and
  inline tools
- Docs: updated testing.md with M2 smoke suite section

Closes #169
2026-02-24 21:55:24 +00:00

195 lines
6.1 KiB
Python

"""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"<html>stub content for {url}</html>",
"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}"}