Compare commits

...

3 Commits

Author SHA1 Message Date
aditya 80a56846fe Merge branch 'master' into develop-aditya-2
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 14s
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 20s
CI / typecheck (pull_request) Successful in 47s
CI / security (pull_request) Successful in 50s
CI / integration_tests (pull_request) Successful in 3m41s
CI / unit_tests (pull_request) Successful in 7m9s
CI / docker (pull_request) Successful in 38s
CI / benchmark-regression (pull_request) Successful in 20m12s
CI / coverage (pull_request) Successful in 24m15s
# Conflicts:
#	implementation_plan.md
2026-02-24 09:37:42 +00:00
aditya a6e56fe96e feat(skill): add MCP adapter for external tools
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 21s
CI / security (pull_request) Successful in 33s
CI / typecheck (pull_request) Successful in 47s
CI / integration_tests (pull_request) Successful in 2m54s
CI / unit_tests (pull_request) Successful in 6m13s
CI / docker (pull_request) Successful in 39s
CI / benchmark-regression (pull_request) Successful in 19m58s
CI / coverage (pull_request) Failing after 22m0s
2026-02-23 13:03:10 +00:00
aditya 7013d4462d feat(actor): extend hierarchical actor YAML schema and loader 2026-02-23 10:42:44 +00:00
17 changed files with 3320 additions and 13 deletions
+142
View File
@@ -0,0 +1,142 @@
"""ASV benchmarks for hierarchical actor YAML parsing performance.
Measures the performance of:
- Parsing actors with per-node LSP bindings
- Parsing actors with tool-source references
- Parsing actors with subgraph actor_ref
- Parsing actors with full hierarchical features combined
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
_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.actor.schema import ActorConfigSchema # noqa: E402
def _graph_data(nodes: list[dict], *, skills: list[str] | None = None) -> dict:
node_ids = [n["id"] for n in nodes]
edges = []
for i in range(len(node_ids) - 1):
edges.append({"from_node": node_ids[i], "to_node": node_ids[i + 1]})
data: dict = {
"name": "bench/hierarchy",
"type": "graph",
"description": "Benchmark hierarchical actor",
"model": "gpt-4",
"route": {
"nodes": nodes,
"edges": edges,
"entry_node": node_ids[0],
"exit_nodes": [node_ids[-1]],
},
}
if skills:
data["skills"] = skills
return data
_AGENT_NODE = {
"id": "agent",
"type": "agent",
"name": "Agent",
"description": "Agent node",
"config": {"model": "gpt-4"},
}
_LSP_NODE = {
"id": "lsp_agent",
"type": "agent",
"name": "LSP Agent",
"description": "Agent with LSP binding",
"config": {"model": "gpt-4"},
"lsp_binding": {
"server": "local/pyright",
"languages": ["python"],
"capabilities": ["diagnostics", "hover"],
},
}
_TS_NODE = {
"id": "ts_agent",
"type": "agent",
"name": "Tool Source Agent",
"description": "Agent with tool sources",
"config": {},
"tool_sources": [
{"type": "skill", "name": "local/file-ops"},
{"type": "mcp", "name": "local/filesystem"},
{"type": "builtin", "group": "git_operations"},
],
}
_SUBGRAPH_NODE = {
"id": "sub",
"type": "subgraph",
"name": "Sub",
"description": "Subgraph node",
"actor_ref": "local/code-reviewer",
}
class TimeLspBindingParse:
"""Benchmark parsing actors with LSP bindings."""
def setup(self) -> None:
self.data = _graph_data([_LSP_NODE])
def time_parse_lsp_binding(self) -> None:
ActorConfigSchema.model_validate(self.data)
class TimeToolSourcesParse:
"""Benchmark parsing actors with tool-source references."""
def setup(self) -> None:
self.data = _graph_data([_TS_NODE])
def time_parse_tool_sources(self) -> None:
ActorConfigSchema.model_validate(self.data)
class TimeSubgraphRefParse:
"""Benchmark parsing actors with subgraph actor_ref."""
def setup(self) -> None:
self.data = _graph_data(
[_AGENT_NODE, _SUBGRAPH_NODE],
)
def time_parse_subgraph_ref(self) -> None:
ActorConfigSchema.model_validate(self.data)
class TimeFullHierarchyParse:
"""Benchmark parsing a full hierarchical actor with all features."""
def setup(self) -> None:
self.data = _graph_data(
[_LSP_NODE, _TS_NODE, _SUBGRAPH_NODE],
skills=["local/file-ops", "local/git-ops"],
)
self.data["lsp"] = ["local/pyright"]
self.data["lsp_capabilities"] = ["diagnostics"]
def time_parse_full_hierarchy(self) -> None:
ActorConfigSchema.model_validate(self.data)
time_lsp = TimeLspBindingParse()
time_ts = TimeToolSourcesParse()
time_sg = TimeSubgraphRefParse()
time_full = TimeFullHierarchyParse()
+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()
+165
View File
@@ -0,0 +1,165 @@
# Actor Hierarchy and Advanced YAML Extensions
This document covers the advanced actor YAML schema extensions introduced
in **M2.1.actor-yaml**: per-node LSP bindings, tool-source references,
subgraph actor references, and actor-level skill/LSP fields.
## Overview
Actor YAML configurations can define multi-node graph workflows. The
M2.1 extensions add fine-grained control over:
| Feature | Scope | Purpose |
|---------|-------|---------|
| `lsp_binding` | Per-node | Bind specific LSP servers to individual graph nodes |
| `tool_sources` | Per-node | Declare where a node's tools come from (skills, MCP, builtins) |
| `actor_ref` | Per-node (subgraph) | Reference another actor by namespaced name |
| `skills` | Actor-level | List of skill references available to the actor |
| `lsp` | Actor-level | Default LSP server bindings for the actor |
| `lsp_capabilities` | Actor-level | Filter which LSP capabilities are exposed |
| `lsp_context_enrichment` | Actor-level | Control automatic context injection |
## Per-Node LSP Binding
Different nodes in a graph can have different LSP configurations:
```yaml
nodes:
- id: strategist
type: agent
name: Strategy Planner
description: Plans with read-only LSP
lsp_binding:
server: local/pyright
languages:
- python
capabilities:
- diagnostics
- hover
- id: implementer
type: agent
name: Implementer
description: Full LSP auto-binding
lsp_binding:
auto: true
```
### `NodeLspBinding` Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `server` | `string` | `null` | Namespaced LSP server name (e.g. `local/pyright`) |
| `languages` | `list[string]` | `[]` | Languages for language-based resolution |
| `auto` | `bool` | `false` | Auto-resolve from project resources |
| `capabilities` | `list[string]` | `null` | Capability filter for this node |
## Tool-Source References
Nodes can declare their tool sources explicitly:
```yaml
nodes:
- id: executor
type: agent
name: Executor
description: Has multiple tool sources
tool_sources:
- type: skill
name: local/file-ops
- type: mcp
name: local/filesystem
- type: builtin
group: git_operations
```
### `ToolSourceRef` Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | `string` | Yes | `skill`, `mcp`, `builtin`, or `custom` |
| `name` | `string` | No | Namespaced name (for `skill` and `mcp`) |
| `group` | `string` | No | Group identifier (for `builtin`) |
## Subgraph Actor References
Subgraph nodes can reference other actors by namespaced name:
```yaml
nodes:
- id: review
type: subgraph
name: Code Review
description: Delegates to code-reviewer
actor_ref: local/code-reviewer
```
The `actor_ref` field must be in `namespace/name` format.
## Actor-Level Fields
### `skills`
```yaml
skills:
- local/file-ops
- local/git-ops
```
### `lsp`
Explicit binding (list of server names):
```yaml
lsp:
- local/pyright
- local/clangd
```
Auto binding:
```yaml
lsp:
auto: true
```
### `lsp_capabilities`
```yaml
lsp_capabilities:
- diagnostics
- hover
- definitions
```
Or expose all:
```yaml
lsp_capabilities: all
```
### `lsp_context_enrichment`
```yaml
lsp_context_enrichment:
diagnostics: true
type_annotations: false
max_diagnostics_per_file: 50
```
## Validation Error Messages
Validation errors now include field-path context:
| Error | Example Message |
|-------|-----------------|
| Bad entry node | `Entry node 'missing' not found in route.entry_node. Valid node IDs: [a, b]` |
| Bad exit node | `Exit node 'missing' not found in route.exit_nodes. Valid node IDs: [a, b]` |
| Bad edge ref | `Edge from_node 'ghost' not found at route.edges[0]. Valid node IDs: [a, b]` |
| Duplicate IDs | `route.nodes: Duplicate node IDs found: ['dup']. Each node ID must be unique.` |
| Cycle | `route: graph contains a cycle involving nodes: [a → b]. Hint: remove or redirect edges.` |
## Complete Example
See `examples/actors/hierarchical_workflow.yaml` for a full working example
combining all these features.
+119
View File
@@ -0,0 +1,119 @@
# 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()` | Start the server process or HTTP session, perform handshake |
| Disconnect | `disconnect()` | Clean shutdown of the server connection |
| 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 |
## 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.
## 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.
+102
View File
@@ -0,0 +1,102 @@
# Hierarchical Actor - Multi-Agent Development Workflow
# Demonstrates LSP bindings, tool-source references, subgraph nodes,
# and skills integration in a graph actor.
name: workflows/dev-workflow
type: graph
description: >-
Multi-agent development workflow with a strategist, implementer, and
reviewer. Each node has fine-grained LSP and tool-source control.
version: "1.0"
model: gpt-4
# Actor-level skill references
skills:
- local/file-ops
- local/git-ops
# Actor-level LSP binding (default for all nodes)
lsp:
- local/pyright
lsp_capabilities:
- diagnostics
- hover
- definitions
- references
lsp_context_enrichment:
diagnostics: true
type_annotations: false
max_diagnostics_per_file: 50
route:
nodes:
- id: strategist
type: agent
name: Strategy Planner
description: Plans the implementation strategy using read-only LSP
config:
model: gpt-4
prompt: |
Plan the implementation strategy. Use LSP diagnostics to assess
codebase health before proposing changes.
lsp_binding:
server: local/pyright
languages:
- python
capabilities:
- diagnostics
- hover
tool_sources:
- type: skill
name: local/file-ops
- id: implementer
type: agent
name: Code Implementer
description: Implements code changes with full LSP and tools
config:
model: gpt-4
prompt: |
Implement the changes as planned. Use full LSP capabilities
for navigation and refactoring.
lsp_binding:
auto: true
tool_sources:
- type: skill
name: local/file-ops
- type: skill
name: local/git-ops
- type: mcp
name: local/filesystem
- id: reviewer
type: subgraph
name: Code Review
description: Delegates to the code-reviewer actor for review
actor_ref: local/code-reviewer
edges:
- from_node: strategist
to_node: implementer
- from_node: implementer
to_node: reviewer
entry_node: strategist
exit_nodes:
- reviewer
context_view: strategist
memory:
enabled: true
max_messages: 50
max_tokens: 16000
summarize_old: true
context:
include_dirs:
- src/
- tests/
exclude_patterns:
- "**/__pycache__/**"
- "*.pyc"
max_context_tokens: 32000
+2 -1
View File
@@ -653,12 +653,13 @@ Feature: Actor YAML examples validation
Scenario: Verify all example files exist
Given the examples/actors directory
Then there should be exactly 5 example YAML files
Then there should be exactly 6 example YAML files
And the example files should include "simple_llm.yaml"
And the example files should include "llm_with_tools.yaml"
And the example files should include "tool_collection.yaml"
And the example files should include "simple_graph.yaml"
And the example files should include "graph_workflow.yaml"
And the example files should include "hierarchical_workflow.yaml"
Scenario: Verify all examples are documented
Given the documentation file "docs/reference/actors_examples.md"
+178
View File
@@ -0,0 +1,178 @@
Feature: Hierarchical Actor YAML Schema Extensions
As a developer configuring actors
I want to extend actor YAML with LSP bindings, tool-source references,
and improved subgraph support
So that graph nodes have fine-grained control over language servers and tools
# -------------------------------------------------------------------
# LSP Binding on NodeDefinition
# -------------------------------------------------------------------
Scenario: Node with explicit LSP binding
Given a hierarchy actor YAML with node lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "code_writer" should have lsp_binding
And the hierarchy node "code_writer" lsp_binding server should be "local/pyright"
And the hierarchy node "code_writer" lsp_binding languages should contain "python"
Scenario: Node with auto LSP binding
Given a hierarchy actor YAML with auto lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "implementer" should have lsp_binding
And the hierarchy node "implementer" lsp_binding auto should be true
Scenario: Node without LSP binding is valid
Given a hierarchy actor YAML with no lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "agent_node" should not have lsp_binding
Scenario: LSP binding with invalid server name
Given a hierarchy actor YAML with lsp_binding server "no-namespace"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "lsp_binding"
# -------------------------------------------------------------------
# Tool-Source References on NodeDefinition
# -------------------------------------------------------------------
Scenario: Node with tool_sources referencing skills
Given a hierarchy actor YAML with node tool_sources skills
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "executor" should have tool_sources
And the hierarchy node "executor" tool_sources should have 1 items
Scenario: Node with tool_sources referencing MCP server
Given a hierarchy actor YAML with node tool_sources mcp
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy node "executor" tool_sources item 0 type should be "mcp"
And the hierarchy node "executor" tool_sources item 0 name should be "local/filesystem"
Scenario: Node with tool_sources referencing builtin group
Given a hierarchy actor YAML with node tool_sources builtin
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy node "executor" tool_sources item 0 type should be "builtin"
And the hierarchy node "executor" tool_sources item 0 group should be "file_operations"
Scenario: Node with mixed tool_sources
Given a hierarchy actor YAML with mixed node tool_sources
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy node "executor" tool_sources should have 3 items
Scenario: Node with invalid tool_sources type
Given a hierarchy actor YAML with invalid tool_sources type
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "tool_sources"
Scenario: Node without tool_sources is valid
Given a hierarchy actor YAML with no lsp_binding
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "agent_node" should not have tool_sources
# -------------------------------------------------------------------
# Subgraph Node Enhancements
# -------------------------------------------------------------------
Scenario: Subgraph node with actor_ref
Given a hierarchy actor YAML with subgraph actor_ref
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy graph node "review" should have type "subgraph"
And the hierarchy node "review" actor_ref should be "local/code-reviewer"
Scenario: Subgraph node with actor_ref non-namespaced fails
Given a hierarchy actor YAML with subgraph actor_ref "bad-ref"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "actor_ref"
# -------------------------------------------------------------------
# Skills Field on ActorConfigSchema
# -------------------------------------------------------------------
Scenario: Actor with skills list
Given a hierarchy actor YAML with skills list
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor should have 2 skills
Scenario: Actor with empty skills is valid
Given a hierarchy actor YAML with empty skills
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor should have 0 skills
# -------------------------------------------------------------------
# LSP Fields on ActorConfigSchema (top-level)
# -------------------------------------------------------------------
Scenario: Actor with lsp list
Given a hierarchy actor YAML with lsp list
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp should have 2 items
Scenario: Actor with lsp auto binding
Given a hierarchy actor YAML with lsp auto
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp_auto should be true
Scenario: Actor with lsp_capabilities list
Given a hierarchy actor YAML with lsp_capabilities
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp_capabilities should contain "diagnostics"
Scenario: Actor with lsp_capabilities all
Given a hierarchy actor YAML with lsp_capabilities all
When I parse the hierarchy actor YAML
Then the hierarchy actor should be valid
And the hierarchy actor lsp_capabilities should be "all"
# -------------------------------------------------------------------
# Precise Validation Error Messages
# -------------------------------------------------------------------
Scenario: Missing entry node gives precise field path
Given a hierarchy actor YAML with bad entry_node "nonexistent"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "entry_node"
And the hierarchy parse error should mention "nonexistent"
Scenario: Duplicate node IDs gives precise error
Given a hierarchy actor YAML with duplicate node ids
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "Duplicate"
Scenario: Cycle detection gives node path
Given a hierarchy actor YAML with cycle
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "cycle"
Scenario: Edge referencing non-existent node gives precise error
Given a hierarchy actor YAML with bad edge from_node "ghost"
When I parse the hierarchy actor YAML expecting failure
Then the hierarchy parse error should mention "ghost"
# -------------------------------------------------------------------
# Loader Integration with Hierarchical Actors
# -------------------------------------------------------------------
Scenario: Loader discovers hierarchical actor YAML
Given a hierarchy temp dir with a hierarchical actor YAML file
When I create a hierarchy loader and run discovery
Then the hierarchy loader should find 1 actors
And the hierarchy loaded actor should have skills
And the hierarchy loaded actor should have lsp
Scenario: Loader rejects actor with invalid lsp_binding
Given a hierarchy temp dir with invalid lsp_binding actor YAML
When I create a hierarchy loader and run discovery expecting error
Then the hierarchy loader error should mention "lsp_binding"
+239
View File
@@ -0,0 +1,239 @@
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"
+635
View File
@@ -0,0 +1,635 @@
"""Step definitions for features/actor_hierarchy.feature."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
import yaml
from behave import given, then, when
from behave.runner import Context
from cleveragents.actor.schema import ActorConfigSchema, NodeType
from cleveragents.core.exceptions import ValidationError
def _base_graph_yaml(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]] | None = None,
entry_node: str = "agent_node",
exit_nodes: list[str] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
if edges is None:
ids = [n["id"] for n in nodes]
edges = []
for i in range(len(ids) - 1):
edges.append({"from_node": ids[i], "to_node": ids[i + 1]})
data: dict[str, Any] = {
"name": "local/hierarchy-test",
"type": "graph",
"description": "Test hierarchical actor",
"model": "gpt-4",
"route": {
"nodes": nodes,
"edges": edges,
"entry_node": entry_node,
"exit_nodes": exit_nodes or [nodes[-1]["id"]],
},
}
if extra:
data.update(extra)
return data
def _simple_agent_node(node_id: str = "agent_node", **overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"id": node_id,
"type": "agent",
"name": f"Agent {node_id}",
"description": f"Agent node {node_id}",
"config": {"model": "gpt-4"},
}
base.update(overrides)
return base
def _get_node(config: ActorConfigSchema, node_id: str) -> Any:
if config.route is None:
return None
for node in config.route.nodes:
if node.id == node_id:
return node
return None
# ---------------------------------------------------------------
# LSP Binding on NodeDefinition
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with node lsp_binding")
def step_hierarchy_yaml_node_lsp(context: Context) -> None:
node = _simple_agent_node(
"code_writer",
lsp_binding={
"server": "local/pyright",
"languages": ["python"],
},
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="code_writer")
@given("a hierarchy actor YAML with auto lsp_binding")
def step_hierarchy_yaml_auto_lsp(context: Context) -> None:
node = _simple_agent_node(
"implementer",
lsp_binding={"auto": True},
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="implementer")
@given("a hierarchy actor YAML with no lsp_binding")
def step_hierarchy_yaml_no_lsp(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml([node])
@given('a hierarchy actor YAML with lsp_binding server "{server}"')
def step_hierarchy_yaml_lsp_bad_server(context: Context, server: str) -> None:
node = _simple_agent_node(
"code_writer",
lsp_binding={"server": server, "languages": ["python"]},
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="code_writer")
# ---------------------------------------------------------------
# Tool-Source References on NodeDefinition
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with node tool_sources skills")
def step_hierarchy_yaml_tool_sources_skills(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[{"type": "skill", "name": "local/file-ops"}],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with node tool_sources mcp")
def step_hierarchy_yaml_tool_sources_mcp(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[{"type": "mcp", "name": "local/filesystem"}],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with node tool_sources builtin")
def step_hierarchy_yaml_tool_sources_builtin(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[
{"type": "builtin", "group": "file_operations"},
],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with mixed node tool_sources")
def step_hierarchy_yaml_tool_sources_mixed(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[
{"type": "skill", "name": "local/file-ops"},
{"type": "mcp", "name": "local/filesystem"},
{"type": "builtin", "group": "git_operations"},
],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
@given("a hierarchy actor YAML with invalid tool_sources type")
def step_hierarchy_yaml_tool_sources_invalid(context: Context) -> None:
node = _simple_agent_node(
"executor",
tool_sources=[{"type": "invalid_type", "name": "x/y"}],
)
context.hierarchy_yaml = _base_graph_yaml([node], entry_node="executor")
# ---------------------------------------------------------------
# Subgraph Node Enhancements
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with subgraph actor_ref")
def step_hierarchy_yaml_subgraph_ref(context: Context) -> None:
planner = _simple_agent_node("planner")
review: dict[str, Any] = {
"id": "review",
"type": "subgraph",
"name": "Review",
"description": "Code review subgraph",
"actor_ref": "local/code-reviewer",
}
context.hierarchy_yaml = _base_graph_yaml(
[planner, review],
edges=[{"from_node": "planner", "to_node": "review"}],
entry_node="planner",
exit_nodes=["review"],
)
@given('a hierarchy actor YAML with subgraph actor_ref "{bad_ref}"')
def step_hierarchy_yaml_subgraph_bad_ref(context: Context, bad_ref: str) -> None:
planner = _simple_agent_node("planner")
review: dict[str, Any] = {
"id": "review",
"type": "subgraph",
"name": "Review",
"description": "Code review subgraph",
"actor_ref": bad_ref,
}
context.hierarchy_yaml = _base_graph_yaml(
[planner, review],
edges=[{"from_node": "planner", "to_node": "review"}],
entry_node="planner",
exit_nodes=["review"],
)
# ---------------------------------------------------------------
# Skills Field on ActorConfigSchema
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with skills list")
def step_hierarchy_yaml_skills(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={"skills": ["local/file-ops", "local/git-ops"]},
)
@given("a hierarchy actor YAML with empty skills")
def step_hierarchy_yaml_empty_skills(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml([node], extra={"skills": []})
# ---------------------------------------------------------------
# LSP Fields on ActorConfigSchema (top-level)
# ---------------------------------------------------------------
@given("a hierarchy actor YAML with lsp list")
def step_hierarchy_yaml_lsp_list(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={"lsp": ["local/pyright", "local/clangd"]},
)
@given("a hierarchy actor YAML with lsp auto")
def step_hierarchy_yaml_lsp_auto(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml([node], extra={"lsp": {"auto": True}})
@given("a hierarchy actor YAML with lsp_capabilities")
def step_hierarchy_yaml_lsp_caps(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={
"lsp": ["local/pyright"],
"lsp_capabilities": ["diagnostics", "hover"],
},
)
@given("a hierarchy actor YAML with lsp_capabilities all")
def step_hierarchy_yaml_lsp_caps_all(context: Context) -> None:
node = _simple_agent_node("agent_node")
context.hierarchy_yaml = _base_graph_yaml(
[node],
extra={
"lsp": ["local/pyright"],
"lsp_capabilities": "all",
},
)
# ---------------------------------------------------------------
# Precise Validation Error Messages
# ---------------------------------------------------------------
@given('a hierarchy actor YAML with bad entry_node "{bad_node}"')
def step_hierarchy_yaml_bad_entry(context: Context, bad_node: str) -> None:
node = _simple_agent_node("agent_node")
data = _base_graph_yaml([node])
data["route"]["entry_node"] = bad_node
context.hierarchy_yaml = data
@given("a hierarchy actor YAML with duplicate node ids")
def step_hierarchy_yaml_dup_ids(context: Context) -> None:
n1 = _simple_agent_node("dup_node")
n2 = _simple_agent_node("dup_node")
n2["name"] = "Second Dup"
context.hierarchy_yaml = _base_graph_yaml([n1, n2], entry_node="dup_node")
@given("a hierarchy actor YAML with cycle")
def step_hierarchy_yaml_cycle(context: Context) -> None:
n1 = _simple_agent_node("a")
n2 = _simple_agent_node("b")
context.hierarchy_yaml = _base_graph_yaml(
[n1, n2],
edges=[
{"from_node": "a", "to_node": "b"},
{"from_node": "b", "to_node": "a"},
],
entry_node="a",
exit_nodes=["b"],
)
@given('a hierarchy actor YAML with bad edge from_node "{bad_node}"')
def step_hierarchy_yaml_bad_edge(context: Context, bad_node: str) -> None:
n1 = _simple_agent_node("real_node")
context.hierarchy_yaml = _base_graph_yaml(
[n1],
edges=[{"from_node": bad_node, "to_node": "real_node"}],
entry_node="real_node",
)
# ---------------------------------------------------------------
# When Steps
# ---------------------------------------------------------------
@when("I parse the hierarchy actor YAML")
def step_parse_hierarchy_yaml(context: Context) -> None:
context.hierarchy_error = None
try:
context.hierarchy_config = ActorConfigSchema.model_validate(
context.hierarchy_yaml
)
except Exception as exc:
context.hierarchy_error = str(exc)
context.hierarchy_config = None
@when("I parse the hierarchy actor YAML expecting failure")
def step_parse_hierarchy_yaml_fail(context: Context) -> None:
context.hierarchy_error = None
try:
context.hierarchy_config = ActorConfigSchema.model_validate(
context.hierarchy_yaml
)
context.hierarchy_error = None
except Exception as exc:
context.hierarchy_error = str(exc)
context.hierarchy_config = None
# ---------------------------------------------------------------
# Then Steps - Validation
# ---------------------------------------------------------------
@then("the hierarchy actor should be valid")
def step_hierarchy_valid(context: Context) -> None:
assert context.hierarchy_config is not None, (
f"Expected valid config, got error: {context.hierarchy_error}"
)
@then('the hierarchy graph node "{node_id}" should have lsp_binding')
def step_hierarchy_node_has_lsp(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None, f"Node '{node_id}' not found"
assert node.lsp_binding is not None, f"Node '{node_id}' has no lsp_binding"
@then('the hierarchy node "{node_id}" lsp_binding server should be "{server}"')
def step_hierarchy_lsp_server(context: Context, node_id: str, server: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.lsp_binding is not None
assert node.lsp_binding.server == server
@then('the hierarchy node "{node_id}" lsp_binding languages should contain "{lang}"')
def step_hierarchy_lsp_lang(context: Context, node_id: str, lang: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.lsp_binding is not None
assert lang in node.lsp_binding.languages
@then('the hierarchy node "{node_id}" lsp_binding auto should be true')
def step_hierarchy_lsp_auto(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.lsp_binding is not None
assert node.lsp_binding.auto is True
@then('the hierarchy graph node "{node_id}" should not have lsp_binding')
def step_hierarchy_node_no_lsp(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None, f"Node '{node_id}' not found"
assert node.lsp_binding is None
@then('the hierarchy graph node "{node_id}" should have tool_sources')
def step_hierarchy_node_has_tool_sources(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None, f"Node '{node_id}' not found"
assert node.tool_sources, f"Node '{node_id}' has no tool_sources"
@then('the hierarchy node "{node_id}" tool_sources should have {count:d} items')
def step_hierarchy_tool_sources_count(
context: Context, node_id: str, count: int
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert len(node.tool_sources) == count
@then(
'the hierarchy node "{node_id}" tool_sources item {idx:d} '
'type should be "{ts_type}"'
)
def step_hierarchy_tool_source_type(
context: Context, node_id: str, idx: int, ts_type: str
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.tool_sources[idx].type == ts_type
@then(
'the hierarchy node "{node_id}" tool_sources item {idx:d} '
'name should be "{ts_name}"'
)
def step_hierarchy_tool_source_name(
context: Context, node_id: str, idx: int, ts_name: str
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.tool_sources[idx].name == ts_name
@then(
'the hierarchy node "{node_id}" tool_sources item {idx:d} '
'group should be "{ts_group}"'
)
def step_hierarchy_tool_source_group(
context: Context, node_id: str, idx: int, ts_group: str
) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.tool_sources[idx].group == ts_group
@then('the hierarchy graph node "{node_id}" should not have tool_sources')
def step_hierarchy_node_no_tool_sources(context: Context, node_id: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert not node.tool_sources
@then('the hierarchy graph node "{node_id}" should have type "{ntype}"')
def step_hierarchy_node_type(context: Context, node_id: str, ntype: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.type == NodeType(ntype)
@then('the hierarchy node "{node_id}" actor_ref should be "{ref}"')
def step_hierarchy_node_actor_ref(context: Context, node_id: str, ref: str) -> None:
node = _get_node(context.hierarchy_config, node_id)
assert node is not None
assert node.actor_ref == ref
@then("the hierarchy actor should have {count:d} skills")
def step_hierarchy_actor_skills_count(context: Context, count: int) -> None:
assert len(context.hierarchy_config.skills) == count
@then("the hierarchy actor lsp should have {count:d} items")
def step_hierarchy_actor_lsp_count(context: Context, count: int) -> None:
assert isinstance(context.hierarchy_config.lsp, list)
assert len(context.hierarchy_config.lsp) == count
@then("the hierarchy actor lsp_auto should be true")
def step_hierarchy_actor_lsp_auto(context: Context) -> None:
assert isinstance(context.hierarchy_config.lsp, dict)
assert context.hierarchy_config.lsp.get("auto") is True
@then('the hierarchy actor lsp_capabilities should contain "{cap}"')
def step_hierarchy_actor_lsp_caps_contain(context: Context, cap: str) -> None:
assert isinstance(context.hierarchy_config.lsp_capabilities, list)
assert cap in context.hierarchy_config.lsp_capabilities
@then('the hierarchy actor lsp_capabilities should be "{value}"')
def step_hierarchy_actor_lsp_caps_str(context: Context, value: str) -> None:
assert context.hierarchy_config.lsp_capabilities == value
@then('the hierarchy parse error should mention "{text}"')
def step_hierarchy_error_mention(context: Context, text: str) -> None:
assert context.hierarchy_error is not None, "Expected error but none occurred"
assert text.lower() in context.hierarchy_error.lower(), (
f"Error '{context.hierarchy_error}' does not mention '{text}'"
)
# ---------------------------------------------------------------
# Loader Integration
# ---------------------------------------------------------------
def _hierarchy_tmpdir() -> Path:
return Path(tempfile.mkdtemp(prefix="hierarchy_actor_"))
@given("a hierarchy temp dir with a hierarchical actor YAML file")
def step_hierarchy_loader_setup(context: Context) -> None:
from cleveragents.actor.loader import ActorLoader
tmpdir = _hierarchy_tmpdir()
actor_data: dict[str, Any] = {
"name": "local/hierarchy-demo",
"type": "graph",
"description": "Hierarchical demo",
"model": "gpt-4",
"skills": ["local/file-ops"],
"lsp": ["local/pyright"],
"lsp_capabilities": ["diagnostics"],
"route": {
"nodes": [
{
"id": "planner",
"type": "agent",
"name": "Planner",
"description": "Plans tasks",
"config": {"model": "gpt-4"},
"lsp_binding": {
"server": "local/pyright",
"languages": ["python"],
},
"tool_sources": [
{"type": "skill", "name": "local/file-ops"},
],
},
],
"edges": [],
"entry_node": "planner",
"exit_nodes": ["planner"],
},
}
yaml_path = tmpdir / "hierarchy_demo.yaml"
with yaml_path.open("w") as f:
yaml.safe_dump(actor_data, f)
context.hierarchy_tmpdir = tmpdir
context.hierarchy_loader_cls = ActorLoader
@given("a hierarchy temp dir with invalid lsp_binding actor YAML")
def step_hierarchy_loader_invalid(context: Context) -> None:
from cleveragents.actor.loader import ActorLoader
tmpdir = _hierarchy_tmpdir()
actor_data: dict[str, Any] = {
"name": "local/bad-lsp",
"type": "graph",
"description": "Bad LSP binding",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "code_node",
"type": "agent",
"name": "Coder",
"description": "Codes stuff",
"config": {},
"lsp_binding": {
"server": "no-namespace",
"languages": ["python"],
},
},
],
"edges": [],
"entry_node": "code_node",
"exit_nodes": ["code_node"],
},
}
yaml_path = tmpdir / "bad_lsp.yaml"
with yaml_path.open("w") as f:
yaml.safe_dump(actor_data, f)
context.hierarchy_tmpdir = tmpdir
context.hierarchy_loader_cls = ActorLoader
@when("I create a hierarchy loader and run discovery")
def step_hierarchy_loader_discover(context: Context) -> None:
loader = context.hierarchy_loader_cls(search_roots=[context.hierarchy_tmpdir])
context.hierarchy_loaded_actors = loader.discover()
context.hierarchy_loader_error = None
@when("I create a hierarchy loader and run discovery expecting error")
def step_hierarchy_loader_discover_error(context: Context) -> None:
loader = context.hierarchy_loader_cls(search_roots=[context.hierarchy_tmpdir])
try:
context.hierarchy_loaded_actors = loader.discover()
context.hierarchy_loader_error = None
except (ValidationError, Exception) as exc:
context.hierarchy_loaded_actors = []
context.hierarchy_loader_error = str(exc)
@then("the hierarchy loader should find {count:d} actors")
def step_hierarchy_loader_count(context: Context, count: int) -> None:
assert len(context.hierarchy_loaded_actors) == count, (
f"Expected {count} actors, got {len(context.hierarchy_loaded_actors)}"
)
@then("the hierarchy loaded actor should have skills")
def step_hierarchy_loaded_has_skills(context: Context) -> None:
actor = context.hierarchy_loaded_actors[0]
assert actor.skills, "Loaded actor should have skills"
@then("the hierarchy loaded actor should have lsp")
def step_hierarchy_loaded_has_lsp(context: Context) -> None:
actor = context.hierarchy_loaded_actors[0]
assert actor.lsp is not None, "Loaded actor should have lsp"
@then('the hierarchy loader error should mention "{text}"')
def step_hierarchy_loader_error_mention(context: Context, text: str) -> None:
assert context.hierarchy_loader_error is not None, (
"Expected loader error but none occurred"
)
assert text.lower() in context.hierarchy_loader_error.lower(), (
f"Error '{context.hierarchy_loader_error}' does not mention '{text}'"
)
+506
View File
@@ -0,0 +1,506 @@
"""Step definitions for features/mcp_adapter.feature."""
from __future__ import annotations
import json
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")
# ---------------------------------------------------------------
# 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 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
+48
View File
@@ -0,0 +1,48 @@
*** Settings ***
Documentation Smoke tests for hierarchical actor YAML extensions
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_actor_hierarchy.py
*** Test Cases ***
Discover Hierarchical Actor Example
[Documentation] Load hierarchical workflow example and verify extensions
${result}= Run Process ${PYTHON} ${HELPER} discover-hierarchical cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-loaded: workflows/dev-workflow
Should Contain ${result.stdout} skills-count: 2
Should Contain ${result.stdout} lsp-configured: True
Parse Actor With LSP Binding
[Documentation] Validate that per-node LSP bindings parse correctly
${result}= Run Process ${PYTHON} ${HELPER} parse-lsp-binding cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-binding-server: local/pyright
Should Contain ${result.stdout} lsp-binding-langs: python
Parse Actor With Tool Sources
[Documentation] Validate that per-node tool-source references parse correctly
${result}= Run Process ${PYTHON} ${HELPER} parse-tool-sources cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tool-sources-count: 2
Parse Actor With Subgraph Actor Ref
[Documentation] Validate subgraph node with actor_ref
${result}= Run Process ${PYTHON} ${HELPER} parse-subgraph-ref cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} actor-ref: local/code-reviewer
Reject Invalid LSP Binding
[Documentation] Reject actor with non-namespaced LSP server
${result}= Run Process ${PYTHON} ${HELPER} reject-bad-lsp cwd=${WORKSPACE}
Log ${result.stdout}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} validation-error: lsp_binding
+193
View File
@@ -0,0 +1,193 @@
"""Robot Framework helper for hierarchical actor YAML smoke tests.
Usage:
python robot/helper_actor_hierarchy.py discover-hierarchical
python robot/helper_actor_hierarchy.py parse-lsp-binding
python robot/helper_actor_hierarchy.py parse-tool-sources
python robot/helper_actor_hierarchy.py parse-subgraph-ref
python robot/helper_actor_hierarchy.py reject-bad-lsp
"""
from __future__ import annotations
import sys
from pathlib import Path
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from cleveragents.actor.loader import ActorLoader # noqa: E402
from cleveragents.actor.schema import ActorConfigSchema # noqa: E402
def main() -> int:
if len(sys.argv) < 2:
print("Usage: helper_actor_hierarchy.py <command>")
return 1
cmd = sys.argv[1]
dispatch = {
"discover-hierarchical": _discover_hierarchical,
"parse-lsp-binding": _parse_lsp_binding,
"parse-tool-sources": _parse_tool_sources,
"parse-subgraph-ref": _parse_subgraph_ref,
"reject-bad-lsp": _reject_bad_lsp,
}
handler = dispatch.get(cmd)
if handler is None:
print(f"Unknown command: {cmd}")
return 1
return handler()
def _discover_hierarchical() -> int:
project_root = Path(__file__).resolve().parents[1]
examples_dir = project_root / "examples" / "actors"
loader = ActorLoader(search_roots=[examples_dir])
actors = loader.discover()
for actor in actors:
if actor.name == "workflows/dev-workflow":
print(f"actor-loaded: {actor.name}")
print(f"skills-count: {len(actor.skills)}")
print(f"lsp-configured: {actor.lsp is not None}")
return 0
print("actor-not-found: workflows/dev-workflow")
return 1
def _parse_lsp_binding() -> int:
data = {
"name": "local/lsp-test",
"type": "graph",
"description": "LSP binding test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "coder",
"type": "agent",
"name": "Coder",
"description": "Codes with LSP",
"config": {"model": "gpt-4"},
"lsp_binding": {
"server": "local/pyright",
"languages": ["python"],
},
},
],
"edges": [],
"entry_node": "coder",
"exit_nodes": ["coder"],
},
}
config = ActorConfigSchema.model_validate(data)
node = config.route.nodes[0] # type: ignore[union-attr]
if node.lsp_binding:
print(f"lsp-binding-server: {node.lsp_binding.server}")
print(f"lsp-binding-langs: {', '.join(node.lsp_binding.languages)}")
return 0
def _parse_tool_sources() -> int:
data = {
"name": "local/ts-test",
"type": "graph",
"description": "Tool sources test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "exec",
"type": "agent",
"name": "Executor",
"description": "Executor with tool sources",
"config": {},
"tool_sources": [
{"type": "skill", "name": "local/file-ops"},
{"type": "mcp", "name": "local/filesystem"},
],
},
],
"edges": [],
"entry_node": "exec",
"exit_nodes": ["exec"],
},
}
config = ActorConfigSchema.model_validate(data)
node = config.route.nodes[0] # type: ignore[union-attr]
print(f"tool-sources-count: {len(node.tool_sources)}")
return 0
def _parse_subgraph_ref() -> int:
data = {
"name": "local/sg-test",
"type": "graph",
"description": "Subgraph ref test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "main",
"type": "agent",
"name": "Main",
"description": "Main agent",
"config": {},
},
{
"id": "review",
"type": "subgraph",
"name": "Review",
"description": "Subgraph review",
"actor_ref": "local/code-reviewer",
},
],
"edges": [{"from_node": "main", "to_node": "review"}],
"entry_node": "main",
"exit_nodes": ["review"],
},
}
config = ActorConfigSchema.model_validate(data)
node = config.route.nodes[1] # type: ignore[union-attr]
print(f"actor-ref: {node.actor_ref}")
return 0
def _reject_bad_lsp() -> int:
data = {
"name": "local/bad-lsp-test",
"type": "graph",
"description": "Bad LSP test",
"model": "gpt-4",
"route": {
"nodes": [
{
"id": "n",
"type": "agent",
"name": "N",
"description": "Bad LSP",
"config": {},
"lsp_binding": {"server": "no-slash"},
},
],
"edges": [],
"entry_node": "n",
"exit_nodes": ["n"],
},
}
try:
ActorConfigSchema.model_validate(data)
print("validation-unexpected-success")
return 1
except Exception as exc:
err = str(exc).lower()
if "lsp_binding" in err:
print("validation-error: lsp_binding")
else:
print(f"validation-error-other: {exc}")
return 0
if __name__ == "__main__":
sys.exit(main())
+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
+162 -12
View File
@@ -272,6 +272,89 @@ class ContextConfigSchema(BaseModel):
)
# ============================================================================
# LSP and Tool-Source Models (for per-node bindings)
# ============================================================================
class NodeLspBinding(BaseModel):
"""Per-node LSP server binding configuration.
Allows individual graph nodes to override or specify their own LSP
server bindings independently of the actor-level ``lsp`` setting.
Three binding modes:
1. Explicit: set ``server`` to a namespaced server name.
2. Language-based: set ``languages`` to match servers by language.
3. Auto: set ``auto=True`` to let the runtime resolve automatically.
Attributes:
server: Namespaced LSP server name (e.g. ``local/pyright``).
languages: Languages for language-based binding resolution.
auto: If ``True``, runtime resolves servers from project resources.
capabilities: Optional capability filter for this node.
"""
server: str | None = Field(default=None, description="LSP server name")
languages: list[str] = Field(
default_factory=list, description="Languages for binding"
)
auto: bool = Field(default=False, description="Auto-resolve binding")
capabilities: list[str] | None = Field(
default=None, description="LSP capability filter"
)
@field_validator("server")
@classmethod
def validate_server_namespace(cls, v: str | None) -> str | None:
if v is not None and "/" not in v:
msg = f"lsp_binding.server must be namespaced (namespace/name): '{v}'"
raise ValueError(msg)
return v
class ToolSourceRef(BaseModel):
"""Reference to a tool source available to a graph node.
Allows nodes to declare where their tools come from — skills,
MCP servers, or builtin tool groups.
Attributes:
type: Source type (``skill``, ``mcp``, ``builtin``, ``custom``).
name: Namespaced name for skill/mcp sources.
group: Group identifier for builtin sources.
"""
type: str = Field(..., description="Source type: skill, mcp, builtin, custom")
name: str | None = Field(default=None, description="Namespaced source name")
group: str | None = Field(default=None, description="Builtin group name")
@field_validator("type")
@classmethod
def validate_source_type(cls, v: str) -> str:
allowed = {"skill", "mcp", "builtin", "custom"}
if v not in allowed:
msg = f"tool_sources type must be one of {sorted(allowed)}, got '{v}'"
raise ValueError(msg)
return v
class LspContextEnrichment(BaseModel):
"""Controls automatic LSP context enrichment injected into ACMS.
Attributes:
diagnostics: Inject file diagnostics into context (default True).
type_annotations: Inject type info into context (default False).
max_diagnostics_per_file: Cap on diagnostics per file (default 50).
"""
diagnostics: bool = Field(default=True, description="Inject diagnostics")
type_annotations: bool = Field(default=False, description="Inject type annotations")
max_diagnostics_per_file: int = Field(
default=50, description="Max diagnostics per file"
)
# ============================================================================
# Graph Models (for ActorType.GRAPH - multi-node workflows)
# ============================================================================
@@ -357,6 +440,16 @@ class NodeDefinition(BaseModel):
name: str = Field(..., description="Node name")
description: str = Field(..., description="Node description")
config: dict[str, Any] = Field(default_factory=dict, description="Node config")
lsp_binding: NodeLspBinding | None = Field(
default=None, description="Per-node LSP binding"
)
tool_sources: list[ToolSourceRef] = Field(
default_factory=list, description="Tool source references"
)
actor_ref: str | None = Field(
default=None,
description="Namespaced actor reference for subgraph nodes",
)
@field_validator("id")
@classmethod
@@ -367,6 +460,15 @@ class NodeDefinition(BaseModel):
raise ValueError(msg)
return v
@field_validator("actor_ref")
@classmethod
def validate_actor_ref(cls, v: str | None) -> str | None:
"""Ensure actor_ref follows namespace/name format when set."""
if v is not None and "/" not in v:
msg = f"actor_ref must be namespaced (namespace/name): '{v}'"
raise ValueError(msg)
return v
class RouteDefinition(BaseModel):
"""
@@ -427,8 +529,16 @@ class RouteDefinition(BaseModel):
"""Ensure all node IDs are unique."""
ids = [node.id for node in v]
if len(ids) != len(set(ids)):
duplicates = [id for id in ids if ids.count(id) > 1]
msg = f"Duplicate node IDs found: {set(duplicates)}"
seen: set[str] = set()
duplicates: list[str] = []
for nid in ids:
if nid in seen and nid not in duplicates:
duplicates.append(nid)
seen.add(nid)
msg = (
f"route.nodes: Duplicate node IDs found: "
f"{duplicates}. Each node ID must be unique."
)
raise ValueError(msg)
return v
@@ -437,28 +547,39 @@ class RouteDefinition(BaseModel):
Validate all node references in edges and entry/exit points.
Raises:
ValueError: If any reference points to non-existent node
ValueError: If any reference points to non-existent node.
Messages include field paths for precise debugging.
"""
node_ids = {node.id for node in self.nodes}
valid_list = ", ".join(sorted(node_ids)) if node_ids else "(none)"
# Validate entry node
if self.entry_node not in node_ids:
msg = f"Entry node '{self.entry_node}' not found in nodes"
msg = (
f"Entry node '{self.entry_node}' not found in "
f"route.entry_node. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
# Validate exit nodes
for exit_node in self.exit_nodes:
if exit_node not in node_ids:
msg = f"Exit node '{exit_node}' not found in nodes"
msg = (
f"Exit node '{exit_node}' not found in "
f"route.exit_nodes. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
# Validate edge references
for edge in self.edges:
for idx, edge in enumerate(self.edges):
if edge.from_node not in node_ids:
msg = f"Edge from_node '{edge.from_node}' not found in nodes"
msg = (
f"Edge from_node '{edge.from_node}' not found at "
f"route.edges[{idx}]. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
if edge.to_node not in node_ids:
msg = f"Edge to_node '{edge.to_node}' not found in nodes"
msg = (
f"Edge to_node '{edge.to_node}' not found at "
f"route.edges[{idx}]. Valid node IDs: [{valid_list}]"
)
raise ValueError(msg)
def detect_cycles(self) -> list[str]:
@@ -582,6 +703,27 @@ class ActorConfigSchema(BaseModel):
default=None, description="Graph topology (for GRAPH type)"
)
# Skills
skills: list[str] = Field(
default_factory=list,
description="Namespaced skill references (e.g. local/file-ops)",
)
# LSP configuration
lsp: list[str] | dict[str, Any] | None = Field(
default=None,
description="LSP server bindings: list of server names, "
"language-based object, or {auto: true}",
)
lsp_capabilities: list[str] | str | None = Field(
default=None,
description="LSP capability filter: list or 'all'",
)
lsp_context_enrichment: LspContextEnrichment | None = Field(
default=None,
description="Automatic LSP context enrichment settings",
)
# Environment variables
env_vars: dict[str, str] = Field(
default_factory=dict, description="Environment variable mappings"
@@ -639,7 +781,12 @@ class ActorConfigSchema(BaseModel):
self.route.validate_references()
cycles = self.route.detect_cycles()
if cycles:
msg = f"Graph contains cycles involving nodes: {cycles}"
nodes_str = "".join(cycles)
msg = (
f"route: graph contains a cycle involving "
f"nodes: [{nodes_str}]. "
f"Hint: remove or redirect edges to break the cycle."
)
raise ValueError(msg)
return self
@@ -702,10 +849,13 @@ __all__ = [
"ContextConfigSchema",
"ContextView",
"EdgeDefinition",
"LspContextEnrichment",
"MemoryConfig",
"NodeDefinition",
"NodeLspBinding",
"NodeType",
"RouteDefinition",
"ToolDefinition",
"ToolParameter",
"ToolSourceRef",
]
+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",
]
+461
View File
@@ -0,0 +1,461 @@
"""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
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) -> None:
"""Connect to the MCP server and perform the initialize handshake.
Raises ``ConnectionError`` if the transport fails to connect.
"""
with self._lock:
if self._connected:
return
try:
caps = self._transport.connect(self._config)
except Exception as exc:
msg = f"MCP connection to '{self._config.name}' failed: {exc}"
raise ConnectionError(msg) from exc
self._capabilities = 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 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.
"""
from cleveragents.domain.models.core.tool import ToolCapability
from cleveragents.tool.runtime import ToolSpec
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)
capabilities = ToolCapability(
read_only=inferred["read_only"],
writes=inferred["writes"],
)
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),
)
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