diff --git a/benchmarks/actor_hierarchy_bench.py b/benchmarks/actor_hierarchy_bench.py new file mode 100644 index 000000000..7892ae9fe --- /dev/null +++ b/benchmarks/actor_hierarchy_bench.py @@ -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() diff --git a/benchmarks/mcp_runtime_bench.py b/benchmarks/mcp_runtime_bench.py new file mode 100644 index 000000000..385feaa4c --- /dev/null +++ b/benchmarks/mcp_runtime_bench.py @@ -0,0 +1,153 @@ +"""ASV benchmarks for MCP Tool Adapter performance. + +Measures the performance of: +- MCPServerConfig creation and validation +- Tool discovery from a mock transport +- Tool invocation with schema validation +- Tool registration into ToolRegistry +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from typing import Any + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +import cleveragents # noqa: E402 + +importlib.reload(cleveragents) + +from cleveragents.mcp.adapter import ( # noqa: E402 + MCPServerConfig, + MCPToolAdapter, + MCPTransport, +) +from cleveragents.tool.registry import ToolRegistry # noqa: E402 + + +class _BenchTransport(MCPTransport): + """Minimal transport for benchmarks — returns canned data.""" + + def __init__(self, tools: list[dict[str, Any]]) -> None: + self._tools = tools + + def connect(self, config: MCPServerConfig) -> dict[str, Any]: + return {"capabilities": {"tools": True}} + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "tools/list": + return {"tools": self._tools} + if method == "tools/call": + return {"content": {"status": "ok"}} + return {} + + def close(self) -> None: + pass + + +def _mock_tool(name: str, schema: dict[str, Any] | None = None) -> dict[str, Any]: + return { + "name": name, + "description": f"Bench tool {name}", + "inputSchema": schema or {}, + } + + +_TOOLS_3 = [_mock_tool(f"tool_{i}") for i in range(3)] +_TOOLS_50 = [_mock_tool(f"tool_{i}") for i in range(50)] +_SCHEMA_TOOL = _mock_tool( + "validated", + { + "type": "object", + "properties": {"x": {"type": "integer"}, "y": {"type": "string"}}, + "required": ["x"], + }, +) + + +class TimeConfigCreation: + """Benchmark MCPServerConfig instantiation.""" + + def time_create_stdio_config(self) -> None: + MCPServerConfig(name="bench", transport="stdio", command="echo") + + def time_create_sse_config(self) -> None: + MCPServerConfig( + name="bench", + transport="sse", + url="https://example.com/mcp", + headers={"Authorization": "Bearer tok"}, + ) + + +class TimeToolDiscovery: + """Benchmark tool discovery with varying tool counts.""" + + def setup(self) -> None: + self._config = MCPServerConfig(name="bench", transport="stdio", command="echo") + + def time_discover_3_tools(self) -> None: + t = _BenchTransport(_TOOLS_3) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + adapter.discover_tools() + + def time_discover_50_tools(self) -> None: + t = _BenchTransport(_TOOLS_50) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + adapter.discover_tools() + + +class TimeToolInvocation: + """Benchmark tool invocation with and without schema validation.""" + + def setup(self) -> None: + config = MCPServerConfig(name="bench", transport="stdio", command="echo") + self._transport = _BenchTransport([_SCHEMA_TOOL]) + self._adapter = MCPToolAdapter(config=config, transport=self._transport) + self._adapter.connect() + self._adapter.discover_tools() + + def time_invoke_with_validation(self) -> None: + self._adapter.invoke("validated", {"x": 42, "y": "hello"}) + + def time_invoke_no_schema(self) -> None: + no_schema = _BenchTransport([_mock_tool("plain")]) + config = MCPServerConfig(name="bench", transport="stdio", command="echo") + adapter = MCPToolAdapter(config=config, transport=no_schema) + adapter.connect() + adapter.discover_tools() + adapter.invoke("plain", {"any": "data"}) + + +class TimeToolRegistration: + """Benchmark registering discovered tools into ToolRegistry.""" + + def setup(self) -> None: + self._config = MCPServerConfig(name="bench", transport="stdio", command="echo") + + def time_register_3_tools(self) -> None: + t = _BenchTransport(_TOOLS_3) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + registry = ToolRegistry() + adapter.register_tools(registry, namespace="bench") + + def time_register_50_tools(self) -> None: + t = _BenchTransport(_TOOLS_50) + adapter = MCPToolAdapter(config=self._config, transport=t) + adapter.connect() + registry = ToolRegistry() + adapter.register_tools(registry, namespace="bench") + + +time_config = TimeConfigCreation() +time_disc = TimeToolDiscovery() +time_invoke = TimeToolInvocation() +time_reg = TimeToolRegistration() diff --git a/docs/reference/actor_hierarchy.md b/docs/reference/actor_hierarchy.md new file mode 100644 index 000000000..218dccab1 --- /dev/null +++ b/docs/reference/actor_hierarchy.md @@ -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. diff --git a/docs/reference/mcp_adapter.md b/docs/reference/mcp_adapter.md new file mode 100644 index 000000000..44a204d6f --- /dev/null +++ b/docs/reference/mcp_adapter.md @@ -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. diff --git a/examples/actors/hierarchical_workflow.yaml b/examples/actors/hierarchical_workflow.yaml new file mode 100644 index 000000000..4879bd78f --- /dev/null +++ b/examples/actors/hierarchical_workflow.yaml @@ -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 diff --git a/features/actor_examples.feature b/features/actor_examples.feature index 248d08284..5fc3be01f 100644 --- a/features/actor_examples.feature +++ b/features/actor_examples.feature @@ -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" diff --git a/features/actor_hierarchy.feature b/features/actor_hierarchy.feature new file mode 100644 index 000000000..a2cfbc80d --- /dev/null +++ b/features/actor_hierarchy.feature @@ -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" diff --git a/features/mcp_adapter.feature b/features/mcp_adapter.feature new file mode 100644 index 000000000..b009bff8e --- /dev/null +++ b/features/mcp_adapter.feature @@ -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" diff --git a/features/steps/actor_hierarchy_steps.py b/features/steps/actor_hierarchy_steps.py new file mode 100644 index 000000000..4497a125e --- /dev/null +++ b/features/steps/actor_hierarchy_steps.py @@ -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}'" + ) diff --git a/features/steps/mcp_adapter_steps.py b/features/steps/mcp_adapter_steps.py new file mode 100644 index 000000000..7760a6e16 --- /dev/null +++ b/features/steps/mcp_adapter_steps.py @@ -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 diff --git a/robot/actor_hierarchy.robot b/robot/actor_hierarchy.robot new file mode 100644 index 000000000..0dfd86087 --- /dev/null +++ b/robot/actor_hierarchy.robot @@ -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 diff --git a/robot/helper_actor_hierarchy.py b/robot/helper_actor_hierarchy.py new file mode 100644 index 000000000..71d1149df --- /dev/null +++ b/robot/helper_actor_hierarchy.py @@ -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 ") + 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()) diff --git a/robot/helper_mcp_adapter.py b/robot/helper_mcp_adapter.py new file mode 100644 index 000000000..0be4ded66 --- /dev/null +++ b/robot/helper_mcp_adapter.py @@ -0,0 +1,146 @@ +"""Robot Framework helper for MCP Tool Adapter smoke tests. + +Usage: + python robot/helper_mcp_adapter.py create-adapter + python robot/helper_mcp_adapter.py discover-tools + python robot/helper_mcp_adapter.py invoke-tool + python robot/helper_mcp_adapter.py register-tools + python robot/helper_mcp_adapter.py reject-no-command +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from cleveragents.mcp.adapter import ( # noqa: E402 + MCPServerConfig, + MCPToolAdapter, + MCPTransport, +) +from cleveragents.tool.registry import ToolRegistry # noqa: E402 + + +class _MockTransport(MCPTransport): + """Minimal mock transport for smoke tests.""" + + def __init__(self, tools: list[dict[str, Any]] | None = None) -> None: + self._tools = tools or [] + self._results: dict[str, dict[str, Any]] = {} + + def connect(self, config: MCPServerConfig) -> dict[str, Any]: + return {"capabilities": {"tools": True}} + + def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + if method == "tools/list": + return {"tools": list(self._tools)} + if method == "tools/call": + name = params.get("name", "") + if name in self._results: + return {"content": self._results[name]} + return {"content": {"result": "ok"}} + return {} + + def close(self) -> None: + pass + + +def _mock_tool(name: str) -> dict[str, Any]: + return {"name": name, "description": f"Mock {name}", "inputSchema": {}} + + +def main() -> int: + if len(sys.argv) < 2: + print("Usage: helper_mcp_adapter.py ") + return 1 + + dispatch = { + "create-adapter": _create_adapter, + "discover-tools": _discover_tools, + "invoke-tool": _invoke_tool, + "register-tools": _register_tools, + "reject-no-command": _reject_no_command, + } + handler = dispatch.get(sys.argv[1]) + if handler is None: + print(f"Unknown command: {sys.argv[1]}") + return 1 + return handler() + + +def _create_adapter() -> int: + config = MCPServerConfig(name="github", transport="stdio", command="echo") + adapter = MCPToolAdapter(config=config) + print(f"server-name: {adapter.server_name}") + print(f"transport: {adapter.transport_type}") + print(f"connected: {adapter.is_connected}") + return 0 + + +def _discover_tools() -> int: + tools = [ + _mock_tool("create_issue"), + _mock_tool("list_repos"), + _mock_tool("search"), + ] + config = MCPServerConfig(name="test", transport="stdio", command="echo") + transport = _MockTransport(tools=tools) + adapter = MCPToolAdapter(config=config, transport=transport) + adapter.connect() + discovered = adapter.discover_tools() + print(f"discovered: {len(discovered)}") + for i, t in enumerate(discovered): + print(f"tool-{i}: {t.name}") + return 0 + + +def _invoke_tool() -> int: + tools = [_mock_tool("create_issue")] + config = MCPServerConfig(name="test", transport="stdio", command="echo") + transport = _MockTransport(tools=tools) + transport._results["create_issue"] = {"id": 42} + adapter = MCPToolAdapter(config=config, transport=transport) + adapter.connect() + adapter.discover_tools() + result = adapter.invoke("create_issue", {"title": "Bug"}) + print(f"success: {result.success}") + print(f"has-data: {bool(result.data)}") + return 0 + + +def _register_tools() -> int: + tools = [_mock_tool("tool_a"), _mock_tool("tool_b")] + config = MCPServerConfig(name="test", transport="stdio", command="echo") + transport = _MockTransport(tools=tools) + adapter = MCPToolAdapter(config=config, transport=transport) + adapter.connect() + registry = ToolRegistry() + names = adapter.register_tools(registry, namespace="mcp-test") + print(f"registered: {len(names)}") + if names and "/" in names[0]: + print(f"prefix: {names[0].split('/')[0]}/") + return 0 + + +def _reject_no_command() -> int: + config = MCPServerConfig(name="bad", transport="stdio") + try: + MCPToolAdapter.from_server_config(config) + print("unexpected-success") + return 1 + except ValueError as exc: + err = str(exc).lower() + if "command" in err: + print("validation-error: command") + else: + print(f"validation-error-other: {exc}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/mcp_adapter.robot b/robot/mcp_adapter.robot new file mode 100644 index 000000000..60855f68e --- /dev/null +++ b/robot/mcp_adapter.robot @@ -0,0 +1,50 @@ +*** Settings *** +Documentation Smoke tests for MCP Tool Adapter +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_mcp_adapter.py + +*** Test Cases *** +Create MCP Adapter From Config + [Documentation] Create an adapter from a server config and verify properties + ${result}= Run Process ${PYTHON} ${HELPER} create-adapter cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} server-name: github + Should Contain ${result.stdout} transport: stdio + Should Contain ${result.stdout} connected: False + +Discover MCP Tools + [Documentation] Connect and discover tools from a mock MCP server + ${result}= Run Process ${PYTHON} ${HELPER} discover-tools cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} discovered: 3 + Should Contain ${result.stdout} tool-0: create_issue + +Invoke MCP Tool + [Documentation] Invoke a discovered tool and verify result + ${result}= Run Process ${PYTHON} ${HELPER} invoke-tool cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} success: True + Should Contain ${result.stdout} has-data: True + +Register MCP Tools In Registry + [Documentation] Register discovered tools into a ToolRegistry + ${result}= Run Process ${PYTHON} ${HELPER} register-tools cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} registered: 2 + Should Contain ${result.stdout} prefix: mcp-test/ + +Reject Stdio Config Without Command + [Documentation] Validate that stdio transport requires command + ${result}= Run Process ${PYTHON} ${HELPER} reject-no-command cwd=${WORKSPACE} + Log ${result.stdout} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} validation-error: command diff --git a/src/cleveragents/actor/schema.py b/src/cleveragents/actor/schema.py index b136bd2ea..22b9bef24 100644 --- a/src/cleveragents/actor/schema.py +++ b/src/cleveragents/actor/schema.py @@ -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", ] diff --git a/src/cleveragents/mcp/__init__.py b/src/cleveragents/mcp/__init__.py new file mode 100644 index 000000000..e3856f0bb --- /dev/null +++ b/src/cleveragents/mcp/__init__.py @@ -0,0 +1,19 @@ +"""MCP (Model Context Protocol) adapter package. + +Bridges external MCP tool servers into the CleverAgents ToolRegistry +via ``MCPToolAdapter``. +""" + +from cleveragents.mcp.adapter import ( + MCPServerConfig, + MCPToolAdapter, + MCPToolDescriptor, + MCPToolResult, +) + +__all__ = [ + "MCPServerConfig", + "MCPToolAdapter", + "MCPToolDescriptor", + "MCPToolResult", +] diff --git a/src/cleveragents/mcp/adapter.py b/src/cleveragents/mcp/adapter.py new file mode 100644 index 000000000..8617e68fe --- /dev/null +++ b/src/cleveragents/mcp/adapter.py @@ -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