arcade-mcp/libs/arcade-evals/arcade_evals/capture.py
jottakka 98fad93d21
Adding MCP Servers supports to Arcade Evals (#689)
# MCP Server Tool Evaluation Support

## Overview
Add support for evaluating tools from remote MCP servers without
requiring Python callables. Enables direct evaluation of any
MCP-compatible tool server.

## What's New

### Core Features
- **`MCPToolRegistry`**: Evaluate tools from a single MCP server
- **`CompositeMCPRegistry`**: Evaluate tools from multiple MCP servers
simultaneously
- **Automatic loaders**: `load_from_stdio()` and `load_from_http()` to
fetch tools from running servers
- **Automatic namespacing**: Tools prefixed with server name (e.g.,
`server_tool_name`)
- **Smart name resolution**: Use short names if unique, full names if
ambiguous
- **OpenAI strict mode**: Automatic schema conversion prevents parameter
hallucinations

### Usage

**Automatic Loading:**
```python
from arcade_evals import load_from_stdio, MCPToolRegistry

# Load tools automatically from MCP server
tools = load_from_stdio(["npx", "-y", "@modelcontextprotocol/server-github"])
registry = MCPToolRegistry(tools)
```

**Single MCP Server:**
```python
from arcade_evals import MCPToolRegistry, ExpectedToolCall

registry = MCPToolRegistry(mcp_tools)
suite = EvalSuite(catalog=registry)

suite.add_case(
    expected_tool_calls=[
        ExpectedToolCall(tool_name="tool_name", args={...})
    ]
)
```

**Multiple MCP Servers:**
```python
from arcade_evals import CompositeMCPRegistry, load_from_stdio

# Load from multiple servers
github_tools = load_from_stdio(["npx", "-y", "@modelcontextprotocol/server-github"])
slack_tools = load_from_stdio(["npx", "-y", "@modelcontextprotocol/server-slack"])

composite = CompositeMCPRegistry(
    tool_lists={
        "github": github_tools,
        "slack": slack_tools,
    }
)

suite = EvalSuite(catalog=composite)

suite.add_case(
    expected_tool_calls=[
        ExpectedToolCall(tool_name="github_list_issues", args={...})
    ]
)
```

## Implementation

### Files Changed
- **`libs/arcade-evals/arcade_evals/registry.py`** (NEW): Registry
abstractions and implementations
- **`libs/arcade-evals/arcade_evals/loaders.py`** (NEW): Automatic tool
loading from MCP servers
- **`libs/arcade-evals/arcade_evals/eval.py`** (MODIFIED): Enhanced
`ExpectedToolCall` and evaluation logic
- **`libs/arcade-evals/arcade_evals/__init__.py`** (MODIFIED): Exported
new registries and loaders

### Key Technical Details
- Added `BaseToolRegistry` interface for abstraction
- `MCPToolRegistry` handles single server tools
- `CompositeMCPRegistry` manages multiple servers with collision
detection
- `load_from_stdio()` and `load_from_http()` for automatic tool
discovery
- Fixed name normalization bug: MCP tools use underscores (not dots)
- Optimized tool copying: 2.5x faster via shallow copy

## Testing
-  41 tests passing (25 new tests added)
-  `test_eval_mcp_registry.py`: MCPToolRegistry functionality
-  `test_eval_composite_mcp.py`: CompositeMCPRegistry with multiple
servers
-  Verified backward compatibility with Python tools

## Backward Compatibility
 **100% backward compatible** - No breaking changes


## Breaking Changes
**None**


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds end-to-end eval UX: examples, a robust CLI runner, and rich
outputs.
> 
> - **New examples**: `eval_arcade_gateway.py`,
`eval_stdio_mcp_server.py`, `eval_http_mcp_server.py`,
`eval_comprehensive_comparison.py` with timeouts, error handling, and
track-based comparisons; detailed `README.md`
> - **CLI runner**: `arcade_cli/evals_runner.py` to execute
evals/capture in parallel with progress, error isolation, failed-only
filtering, context inclusion, and multi-provider/model support
> - **Output formatters**: `arcade_cli/formatters/` (txt, md, html,
json) for evals and capture; comparative and multi-model HTML with tabs
and context rendering
> - **Display refactor**: `display.py` now supports writing multiple
formats, failed-only disclaimers, include-context, and improved console
summaries
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ff8acf9c34a6b61462a019a1ee9df081006517d0. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Francisco Liberal <francisco@arcade.dev>
Co-authored-by: Mateo Torres <torresmateo@gmail.com>
2026-01-07 20:26:23 -03:00

186 lines
6.5 KiB
Python

"""
Capture mode for EvalSuite.
Capture mode runs evaluation cases and records tool calls from the model
without scoring or evaluating them. This is useful for:
- Generating expected tool calls for new test cases
- Debugging model behavior
- Creating baseline recordings
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from openai import AsyncOpenAI
if TYPE_CHECKING:
from arcade_evals.eval import EvalSuite
@dataclass
class CapturedToolCall:
"""
A captured tool call from the model during capture mode.
Attributes:
name: The name of the tool that was called.
args: The arguments passed to the tool.
"""
name: str
args: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {"name": self.name, "args": self.args}
@dataclass
class CapturedCase:
"""
Result of running a single case in capture mode.
Attributes:
case_name: The name of the evaluation case.
user_message: The user message that triggered the tool calls.
tool_calls: List of tool calls made by the model.
system_message: The system message (included if include_context is True).
additional_messages: Additional messages (included if include_context is True).
track_name: The track name for comparative captures (None for regular cases).
"""
case_name: str
user_message: str
tool_calls: list[CapturedToolCall] = field(default_factory=list)
system_message: str | None = None
additional_messages: list[dict[str, Any]] | None = None
track_name: str | None = None
@staticmethod
def _try_parse_json(value: str) -> Any:
"""Try to parse a JSON string, returning the original string if parsing fails."""
try:
return json.loads(value)
except json.JSONDecodeError:
return value
@staticmethod
def _normalize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Normalize additional_messages by parsing JSON strings into proper objects.
OpenAI returns:
- Tool call arguments as JSON strings in assistant messages
- Tool response content as JSON strings in tool messages
For cleaner output, we parse these into proper objects.
"""
normalized = []
for msg in messages:
msg_copy = dict(msg)
# Parse tool call arguments in assistant messages
if "tool_calls" in msg_copy and isinstance(msg_copy["tool_calls"], list):
normalized_tool_calls = []
for tc in msg_copy["tool_calls"]:
tc_copy = dict(tc)
if "function" in tc_copy and isinstance(tc_copy["function"], dict):
func = dict(tc_copy["function"])
if "arguments" in func and isinstance(func["arguments"], str):
func["arguments"] = CapturedCase._try_parse_json(func["arguments"])
tc_copy["function"] = func
normalized_tool_calls.append(tc_copy)
msg_copy["tool_calls"] = normalized_tool_calls
# Parse content in tool response messages
if msg_copy.get("role") == "tool" and isinstance(msg_copy.get("content"), str):
msg_copy["content"] = CapturedCase._try_parse_json(msg_copy["content"])
normalized.append(msg_copy)
return normalized
def to_dict(self, include_context: bool = False) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result: dict[str, Any] = {
"case_name": self.case_name,
"user_message": self.user_message,
"tool_calls": [tc.to_dict() for tc in self.tool_calls],
}
if self.track_name:
result["track_name"] = self.track_name
if include_context:
result["system_message"] = self.system_message
# Normalize additional_messages to parse JSON string arguments
raw_messages = self.additional_messages or []
result["additional_messages"] = self._normalize_messages(raw_messages)
return result
@dataclass
class CaptureResult:
"""
Result of running an EvalSuite in capture mode.
Attributes:
suite_name: The name of the evaluation suite.
model: The model used for capture.
provider: The provider used (openai, anthropic).
captured_cases: List of captured cases with tool calls.
"""
suite_name: str
model: str
provider: str
captured_cases: list[CapturedCase] = field(default_factory=list)
def to_dict(self, include_context: bool = False) -> dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
"suite_name": self.suite_name,
"model": self.model,
"provider": self.provider,
"captured_cases": [c.to_dict(include_context) for c in self.captured_cases],
}
def to_json(self, include_context: bool = False, indent: int = 2) -> str:
"""Convert to JSON string."""
return json.dumps(self.to_dict(include_context), indent=indent)
def write_to_file(self, file_path: str, include_context: bool = False, indent: int = 2) -> None:
"""Write capture results to a JSON file."""
with open(file_path, "w") as f:
f.write(self.to_json(include_context, indent))
# --- Helper functions for running capture mode ---
async def _capture_with_openai(
suite: EvalSuite, api_key: str, model: str, include_context: bool = False
) -> CaptureResult:
"""Run capture mode with OpenAI client."""
async with AsyncOpenAI(api_key=api_key) as client:
return await suite.capture(
client, model, provider="openai", include_context=include_context
)
async def _capture_with_anthropic(
suite: EvalSuite, api_key: str, model: str, include_context: bool = False
) -> CaptureResult:
"""Run capture mode with Anthropic client."""
try:
from anthropic import AsyncAnthropic
except ImportError as e:
raise ImportError(
"The 'anthropic' package is required for Anthropic provider. "
"Install it with: pip install anthropic"
) from e
async with AsyncAnthropic(api_key=api_key) as client:
return await suite.capture(
client, model, provider="anthropic", include_context=include_context
)