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:
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user