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
This commit is contained in:
2026-02-25 13:17:26 +00:00
parent 1282bc03b3
commit ee82d6101e
8 changed files with 1891 additions and 0 deletions
+153
View File
@@ -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()
+144
View File
@@ -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": "<server-name>"}` 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.
+281
View File
@@ -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
+583
View File
@@ -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}"
)
+146
View File
@@ -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 <command>")
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())
+50
View File
@@ -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
+19
View File
@@ -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",
]
+515
View File
@@ -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