From ee82d6101ef445996d50f2c8ba0fbe7340061cd5 Mon Sep 17 00:00:00 2001 From: Aditya Chhabra Date: Wed, 25 Feb 2026 13:17:26 +0000 Subject: [PATCH] feat(skill): add MCP adapter for external tools Implement MCPToolAdapter to connect to external MCP servers, enumerate tools, and register them in ToolRegistry with source="mcp". Includes connect/reconnect/disconnect lifecycle with timeout enforcement, input validation on invoke, capability inference, Behave/Robot/ASV tests, and docs/reference/mcp_adapter.md. ISSUES CLOSED: #159 --- benchmarks/mcp_runtime_bench.py | 153 ++++++++ docs/reference/mcp_adapter.md | 144 +++++++ features/mcp_adapter.feature | 281 ++++++++++++++ features/steps/mcp_adapter_steps.py | 583 ++++++++++++++++++++++++++++ robot/helper_mcp_adapter.py | 146 +++++++ robot/mcp_adapter.robot | 50 +++ src/cleveragents/mcp/__init__.py | 19 + src/cleveragents/mcp/adapter.py | 515 ++++++++++++++++++++++++ 8 files changed, 1891 insertions(+) create mode 100644 benchmarks/mcp_runtime_bench.py create mode 100644 docs/reference/mcp_adapter.md create mode 100644 features/mcp_adapter.feature create mode 100644 features/steps/mcp_adapter_steps.py create mode 100644 robot/helper_mcp_adapter.py create mode 100644 robot/mcp_adapter.robot create mode 100644 src/cleveragents/mcp/__init__.py create mode 100644 src/cleveragents/mcp/adapter.py diff --git a/benchmarks/mcp_runtime_bench.py b/benchmarks/mcp_runtime_bench.py new file mode 100644 index 000000000..385feaa4c --- /dev/null +++ b/benchmarks/mcp_runtime_bench.py @@ -0,0 +1,153 @@ +"""ASV benchmarks for MCP Tool Adapter performance. + +Measures the performance of: +- MCPServerConfig creation and validation +- Tool discovery from a mock transport +- Tool invocation with schema validation +- Tool registration into ToolRegistry +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from typing import Any + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.mcp.adapter import ( # noqa: E402 + MCPServerConfig, + MCPToolAdapter, + MCPTransport, +) +from cleveragents.tool.registry import ToolRegistry # noqa: E402 + + +class _BenchTransport(MCPTransport): + """Minimal transport for benchmarks — returns canned data.""" + + def __init__(self, tools: list[dict[str, Any]]) -> None: + self._tools = tools + + def connect(self, config: MCPServerConfig) -> dict[str, Any]: + return {"capabilities": {"tools": True}} + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "tools/list": + return {"tools": self._tools} + if method == "tools/call": + return {"content": {"status": "ok"}} + return {} + + def close(self) -> None: + pass + + +def _mock_tool(name: str, schema: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "name": name, + "description": f"Bench tool {name}", + "inputSchema": schema or {}, + } + + +_TOOLS_3 = [_mock_tool(f"tool_{i}") for i in range(3)] +_TOOLS_50 = [_mock_tool(f"tool_{i}") for i in range(50)] +_SCHEMA_TOOL = _mock_tool( + "validated", + { + "type": "object", + "properties": {"x": {"type": "integer"}, "y": {"type": "string"}}, + "required": ["x"], + }, +) + + +class TimeConfigCreation: + """Benchmark MCPServerConfig instantiation.""" + + def time_create_stdio_config(self) -> None: + MCPServerConfig(name="bench", transport="stdio", command="echo") + + def time_create_sse_config(self) -> None: + MCPServerConfig( + name="bench", + transport="sse", + url="https://example.com/mcp", + headers={"Authorization": "Bearer tok"}, + ) + + +class TimeToolDiscovery: + """Benchmark tool discovery with varying tool counts.""" + + def setup(self) -> None: + self._config = MCPServerConfig(name="bench", transport="stdio", command="echo") + + def time_discover_3_tools(self) -> None: + t = _BenchTransport(_TOOLS_3) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + adapter.discover_tools() + + def time_discover_50_tools(self) -> None: + t = _BenchTransport(_TOOLS_50) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + adapter.discover_tools() + + +class TimeToolInvocation: + """Benchmark tool invocation with and without schema validation.""" + + def setup(self) -> None: + config = MCPServerConfig(name="bench", transport="stdio", command="echo") + self._transport = _BenchTransport([_SCHEMA_TOOL]) + self._adapter = MCPToolAdapter(config=config, transport=self._transport) + self._adapter.connect() + self._adapter.discover_tools() + + def time_invoke_with_validation(self) -> None: + self._adapter.invoke("validated", {"x": 42, "y": "hello"}) + + def time_invoke_no_schema(self) -> None: + no_schema = _BenchTransport([_mock_tool("plain")]) + config = MCPServerConfig(name="bench", transport="stdio", command="echo") + adapter = MCPToolAdapter(config=config, transport=no_schema) + adapter.connect() + adapter.discover_tools() + adapter.invoke("plain", {"any": "data"}) + + +class TimeToolRegistration: + """Benchmark registering discovered tools into ToolRegistry.""" + + def setup(self) -> None: + self._config = MCPServerConfig(name="bench", transport="stdio", command="echo") + + def time_register_3_tools(self) -> None: + t = _BenchTransport(_TOOLS_3) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + registry = ToolRegistry() + adapter.register_tools(registry, namespace="bench") + + def time_register_50_tools(self) -> None: + t = _BenchTransport(_TOOLS_50) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + registry = ToolRegistry() + adapter.register_tools(registry, namespace="bench") + + +time_config = TimeConfigCreation() +time_disc = TimeToolDiscovery() +time_invoke = TimeToolInvocation() +time_reg = TimeToolRegistration() diff --git a/docs/reference/mcp_adapter.md b/docs/reference/mcp_adapter.md new file mode 100644 index 000000000..899939b63 --- /dev/null +++ b/docs/reference/mcp_adapter.md @@ -0,0 +1,144 @@ +# MCP Tool Adapter + +This document covers the MCP (Model Context Protocol) adapter introduced in +**M2.3.mcp-runtime**: connecting to external MCP servers, discovering tools, +invoking them with schema validation, and registering them in the ToolRegistry. + +## Overview + +The `MCPToolAdapter` bridges any MCP-compatible tool server into the +CleverAgents runtime. It manages the full lifecycle: + +| Operation | Method | Description | +|-----------|--------|-------------| +| Connect | `connect(timeout?)` | Start the server process or HTTP session, perform handshake | +| Disconnect | `disconnect()` | Clean shutdown of the server connection | +| Reconnect | `reconnect(timeout?)` | Disconnect and re-connect; clears cached tool list | +| Discover | `discover_tools(filter?)` | Enumerate available tools from the server | +| Invoke | `invoke(name, args)` | Call a tool with automatic input validation | +| Register | `register_tools(registry, namespace)` | Bulk-register into a ToolRegistry | + +## Server Configuration + +### `MCPServerConfig` Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | `string` | Yes | Server identifier | +| `transport` | `string` | Yes | `stdio`, `sse`, or `streamable-http` | +| `command` | `string` | stdio only | Command to spawn the server process | +| `args` | `list[string]` | No | Command-line arguments | +| `env` | `dict` | No | Environment variables for the server process | +| `url` | `string` | sse/http only | Server URL for remote connections | +| `headers` | `dict` | No | HTTP headers for remote connections | + +### Validation Rules + +- `stdio` transport **requires** `command` +- `sse` / `streamable-http` transport **requires** `url` + +## Tool Discovery + +After connecting, call `discover_tools()` to enumerate all tools exposed by +the MCP server. Each returned `MCPToolDescriptor` includes the tool name, +description, and JSON Schema for inputs. + +### Filtering + +Use `MCPToolFilter` with `include` or `exclude` lists to control which +tools are exposed. + +### `MCPToolDescriptor` Fields + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `string` | Tool name as exposed by the server | +| `description` | `string` | Human-readable description | +| `input_schema` | `dict` | JSON Schema for tool inputs | + +## Tool Invocation + +Call `invoke(name, arguments)` to execute a discovered tool. Input arguments +are validated against the tool's JSON Schema before the call is made. If +validation fails, the result is returned immediately with `success=False`. + +### `MCPToolResult` Fields + +| Field | Type | Description | +|-------|------|-------------| +| `success` | `bool` | Whether the invocation succeeded | +| `data` | `dict` | Result payload from the server | +| `error` | `string` or `null` | Error message on failure | +| `duration_ms` | `float` | Wall-clock execution time in milliseconds | + +## Reconnect + +Use `reconnect(timeout?)` to recover from a dropped connection or after a +server restart. It performs a clean `disconnect()` followed by a fresh +`connect()` and clears the cached tool list. Call `discover_tools()` again +after reconnecting. + +```python +try: + adapter.invoke("create_issue", {"title": "Bug"}) +except RuntimeError: + # Connection dropped — attempt recovery + adapter.reconnect(timeout=10.0) + adapter.discover_tools() +``` + +If the new connection attempt fails or times out, `reconnect()` raises +`ConnectionError`. + +## ToolRegistry Integration + +Discovered MCP tools can be bulk-registered into any `ToolRegistry` via +`register_tools(registry, namespace)`. Each tool is registered with the +name `{namespace}/{tool_name}`. Re-registration after tool list changes +replaces stale entries automatically. + +Registered tools always carry `source="mcp"` and +`source_metadata={"server": ""}` for provenance tracking. +`checkpointable` is always `False` for MCP tools — external server calls +cannot be rolled back transactionally. + +## Capability Inference + +MCP tools expose limited metadata (name, description, inputSchema). The adapter +automatically infers extended `ToolCapability` metadata using name-based heuristics: + +| Tool name keywords | Inferred capability | +|---|---| +| `read`, `get`, `list`, `search`, `find` | `read_only: true` | +| `write`, `create`, `update`, `delete`, `set` | `writes: true` | +| Both read and write keywords | `writes: true` (write takes precedence) | +| Neither | `read_only: false`, `writes: false` | + +Inferences are applied automatically when registering tools via +`register_tools()`. They can be overridden via the `overrides` block in +tool or skill YAML. + +Use `MCPToolAdapter.infer_capabilities(tool_name)` directly to check +what would be inferred for a given tool name. + +## Error Classification + +| Error Type | When | Exception / Field | +|------------|------|-------------------| +| Connection failure | `connect()` with unreachable server | `ConnectionError` | +| Not connected | `invoke()` / `discover_tools()` before `connect()` | `RuntimeError` | +| Tool not found | `invoke()` with unknown tool name | `result.error` contains "not found" | +| Validation failure | `invoke()` with invalid input | `result.error` contains "validation" | +| Timeout | Server call exceeds deadline | `result.error` contains "timeout" | +| Server error | Server returns `isError: true` | `result.error` contains "server error" | + +## Transport Layer + +The `MCPTransport` base class defines the abstract interface. Concrete +implementations handle stdio (child process) and HTTP (SSE / streamable-http) +protocols. For testing, provide a mock transport to the adapter constructor. + +## Thread Safety + +`MCPToolAdapter` uses `threading.RLock` internally. All public methods are +safe to call from multiple threads concurrently. diff --git a/features/mcp_adapter.feature b/features/mcp_adapter.feature new file mode 100644 index 000000000..85c522177 --- /dev/null +++ b/features/mcp_adapter.feature @@ -0,0 +1,281 @@ +Feature: MCP Tool Adapter + As an actor runtime + I want an adapter that connects to MCP servers, discovers tools, + and invokes them with schema validation + So that external MCP tools are usable like any registered tool + + # ------------------------------------------------------------------- + # Adapter Lifecycle + # ------------------------------------------------------------------- + + Scenario: Create adapter from server config + Given an MCP server config for "github" with stdio transport + When I create an MCP adapter from the config + Then the adapter server name should be "github" + And the adapter transport should be "stdio" + And the adapter should not be connected + + Scenario: Create adapter from SSE server config + Given an MCP server config for "remote-api" with sse transport and url "https://api.example.com/mcp" + When I create an MCP adapter from the config + Then the adapter server name should be "remote-api" + And the adapter transport should be "sse" + + Scenario: Connect adapter starts handshake + Given an MCP adapter with a mock transport + When I connect the adapter + Then the adapter should be connected + And the adapter server capabilities should be set + + Scenario: Disconnect adapter cleans up + Given a connected MCP adapter with a mock transport + When I disconnect the adapter + Then the adapter should not be connected + + Scenario: Double connect is idempotent + Given a connected MCP adapter with a mock transport + When I connect the adapter + Then the adapter should be connected + + Scenario: Disconnect when not connected is safe + Given an MCP adapter with a mock transport + When I disconnect the adapter + Then the adapter should not be connected + + # ------------------------------------------------------------------- + # Tool Discovery + # ------------------------------------------------------------------- + + Scenario: Discover tools from MCP server + Given a connected MCP adapter with 3 mock tools + When I discover tools from the adapter + Then the adapter should have 3 discovered tools + And discovered tool 0 should have a name + And discovered tool 0 should have an input schema + + Scenario: Discover tools with include filter + Given a connected MCP adapter with 3 mock tools + And a tool filter including only "tool_0" + When I discover tools from the adapter with filter + Then the adapter should have 1 discovered tools + + Scenario: Discover tools with exclude filter + Given a connected MCP adapter with 3 mock tools + And a tool filter excluding "tool_0" + When I discover tools from the adapter with filter + Then the adapter should have 2 discovered tools + + Scenario: Discover returns empty when server has no tools + Given a connected MCP adapter with 0 mock tools + When I discover tools from the adapter + Then the adapter should have 0 discovered tools + + Scenario: Discover when not connected raises error + Given an MCP adapter with a mock transport + When I discover tools expecting an error + Then the adapter error should mention "not connected" + + # ------------------------------------------------------------------- + # Tool Invocation + # ------------------------------------------------------------------- + + Scenario: Invoke a discovered tool successfully + Given a connected MCP adapter with a callable mock tool "create_issue" + When I invoke "create_issue" with arguments {"title": "Bug", "body": "Fix it"} + Then the invocation should succeed + And the invocation result should contain key "id" + + Scenario: Invoke with valid input schema passes validation + Given a connected MCP adapter with a schema-validated mock tool "create_issue" + When I invoke "create_issue" with arguments {"title": "Bug", "body": "Fix it"} + Then the invocation should succeed + + Scenario: Invoke with invalid input fails validation + Given a connected MCP adapter with a schema-validated mock tool "create_issue" + When I invoke "create_issue" with arguments {"wrong_field": 123} + Then the invocation should fail + And the invocation error should mention "validation" + + Scenario: Invoke unknown tool raises error + Given a connected MCP adapter with a callable mock tool "create_issue" + When I invoke "nonexistent_tool" with arguments {} + Then the invocation should fail + And the invocation error should mention "not found" + + Scenario: Invoke when not connected raises error + Given an MCP adapter with a mock transport + When I invoke MCP tool "create_issue" while disconnected + Then the adapter error should mention "not connected" + + Scenario: Invoke tool that returns error from server + Given a connected MCP adapter with a failing mock tool "bad_tool" + When I invoke "bad_tool" with arguments {} + Then the invocation should fail + And the invocation error should mention "server error" + + # ------------------------------------------------------------------- + # Tool Registration in ToolRegistry + # ------------------------------------------------------------------- + + Scenario: Register discovered MCP tools in ToolRegistry + Given a connected MCP adapter with 2 mock tools + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + Then the registry should have 2 tools + And registry tool 0 name should start with "mcp-github/" + + Scenario: Registered MCP tool is callable via registry + Given a connected MCP adapter with a callable mock tool "list_repos" + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + Then calling registry tool "mcp-github/list_repos" should succeed + + Scenario: Re-registration after tool list changed + Given a connected MCP adapter with 2 mock tools + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + And the MCP server adds a new tool "deploy" + And I re-register MCP tools in the registry with namespace "mcp-github" + Then the registry should have 3 tools + + Scenario: Registered tool handler returns error on failed invoke + Given a connected MCP adapter with a failing mock tool "bad_tool" + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-fail" + Then calling registry tool "mcp-fail/bad_tool" should return an error + + # ------------------------------------------------------------------- + # Capability Inference + # ------------------------------------------------------------------- + + Scenario: Infer read_only for tool named list_repos + When I infer capabilities for tool name "list_repos" + Then the inferred capabilities should have read_only true + And the inferred capabilities should have writes false + + Scenario: Infer writes for tool named create_issue + When I infer capabilities for tool name "create_issue" + Then the inferred capabilities should have read_only false + And the inferred capabilities should have writes true + + Scenario: Infer writes for tool named delete_branch + When I infer capabilities for tool name "delete_branch" + Then the inferred capabilities should have read_only false + And the inferred capabilities should have writes true + + Scenario: Infer default for unknown tool name + When I infer capabilities for tool name "run_pipeline" + Then the inferred capabilities should have read_only false + And the inferred capabilities should have writes false + + Scenario: Infer read_only for tool with get prefix + When I infer capabilities for tool name "get-user-details" + Then the inferred capabilities should have read_only true + And the inferred capabilities should have writes false + + Scenario: Write overrides read when both keywords present + When I infer capabilities for tool name "search_and_update" + Then the inferred capabilities should have writes true + + Scenario: Registered MCP tool has inferred capabilities + Given a connected MCP adapter with a callable mock tool "list_repos" + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + Then registry tool "mcp-github/list_repos" should have read_only capability + + Scenario: Registered write tool has writes capability + Given a connected MCP adapter with a callable mock tool "create_issue" + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + Then registry tool "mcp-github/create_issue" should have writes capability + + # ------------------------------------------------------------------- + # Edge Cases + # ------------------------------------------------------------------- + + Scenario: Access discovered_tools property after discovery + Given a connected MCP adapter with 2 mock tools + When I discover tools from the adapter + Then the adapter discovered_tools property should have 2 items + + Scenario: Disconnect handles close errors gracefully + Given a connected MCP adapter with a transport that errors on close + When I disconnect the adapter + Then the adapter should not be connected + + Scenario: Invoke tool that raises generic exception + Given a connected MCP adapter with a transport-error mock tool "crash_tool" + When I invoke "crash_tool" with arguments {} + Then the invocation should fail + And the invocation error should mention "server error" + + # ------------------------------------------------------------------- + # Error Classification + # ------------------------------------------------------------------- + + Scenario: Classify connection failure + Given an MCP adapter with a transport that fails to connect + When I connect the adapter expecting error + Then the adapter error should mention "connection" + + Scenario: Classify timeout error + Given a connected MCP adapter with a tool that times out + When I invoke "slow_tool" with arguments {} + Then the invocation should fail + And the invocation error should mention "timeout" + + # ------------------------------------------------------------------- + # Server Config Validation + # ------------------------------------------------------------------- + + Scenario: Stdio transport requires command + Given an MCP server config for "bad" with stdio transport but no command + When I create an MCP adapter from the config expecting validation error + Then the adapter error should mention "command" + + Scenario: SSE transport requires url + Given an MCP server config for "bad" with sse transport but no url + When I create an MCP adapter from the config expecting validation error + Then the adapter error should mention "url" + + # ------------------------------------------------------------------- + # Reconnect Lifecycle + # ------------------------------------------------------------------- + + Scenario: Reconnect restores connection after disconnect + Given a connected MCP adapter with a mock transport + When I disconnect the adapter + And I reconnect the adapter + Then the adapter should be connected + + Scenario: Reconnect clears tool cache + Given a connected MCP adapter with 3 mock tools + When I discover tools from the adapter + And I reconnect the adapter + Then the adapter discovered_tools property should have 0 items + + Scenario: Reconnect on unavailable server raises connection error + Given a connected MCP adapter with a transport that fails to reconnect + When I reconnect the adapter expecting error + Then the adapter error should mention "connection" + + Scenario: Connect times out when transport hangs + Given an MCP adapter with a transport that hangs on connect + When I connect the adapter with timeout 0.05 expecting error + Then the adapter error should mention "connection" + + # ------------------------------------------------------------------- + # Source and Checkpointable Metadata + # ------------------------------------------------------------------- + + Scenario: Registered MCP tool has source mcp + Given a connected MCP adapter with a callable mock tool "list_repos" + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + Then registry tool "mcp-github/list_repos" should have source "mcp" + + Scenario: Registered MCP tool has checkpointable false + Given a connected MCP adapter with a callable mock tool "create_issue" + And an empty MCP tool registry + When I register MCP tools in the registry with namespace "mcp-github" + Then registry tool "mcp-github/create_issue" should have checkpointable false diff --git a/features/steps/mcp_adapter_steps.py b/features/steps/mcp_adapter_steps.py new file mode 100644 index 000000000..62af05f8b --- /dev/null +++ b/features/steps/mcp_adapter_steps.py @@ -0,0 +1,583 @@ +"""Step definitions for features/mcp_adapter.feature.""" + +from __future__ import annotations + +import json +import threading +from typing import Any + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.mcp.adapter import ( + MCPServerConfig, + MCPToolAdapter, + MCPToolFilter, + MCPTransport, +) +from cleveragents.tool.registry import ToolRegistry + +# --------------------------------------------------------------- +# Mock transport for testing +# --------------------------------------------------------------- + + +class MockMCPTransport(MCPTransport): + """In-memory mock transport simulating an MCP server.""" + + def __init__( + self, + tools: list[dict[str, Any]] | None = None, + *, + fail_connect: bool = False, + invoke_results: dict[str, dict[str, Any]] | None = None, + invoke_errors: dict[str, str] | None = None, + timeout_tools: set[str] | None = None, + ) -> None: + self._tools = tools or [] + self._fail_connect = fail_connect + self._invoke_results = invoke_results or {} + self._invoke_errors = invoke_errors or {} + self._timeout_tools = timeout_tools or set() + self._connected = False + + def connect(self, config: MCPServerConfig) -> dict[str, Any]: + if self._fail_connect: + msg = "Mock connection refused" + raise ConnectionRefusedError(msg) + self._connected = True + return {"capabilities": {"tools": True}} + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "tools/list": + return {"tools": list(self._tools)} + + if method == "tools/call": + tool_name = params.get("name", "") + + if tool_name in self._timeout_tools: + msg = f"Tool '{tool_name}' exceeded timeout" + raise TimeoutError(msg) + + if tool_name in self._invoke_errors: + return { + "isError": True, + "error": self._invoke_errors[tool_name], + } + + if tool_name in self._invoke_results: + return {"content": self._invoke_results[tool_name]} + + return {"content": {"result": "ok"}} + + return {} + + def close(self) -> None: + self._connected = False + + def add_tool(self, tool: dict[str, Any]) -> None: + self._tools.append(tool) + + +def _mock_tool(name: str, desc: str = "", schema: dict | None = None) -> dict: + return { + "name": name, + "description": desc or f"Mock tool {name}", + "inputSchema": schema or {}, + } + + +# --------------------------------------------------------------- +# Adapter Lifecycle +# --------------------------------------------------------------- + + +@given('an MCP server config for "{name}" with stdio transport') +def step_mcp_config_stdio(context: Context, name: str) -> None: + context.mcp_config = MCPServerConfig( + name=name, + transport="stdio", + command="echo", + args=["hello"], + ) + + +@given('an MCP server config for "{name}" with sse transport and url "{url}"') +def step_mcp_config_sse(context: Context, name: str, url: str) -> None: + context.mcp_config = MCPServerConfig(name=name, transport="sse", url=url) + + +@given("an MCP adapter with a mock transport") +def step_mcp_adapter_mock(context: Context) -> None: + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport() + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + + +@given("a connected MCP adapter with a mock transport") +def step_mcp_adapter_connected(context: Context) -> None: + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport() + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + + +@given("a connected MCP adapter with {count:d} mock tools") +def step_mcp_adapter_with_tools(context: Context, count: int) -> None: + tools = [_mock_tool(f"tool_{i}") for i in range(count)] + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport(tools=tools) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + + +@given('a connected MCP adapter with a callable mock tool "{tool_name}"') +def step_mcp_adapter_callable_tool(context: Context, tool_name: str) -> None: + tools = [_mock_tool(tool_name)] + results = {tool_name: {"id": 42, "status": "created"}} + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport(tools=tools, invoke_results=results) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + context.mcp_adapter.discover_tools() + + +@given('a connected MCP adapter with a schema-validated mock tool "{tool_name}"') +def step_mcp_adapter_schema_tool(context: Context, tool_name: str) -> None: + schema: dict[str, Any] = { + "type": "object", + "properties": { + "title": {"type": "string"}, + "body": {"type": "string"}, + }, + "required": ["title", "body"], + } + tools = [_mock_tool(tool_name, schema=schema)] + results = {tool_name: {"id": 1}} + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport(tools=tools, invoke_results=results) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + context.mcp_adapter.discover_tools() + + +@given('a connected MCP adapter with a failing mock tool "{tool_name}"') +def step_mcp_adapter_failing_tool(context: Context, tool_name: str) -> None: + tools = [_mock_tool(tool_name)] + errors = {tool_name: "Internal server error"} + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport(tools=tools, invoke_errors=errors) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + context.mcp_adapter.discover_tools() + + +@given('a tool filter including only "{tool_name}"') +def step_tool_filter_include(context: Context, tool_name: str) -> None: + context.mcp_tool_filter = MCPToolFilter(include=[tool_name]) + + +@given('a tool filter excluding "{tool_name}"') +def step_tool_filter_exclude(context: Context, tool_name: str) -> None: + context.mcp_tool_filter = MCPToolFilter(exclude=[tool_name]) + + +@given("an empty MCP tool registry") +def step_empty_mcp_registry(context: Context) -> None: + context.mcp_registry = ToolRegistry() + + +@given("an MCP adapter with a transport that fails to connect") +def step_mcp_adapter_fail_connect(context: Context) -> None: + config = MCPServerConfig(name="bad-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport(fail_connect=True) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + + +@given("a connected MCP adapter with a tool that times out") +def step_mcp_adapter_timeout_tool(context: Context) -> None: + tools = [_mock_tool("slow_tool")] + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + context.mcp_transport = MockMCPTransport(tools=tools, timeout_tools={"slow_tool"}) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + context.mcp_adapter.discover_tools() + + +@given("a connected MCP adapter with a transport that errors on close") +def step_mcp_adapter_close_error(context: Context) -> None: + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + + class _ErrorCloseTransport(MockMCPTransport): + def close(self) -> None: + msg = "Close failed" + raise OSError(msg) + + context.mcp_transport = _ErrorCloseTransport() + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + + +@given('a connected MCP adapter with a transport-error mock tool "{tool_name}"') +def step_mcp_adapter_transport_error_tool(context: Context, tool_name: str) -> None: + tools = [_mock_tool(tool_name)] + config = MCPServerConfig(name="test-server", transport="stdio", command="echo") + + class _CrashTransport(MockMCPTransport): + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "tools/call": + msg = "Transport crashed unexpectedly" + raise RuntimeError(msg) + return super().call(method, params) + + context.mcp_transport = _CrashTransport(tools=tools) + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + context.mcp_adapter.discover_tools() + + +@given('an MCP server config for "{name}" with stdio transport but no command') +def step_mcp_config_stdio_no_cmd(context: Context, name: str) -> None: + context.mcp_config = MCPServerConfig(name=name, transport="stdio") + + +@given('an MCP server config for "{name}" with sse transport but no url') +def step_mcp_config_sse_no_url(context: Context, name: str) -> None: + context.mcp_config = MCPServerConfig(name=name, transport="sse") + + +@given("a connected MCP adapter with a transport that fails to reconnect") +def step_mcp_adapter_fail_reconnect(context: Context) -> None: + """Adapter starts connected but the transport will refuse the next connect.""" + config = MCPServerConfig(name="flaky-server", transport="stdio", command="echo") + call_count: list[int] = [0] + + class _FlakeyTransport(MockMCPTransport): + def connect(self, cfg: MCPServerConfig) -> dict[str, Any]: + call_count[0] += 1 + if call_count[0] > 1: + msg = "Server unavailable after restart" + raise ConnectionRefusedError(msg) + return super().connect(cfg) + + context.mcp_transport = _FlakeyTransport() + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context.mcp_adapter.connect() + + +@given("an MCP adapter with a transport that hangs on connect") +def step_mcp_adapter_hang_connect(context: Context) -> None: + """Adapter whose transport blocks indefinitely during connect.""" + config = MCPServerConfig(name="hang-server", transport="stdio", command="echo") + + _hang_event = threading.Event() + + class _HangTransport(MockMCPTransport): + def connect(self, cfg: MCPServerConfig) -> dict[str, Any]: + _hang_event.wait(timeout=10) + return super().connect(cfg) + + context.mcp_transport = _HangTransport() + context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport) + context._hang_event = _hang_event + + +# --------------------------------------------------------------- +# When Steps +# --------------------------------------------------------------- + + +@when("I create an MCP adapter from the config") +def step_create_adapter(context: Context) -> None: + context.mcp_error = None + try: + context.mcp_adapter = MCPToolAdapter.from_server_config(context.mcp_config) + except Exception as exc: + context.mcp_error = str(exc) + + +@when("I create an MCP adapter from the config expecting validation error") +def step_create_adapter_fail(context: Context) -> None: + context.mcp_error = None + try: + context.mcp_adapter = MCPToolAdapter.from_server_config(context.mcp_config) + except Exception as exc: + context.mcp_error = str(exc) + + +@when("I connect the adapter") +def step_connect_adapter(context: Context) -> None: + context.mcp_error = None + context.mcp_adapter.connect() + + +@when("I connect the adapter expecting error") +def step_connect_adapter_fail(context: Context) -> None: + context.mcp_error = None + try: + context.mcp_adapter.connect() + except Exception as exc: + context.mcp_error = str(exc) + + +@when("I disconnect the adapter") +def step_disconnect_adapter(context: Context) -> None: + context.mcp_adapter.disconnect() + + +@when("I reconnect the adapter") +def step_reconnect_adapter(context: Context) -> None: + context.mcp_error = None + context.mcp_adapter.reconnect() + + +@when("I reconnect the adapter expecting error") +def step_reconnect_adapter_fail(context: Context) -> None: + context.mcp_error = None + try: + context.mcp_adapter.reconnect() + except Exception as exc: + context.mcp_error = str(exc) + + +@when("I connect the adapter with timeout {secs:f} expecting error") +def step_connect_with_timeout_fail(context: Context, secs: float) -> None: + context.mcp_error = None + try: + context.mcp_adapter.connect(timeout=secs) + except Exception as exc: + context.mcp_error = str(exc) + + +@when("I discover tools from the adapter") +def step_discover_tools(context: Context) -> None: + context.mcp_discovered = context.mcp_adapter.discover_tools() + + +@when("I discover tools from the adapter with filter") +def step_discover_tools_filtered(context: Context) -> None: + context.mcp_discovered = context.mcp_adapter.discover_tools( + tool_filter=context.mcp_tool_filter + ) + + +@when("I discover tools expecting an error") +def step_discover_tools_error(context: Context) -> None: + context.mcp_error = None + try: + context.mcp_adapter.discover_tools() + except Exception as exc: + context.mcp_error = str(exc) + + +@when('I invoke "{tool_name}" with arguments {args_json}') +def step_invoke_tool(context: Context, tool_name: str, args_json: str) -> None: + arguments = json.loads(args_json) + context.mcp_invoke_result = context.mcp_adapter.invoke(tool_name, arguments) + + +@when('I invoke MCP tool "{tool_name}" while disconnected') +def step_invoke_not_connected(context: Context, tool_name: str) -> None: + context.mcp_error = None + try: + context.mcp_adapter.invoke(tool_name, {}) + except Exception as exc: + context.mcp_error = str(exc) + + +@when('I register MCP tools in the registry with namespace "{namespace}"') +def step_register_tools(context: Context, namespace: str) -> None: + context.mcp_registered = context.mcp_adapter.register_tools( + registry=context.mcp_registry, + namespace=namespace, + ) + + +@when('the MCP server adds a new tool "{tool_name}"') +def step_server_adds_tool(context: Context, tool_name: str) -> None: + context.mcp_transport.add_tool(_mock_tool(tool_name)) + + +@when('I re-register MCP tools in the registry with namespace "{namespace}"') +def step_reregister_tools(context: Context, namespace: str) -> None: + context.mcp_registered = context.mcp_adapter.register_tools( + registry=context.mcp_registry, + namespace=namespace, + ) + + +# --------------------------------------------------------------- +# Then Steps +# --------------------------------------------------------------- + + +@then('the adapter server name should be "{name}"') +def step_adapter_name(context: Context, name: str) -> None: + assert context.mcp_adapter.server_name == name + + +@then('the adapter transport should be "{transport}"') +def step_adapter_transport(context: Context, transport: str) -> None: + assert context.mcp_adapter.transport_type == transport + + +@then("the adapter should not be connected") +def step_adapter_not_connected(context: Context) -> None: + assert not context.mcp_adapter.is_connected + + +@then("the adapter should be connected") +def step_adapter_connected(context: Context) -> None: + assert context.mcp_adapter.is_connected + + +@then("the adapter server capabilities should be set") +def step_adapter_capabilities(context: Context) -> None: + assert context.mcp_adapter.capabilities + + +@then("the adapter should have {count:d} discovered tools") +def step_adapter_tool_count(context: Context, count: int) -> None: + assert len(context.mcp_discovered) == count, ( + f"Expected {count} tools, got {len(context.mcp_discovered)}" + ) + + +@then("discovered tool {idx:d} should have a name") +def step_discovered_tool_name(context: Context, idx: int) -> None: + assert context.mcp_discovered[idx].name + + +@then("discovered tool {idx:d} should have an input schema") +def step_discovered_tool_schema(context: Context, idx: int) -> None: + assert context.mcp_discovered[idx].input_schema is not None + + +@then("the invocation should succeed") +def step_invoke_success(context: Context) -> None: + assert context.mcp_invoke_result.success, ( + f"Expected success, got error: {context.mcp_invoke_result.error}" + ) + + +@then("the invocation should fail") +def step_invoke_fail(context: Context) -> None: + assert not context.mcp_invoke_result.success + + +@then('the invocation result should contain key "{key}"') +def step_invoke_result_key(context: Context, key: str) -> None: + assert key in context.mcp_invoke_result.data, ( + f"Key '{key}' not in result: {context.mcp_invoke_result.data}" + ) + + +@then('the invocation error should mention "{text}"') +def step_invoke_error_mention(context: Context, text: str) -> None: + assert context.mcp_invoke_result.error is not None + assert text.lower() in context.mcp_invoke_result.error.lower(), ( + f"Error '{context.mcp_invoke_result.error}' does not mention '{text}'" + ) + + +@then('the adapter error should mention "{text}"') +def step_adapter_error_mention(context: Context, text: str) -> None: + assert context.mcp_error is not None, "Expected error but none occurred" + assert text.lower() in context.mcp_error.lower(), ( + f"Error '{context.mcp_error}' does not mention '{text}'" + ) + + +@then("the registry should have {count:d} tools") +def step_registry_count(context: Context, count: int) -> None: + all_tools = context.mcp_registry.list_tools() + assert len(all_tools) == count, f"Expected {count} tools, got {len(all_tools)}" + + +@then('registry tool {idx:d} name should start with "{prefix}"') +def step_registry_tool_prefix(context: Context, idx: int, prefix: str) -> None: + all_tools = context.mcp_registry.list_tools() + assert all_tools[idx].name.startswith(prefix) + + +@then('calling registry tool "{tool_name}" should succeed') +def step_registry_tool_callable(context: Context, tool_name: str) -> None: + spec = context.mcp_registry.get(tool_name) + assert spec is not None, f"Tool '{tool_name}' not in registry" + result = spec.handler() + assert isinstance(result, dict) + + +@then('calling registry tool "{tool_name}" should return an error') +def step_registry_tool_error(context: Context, tool_name: str) -> None: + spec = context.mcp_registry.get(tool_name) + assert spec is not None, f"Tool '{tool_name}' not in registry" + result = spec.handler() + assert isinstance(result, dict) + assert "error" in result + + +@then("the adapter discovered_tools property should have {count:d} items") +def step_adapter_discovered_tools_property(context: Context, count: int) -> None: + dt = context.mcp_adapter.discovered_tools + assert len(dt) == count, f"Expected {count}, got {len(dt)}" + + +# --------------------------------------------------------------- +# Capability Inference Steps +# --------------------------------------------------------------- + + +@when('I infer capabilities for tool name "{tool_name}"') +def step_infer_capabilities(context: Context, tool_name: str) -> None: + context.inferred_caps = MCPToolAdapter.infer_capabilities(tool_name) + + +@then("the inferred capabilities should have read_only true") +def step_inferred_read_only_true(context: Context) -> None: + assert context.inferred_caps["read_only"] is True + + +@then("the inferred capabilities should have read_only false") +def step_inferred_read_only_false(context: Context) -> None: + assert context.inferred_caps["read_only"] is False + + +@then("the inferred capabilities should have writes true") +def step_inferred_writes_true(context: Context) -> None: + assert context.inferred_caps["writes"] is True + + +@then("the inferred capabilities should have writes false") +def step_inferred_writes_false(context: Context) -> None: + assert context.inferred_caps["writes"] is False + + +@then('registry tool "{tool_name}" should have read_only capability') +def step_registry_tool_read_only(context: Context, tool_name: str) -> None: + spec = context.mcp_registry.get(tool_name) + assert spec is not None, f"Tool '{tool_name}' not in registry" + assert spec.capabilities.read_only is True + + +@then('registry tool "{tool_name}" should have writes capability') +def step_registry_tool_writes(context: Context, tool_name: str) -> None: + spec = context.mcp_registry.get(tool_name) + assert spec is not None, f"Tool '{tool_name}' not in registry" + assert spec.capabilities.writes is True + + +@then('registry tool "{tool_name}" should have source "{source}"') +def step_registry_tool_source(context: Context, tool_name: str, source: str) -> None: + spec = context.mcp_registry.get(tool_name) + assert spec is not None, f"Tool '{tool_name}' not in registry" + assert spec.source == source, f"Expected source '{source}', got '{spec.source}'" + + +@then('registry tool "{tool_name}" should have checkpointable false') +def step_registry_tool_checkpointable_false(context: Context, tool_name: str) -> None: + spec = context.mcp_registry.get(tool_name) + assert spec is not None, f"Tool '{tool_name}' not in registry" + assert spec.capabilities.checkpointable is False, ( + f"Expected checkpointable=False, got {spec.capabilities.checkpointable}" + ) diff --git a/robot/helper_mcp_adapter.py b/robot/helper_mcp_adapter.py new file mode 100644 index 000000000..0be4ded66 --- /dev/null +++ b/robot/helper_mcp_adapter.py @@ -0,0 +1,146 @@ +"""Robot Framework helper for MCP Tool Adapter smoke tests. + +Usage: + python robot/helper_mcp_adapter.py create-adapter + python robot/helper_mcp_adapter.py discover-tools + python robot/helper_mcp_adapter.py invoke-tool + python robot/helper_mcp_adapter.py register-tools + python robot/helper_mcp_adapter.py reject-no-command +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.mcp.adapter import ( # noqa: E402 + MCPServerConfig, + MCPToolAdapter, + MCPTransport, +) +from cleveragents.tool.registry import ToolRegistry # noqa: E402 + + +class _MockTransport(MCPTransport): + """Minimal mock transport for smoke tests.""" + + def __init__(self, tools: list[dict[str, Any]] | None = None) -> None: + self._tools = tools or [] + self._results: dict[str, dict[str, Any]] = {} + + def connect(self, config: MCPServerConfig) -> dict[str, Any]: + return {"capabilities": {"tools": True}} + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "tools/list": + return {"tools": list(self._tools)} + if method == "tools/call": + name = params.get("name", "") + if name in self._results: + return {"content": self._results[name]} + return {"content": {"result": "ok"}} + return {} + + def close(self) -> None: + pass + + +def _mock_tool(name: str) -> dict[str, Any]: + return {"name": name, "description": f"Mock {name}", "inputSchema": {}} + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: helper_mcp_adapter.py ") + return 1 + + dispatch = { + "create-adapter": _create_adapter, + "discover-tools": _discover_tools, + "invoke-tool": _invoke_tool, + "register-tools": _register_tools, + "reject-no-command": _reject_no_command, + } + handler = dispatch.get(sys.argv[1]) + if handler is None: + print(f"Unknown command: {sys.argv[1]}") + return 1 + return handler() + + +def _create_adapter() -> int: + config = MCPServerConfig(name="github", transport="stdio", command="echo") + adapter = MCPToolAdapter(config=config) + print(f"server-name: {adapter.server_name}") + print(f"transport: {adapter.transport_type}") + print(f"connected: {adapter.is_connected}") + return 0 + + +def _discover_tools() -> int: + tools = [ + _mock_tool("create_issue"), + _mock_tool("list_repos"), + _mock_tool("search"), + ] + config = MCPServerConfig(name="test", transport="stdio", command="echo") + transport = _MockTransport(tools=tools) + adapter = MCPToolAdapter(config=config, transport=transport) + adapter.connect() + discovered = adapter.discover_tools() + print(f"discovered: {len(discovered)}") + for i, t in enumerate(discovered): + print(f"tool-{i}: {t.name}") + return 0 + + +def _invoke_tool() -> int: + tools = [_mock_tool("create_issue")] + config = MCPServerConfig(name="test", transport="stdio", command="echo") + transport = _MockTransport(tools=tools) + transport._results["create_issue"] = {"id": 42} + adapter = MCPToolAdapter(config=config, transport=transport) + adapter.connect() + adapter.discover_tools() + result = adapter.invoke("create_issue", {"title": "Bug"}) + print(f"success: {result.success}") + print(f"has-data: {bool(result.data)}") + return 0 + + +def _register_tools() -> int: + tools = [_mock_tool("tool_a"), _mock_tool("tool_b")] + config = MCPServerConfig(name="test", transport="stdio", command="echo") + transport = _MockTransport(tools=tools) + adapter = MCPToolAdapter(config=config, transport=transport) + adapter.connect() + registry = ToolRegistry() + names = adapter.register_tools(registry, namespace="mcp-test") + print(f"registered: {len(names)}") + if names and "/" in names[0]: + print(f"prefix: {names[0].split('/')[0]}/") + return 0 + + +def _reject_no_command() -> int: + config = MCPServerConfig(name="bad", transport="stdio") + try: + MCPToolAdapter.from_server_config(config) + print("unexpected-success") + return 1 + except ValueError as exc: + err = str(exc).lower() + if "command" in err: + print("validation-error: command") + else: + print(f"validation-error-other: {exc}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/mcp_adapter.robot b/robot/mcp_adapter.robot new file mode 100644 index 000000000..60855f68e --- /dev/null +++ b/robot/mcp_adapter.robot @@ -0,0 +1,50 @@ +*** Settings *** +Documentation Smoke tests for MCP Tool Adapter +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_mcp_adapter.py + +*** Test Cases *** +Create MCP Adapter From Config + [Documentation] Create an adapter from a server config and verify properties + ${result}= Run Process ${PYTHON} ${HELPER} create-adapter cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-name: github + Should Contain ${result.stdout} transport: stdio + Should Contain ${result.stdout} connected: False + +Discover MCP Tools + [Documentation] Connect and discover tools from a mock MCP server + ${result}= Run Process ${PYTHON} ${HELPER} discover-tools cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovered: 3 + Should Contain ${result.stdout} tool-0: create_issue + +Invoke MCP Tool + [Documentation] Invoke a discovered tool and verify result + ${result}= Run Process ${PYTHON} ${HELPER} invoke-tool cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} success: True + Should Contain ${result.stdout} has-data: True + +Register MCP Tools In Registry + [Documentation] Register discovered tools into a ToolRegistry + ${result}= Run Process ${PYTHON} ${HELPER} register-tools cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} registered: 2 + Should Contain ${result.stdout} prefix: mcp-test/ + +Reject Stdio Config Without Command + [Documentation] Validate that stdio transport requires command + ${result}= Run Process ${PYTHON} ${HELPER} reject-no-command cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} validation-error: command diff --git a/src/cleveragents/mcp/__init__.py b/src/cleveragents/mcp/__init__.py new file mode 100644 index 000000000..e3856f0bb --- /dev/null +++ b/src/cleveragents/mcp/__init__.py @@ -0,0 +1,19 @@ +"""MCP (Model Context Protocol) adapter package. + +Bridges external MCP tool servers into the CleverAgents ToolRegistry +via ``MCPToolAdapter``. +""" + +from cleveragents.mcp.adapter import ( + MCPServerConfig, + MCPToolAdapter, + MCPToolDescriptor, + MCPToolResult, +) + +__all__ = [ + "MCPServerConfig", + "MCPToolAdapter", + "MCPToolDescriptor", + "MCPToolResult", +] diff --git a/src/cleveragents/mcp/adapter.py b/src/cleveragents/mcp/adapter.py new file mode 100644 index 000000000..7d932567a --- /dev/null +++ b/src/cleveragents/mcp/adapter.py @@ -0,0 +1,515 @@ +"""MCP Tool Adapter for bridging MCP servers into the ToolRegistry. + +Manages the lifecycle of an MCP server connection — handshake, tool +discovery, invocation with schema validation, and clean shutdown. + +Operations: + | Method | Description | + |-------------------------------|--------------------------------------------| + | ``connect()`` | Start server process / HTTP session | + | ``disconnect()`` | Clean shutdown of the server connection | + | ``discover_tools(filter)`` | Enumerate tools from the server | + | ``invoke(name, arguments)`` | Call a tool with input validation | + | ``register_tools(registry)`` | Bulk-register discovered tools | +""" + +from __future__ import annotations + +import logging +import threading +import time +from typing import Any + +import jsonschema +from pydantic import BaseModel, ConfigDict, Field + +from cleveragents.domain.models.core.tool import ToolCapability +from cleveragents.tool.runtime import ToolSpec + +logger = logging.getLogger(__name__) + + +class MCPServerConfig(BaseModel): + """Configuration for connecting to an MCP server. + + Attributes: + name: Server identifier. + transport: Protocol type (``stdio``, ``sse``, ``streamable-http``). + command: Command to spawn the server (required for stdio). + args: Command-line arguments for the server process. + env: Environment variables passed to the server process. + url: Server URL (required for sse/streamable-http). + headers: HTTP headers for remote connections. + """ + + name: str = Field(..., min_length=1, description="Server identifier") + transport: str = Field(..., description="Transport: stdio | sse | streamable-http") + command: str | None = Field(default=None, description="Spawn command (stdio)") + args: list[str] = Field(default_factory=list, description="Command arguments") + env: dict[str, str] = Field(default_factory=dict, description="Environment vars") + url: str | None = Field(default=None, description="Server URL (sse/http)") + headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers") + + model_config = ConfigDict(str_strip_whitespace=True) + + +class MCPToolDescriptor(BaseModel): + """Descriptor for a tool discovered from an MCP server. + + Attributes: + name: Tool name as exposed by the MCP server. + description: Human-readable description of the tool. + input_schema: JSON Schema for tool inputs. + """ + + name: str = Field(..., description="Tool name") + description: str = Field(default="", description="Tool description") + input_schema: dict[str, Any] = Field( + default_factory=dict, description="JSON Schema for inputs" + ) + + model_config = ConfigDict(str_strip_whitespace=True) + + +class MCPToolResult(BaseModel): + """Result of an MCP tool invocation. + + Attributes: + success: Whether the invocation succeeded. + data: Result payload from the MCP server. + error: Error message on failure. + duration_ms: Wall-clock execution time in milliseconds. + """ + + success: bool = Field(..., description="Whether invocation succeeded") + data: dict[str, Any] = Field(default_factory=dict, description="Result payload") + error: str | None = Field(default=None, description="Error message") + duration_ms: float = Field(default=0.0, description="Execution time (ms)") + + model_config = ConfigDict(str_strip_whitespace=True) + + +class MCPToolFilter(BaseModel): + """Filter controlling which MCP tools are exposed. + + Attributes: + include: Whitelist of tool names (empty = include all). + exclude: Blacklist of tool names. + """ + + include: list[str] = Field(default_factory=list, description="Include list") + exclude: list[str] = Field(default_factory=list, description="Exclude list") + + +class MCPTransport: + """Abstract transport layer for MCP server communication. + + Subclass to provide real stdio or HTTP transports. The default + implementation raises ``NotImplementedError`` for all operations. + """ + + def connect(self, config: MCPServerConfig) -> dict[str, Any]: + """Perform handshake and return server capabilities.""" + raise NotImplementedError + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + """Send a JSON-RPC method call and return the result.""" + _ = method, params + raise NotImplementedError + + def close(self) -> None: + """Shut down the transport connection.""" + + +class MCPToolAdapter: + """Adapter bridging an MCP server into the CleverAgents ToolRegistry. + + Parameters + ---------- + config: + Server connection configuration. + transport: + Optional transport override (for testing). When ``None``, a + real transport is selected based on ``config.transport``. + """ + + def __init__( + self, + config: MCPServerConfig, + transport: MCPTransport | None = None, + ) -> None: + self._config = config + self._transport = transport or MCPTransport() + self._connected = False + self._capabilities: dict[str, Any] = {} + self._tools: dict[str, MCPToolDescriptor] = {} + self._lock = threading.RLock() + + @classmethod + def from_server_config( + cls, + config: MCPServerConfig, + transport: MCPTransport | None = None, + ) -> MCPToolAdapter: + """Create an adapter with config validation. + + Raises ``ValueError`` when required transport fields are missing. + """ + if config.transport == "stdio" and not config.command: + msg = ( + f"MCPServerConfig '{config.name}': stdio transport " + f"requires 'command' field" + ) + raise ValueError(msg) + if config.transport in ("sse", "streamable-http") and not config.url: + msg = ( + f"MCPServerConfig '{config.name}': {config.transport} " + f"transport requires 'url' field" + ) + raise ValueError(msg) + return cls(config=config, transport=transport) + + @property + def server_name(self) -> str: + return self._config.name + + @property + def transport_type(self) -> str: + return self._config.transport + + @property + def is_connected(self) -> bool: + with self._lock: + return self._connected + + @property + def capabilities(self) -> dict[str, Any]: + with self._lock: + return dict(self._capabilities) + + @property + def discovered_tools(self) -> list[MCPToolDescriptor]: + with self._lock: + return list(self._tools.values()) + + def connect(self, timeout: float = 30.0) -> None: + """Connect to the MCP server and perform the initialize handshake. + + Parameters + ---------- + timeout: + Maximum seconds to wait for the transport handshake. + Raises ``ConnectionError`` if exceeded. Defaults to 30 s. + + Raises + ------ + ConnectionError + If the transport fails to connect or the handshake times out. + """ + with self._lock: + if self._connected: + return + + result: dict[str, Any] = {} + exc_holder: list[BaseException] = [] + + def _do_connect() -> None: + try: + result["caps"] = self._transport.connect(self._config) + except Exception as exc: + exc_holder.append(exc) + + thread = threading.Thread(target=_do_connect, daemon=True) + thread.start() + thread.join(timeout=timeout) + + if thread.is_alive(): + msg = ( + f"MCP connection to '{self._config.name}' timed out " + f"after {timeout}s" + ) + raise ConnectionError(msg) + + if exc_holder: + msg = f"MCP connection to '{self._config.name}' failed: {exc_holder[0]}" + raise ConnectionError(msg) from exc_holder[0] + + self._capabilities = result.get("caps") or {} + self._connected = True + logger.info("Connected to MCP server '%s'", self._config.name) + + def disconnect(self) -> None: + """Disconnect from the MCP server.""" + with self._lock: + if not self._connected: + return + try: + self._transport.close() + except Exception: + logger.warning( + "Error during MCP disconnect from '%s'", + self._config.name, + exc_info=True, + ) + self._connected = False + self._tools.clear() + logger.info("Disconnected from MCP server '%s'", self._config.name) + + def reconnect(self, timeout: float = 30.0) -> None: + """Disconnect from the current session and establish a fresh connection. + + Clears cached tool descriptors. Callers should call + ``discover_tools()`` again after reconnecting. + + Parameters + ---------- + timeout: + Maximum seconds to wait for the new transport handshake. + + Raises + ------ + ConnectionError + If the new connection attempt fails or times out. + """ + self.disconnect() + self.connect(timeout=timeout) + logger.info("Reconnected to MCP server '%s'", self._config.name) + + def discover_tools( + self, + tool_filter: MCPToolFilter | None = None, + ) -> list[MCPToolDescriptor]: + """Enumerate tools from the connected MCP server. + + Parameters + ---------- + tool_filter: + Optional include/exclude filter. + + Raises + ------ + RuntimeError + If the adapter is not connected. + """ + with self._lock: + if not self._connected: + msg = ( + f"MCP adapter '{self._config.name}' is not connected. " + f"Call connect() first." + ) + raise RuntimeError(msg) + + result = self._transport.call("tools/list", {}) + raw_tools = result.get("tools", []) + + descriptors: list[MCPToolDescriptor] = [] + for raw in raw_tools: + desc = MCPToolDescriptor( + name=raw.get("name", ""), + description=raw.get("description", ""), + input_schema=raw.get("inputSchema", {}), + ) + descriptors.append(desc) + + if tool_filter: + descriptors = self._apply_filter(descriptors, tool_filter) + + self._tools = {d.name: d for d in descriptors} + return descriptors + + def invoke( + self, + tool_name: str, + arguments: dict[str, Any], + ) -> MCPToolResult: + """Invoke a discovered MCP tool by name. + + Validates inputs against the tool's JSON Schema before calling + the server. + + Parameters + ---------- + tool_name: + Name of the tool as discovered from the server. + arguments: + Tool input arguments. + + Returns + ------- + MCPToolResult + Invocation outcome with success flag, data, and timing. + """ + with self._lock: + if not self._connected: + msg = ( + f"MCP adapter '{self._config.name}' is not connected. " + f"Call connect() first." + ) + raise RuntimeError(msg) + + descriptor = self._tools.get(tool_name) + if descriptor is None: + return MCPToolResult( + success=False, + error=f"Tool '{tool_name}' not found in MCP server " + f"'{self._config.name}'", + ) + + validation_error = self._validate_input(descriptor, arguments) + if validation_error: + return MCPToolResult( + success=False, + error=f"Input validation failed for '{tool_name}': " + f"{validation_error}", + ) + + start = time.monotonic() + try: + result = self._transport.call( + "tools/call", + {"name": tool_name, "arguments": arguments}, + ) + except TimeoutError as exc: + elapsed = (time.monotonic() - start) * 1000 + return MCPToolResult( + success=False, + error=f"Tool '{tool_name}' timeout: {exc}", + duration_ms=elapsed, + ) + except Exception as exc: + elapsed = (time.monotonic() - start) * 1000 + return MCPToolResult( + success=False, + error=f"MCP server error invoking '{tool_name}': {exc}", + duration_ms=elapsed, + ) + elapsed = (time.monotonic() - start) * 1000 + + if result.get("isError"): + return MCPToolResult( + success=False, + error=f"MCP server error: {result.get('error', 'unknown error')}", + duration_ms=elapsed, + ) + + return MCPToolResult( + success=True, + data=result.get("content", result), + duration_ms=elapsed, + ) + + def register_tools( + self, + registry: Any, + namespace: str, + tool_filter: MCPToolFilter | None = None, + ) -> list[str]: + """Discover and register MCP tools into a ToolRegistry. + + Parameters + ---------- + registry: + A ``ToolRegistry`` instance. + namespace: + Namespace prefix for registered tool names. + tool_filter: + Optional include/exclude filter. + + Returns + ------- + list[str] + Names of successfully registered tools. + """ + descriptors = self.discover_tools(tool_filter) + registered: list[str] = [] + + for desc in descriptors: + tool_name = f"{namespace}/{desc.name}" + existing = registry.get(tool_name) + if existing is not None: + registry.remove(tool_name) + + adapter_ref = self + + def _make_handler(tn: str, ar: MCPToolAdapter) -> Any: + def handler(**kwargs: Any) -> dict[str, Any]: + result = ar.invoke(tn, kwargs) + if not result.success: + return {"error": result.error} + return result.data + + return handler + + inferred = self.infer_capabilities(desc.name) + # MCP tools are never checkpointable — they make real external + # calls to a server that cannot be rolled back transactionally. + capabilities = ToolCapability( + read_only=inferred["read_only"], + writes=inferred["writes"], + checkpointable=False, + ) + + spec = ToolSpec( + name=tool_name, + description=desc.description or f"MCP tool: {desc.name}", + input_schema=desc.input_schema, + capabilities=capabilities, + handler=_make_handler(desc.name, adapter_ref), + source="mcp", + source_metadata={"server": self._config.name}, + ) + registry.register(spec) + registered.append(tool_name) + + return registered + + @staticmethod + def infer_capabilities(tool_name: str) -> dict[str, bool]: + """Infer ToolCapability metadata from an MCP tool name. + + Applies heuristics from the specification: + - Names containing read/get/list/search/find -> read_only + - Names containing write/create/update/delete/set -> writes + """ + lower = tool_name.lower() + read_keywords = {"read", "get", "list", "search", "find"} + write_keywords = {"write", "create", "update", "delete", "set"} + + parts = set(lower.replace("-", "_").split("_")) + + is_read = bool(parts & read_keywords) + is_write = bool(parts & write_keywords) + + if is_read and not is_write: + return {"read_only": True, "writes": False} + if is_write: + return {"read_only": False, "writes": True} + return {"read_only": False, "writes": False} + + @staticmethod + def _apply_filter( + descriptors: list[MCPToolDescriptor], + tool_filter: MCPToolFilter, + ) -> list[MCPToolDescriptor]: + result = descriptors + if tool_filter.include: + include_set = set(tool_filter.include) + result = [d for d in result if d.name in include_set] + if tool_filter.exclude: + exclude_set = set(tool_filter.exclude) + result = [d for d in result if d.name not in exclude_set] + return result + + @staticmethod + def _validate_input( + descriptor: MCPToolDescriptor, + arguments: dict[str, Any], + ) -> str | None: + schema = descriptor.input_schema + if not schema: + return None + try: + jsonschema.validate(instance=arguments, schema=schema) + except jsonschema.ValidationError as exc: + return str(exc.message) + return None