arcade-mcp/libs/arcade-evals/arcade_evals/_evalsuite/_comparative_execution.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

233 lines
9.7 KiB
Python

"""Comparative evaluation execution mixin for EvalSuite.
This module provides the execution logic for comparative evaluations,
allowing the same cases to be run against multiple tool tracks.
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING, Any
from arcade_evals._evalsuite._comparative import ComparativeCaseBuilder
from arcade_evals._evalsuite._types import ComparativeCase, EvalRubric
if TYPE_CHECKING:
from arcade_evals._evalsuite._providers import ProviderName
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
from arcade_evals._evalsuite._tracks import TrackManager
class _EvalSuiteComparativeMixin:
"""Mixin providing comparative evaluation execution methods."""
# Type hints for attributes from EvalSuite
name: str
system_message: str
rubric: EvalRubric # EvalSuite always has a rubric (default_factory)
max_concurrent: int
_comparative_case_builders: list[ComparativeCaseBuilder]
_track_manager: TrackManager
_create_eval_case: Any # Method from EvalSuite to create EvalCase
_convert_to_named_expected_tool_call: Any # Method from EvalSuite
_add_none_critics: Any # Method from EvalSuite
_process_tool_calls: Any # Method from EvalSuite
_run_openai: Any # Method from EvalSuite
_run_anthropic: Any # Method from EvalSuite
def add_comparative_case(
self,
name: str,
user_message: str,
system_message: str | None = None,
additional_messages: list[dict[str, str]] | None = None,
rubric: EvalRubric | None = None,
) -> ComparativeCaseBuilder:
"""Create a comparative case that runs against multiple tool tracks.
Use .for_track() on the returned builder to configure track-specific
expected tool calls and critics.
Args:
name: Unique case name.
user_message: User message (shared across all tracks).
system_message: System message (shared, defaults to suite's system_message).
additional_messages: Additional context messages (shared).
rubric: Evaluation rubric (shared, defaults to suite's rubric).
Returns:
A ComparativeCaseBuilder for fluent track configuration.
Example:
suite.add_comparative_case(
name="weather_query",
user_message="What's the weather in NYC?",
).for_track(
"Google Weather",
expected_tool_calls=[ExpectedMCPToolCall("Google_GetWeather", city="NYC")],
critics=[RangeCritic(field="temperature", min_val=0, max_val=100)],
).for_track(
"OpenWeather",
expected_tool_calls=[ExpectedMCPToolCall("get_current", location="NYC")],
critics=[RangeCritic(field="main.temp", min_val=273, max_val=373)],
)
"""
builder = ComparativeCaseBuilder(
suite=self,
name=name,
user_message=user_message,
system_message=system_message or self.system_message,
additional_messages=additional_messages,
rubric=rubric or self.rubric,
)
# Store the builder (validated at execution time to allow fluent configuration)
self._comparative_case_builders.append(builder)
return builder
async def run_comparative(
self,
client: Any,
model: str,
provider: ProviderName = "openai",
) -> dict[str, dict[str, Any]]:
"""Run comparative cases across all configured tracks.
Args:
client: The LLM client instance.
model: The model to evaluate.
provider: The provider name.
Returns:
Dictionary mapping track names to their results.
Each track result contains:
- model: The model name
- suite_name: The suite name
- track_name: The track name
- cases: List of case results
Example:
results = await suite.run_comparative(client, "gpt-4o")
# results["Google Weather"]["cases"][0] -> first case result
# results["OpenWeather"]["cases"][0] -> same case, different track
"""
if not self._comparative_case_builders:
raise ValueError(
"No comparative cases defined. Use add_comparative_case() to add cases."
)
# Build and validate all cases upfront
comparative_cases: list[ComparativeCase] = []
all_required_tracks: set[str] = set()
for builder in self._comparative_case_builders:
comp_case = builder.build() # Validates that tracks are configured
comparative_cases.append(comp_case)
all_required_tracks.update(comp_case.track_configs.keys())
# Validate all required tracks exist upfront (fail fast)
missing_tracks = [t for t in all_required_tracks if not self._track_manager.has_track(t)]
if missing_tracks:
available = self._track_manager.get_track_names()
raise ValueError(
f"Missing track registries: {missing_tracks}. "
f"Available tracks: {available}. "
f"Ensure you registered tools with track='<track_name>'."
)
# Initialize track results structure
track_results: dict[str, dict[str, Any]] = {}
for track_name in all_required_tracks:
track_results[track_name] = {
"model": model,
"suite_name": self.name,
"track_name": track_name,
"rubric": self.rubric,
"cases": [],
}
# Prepare all async tasks for parallel execution
semaphore = asyncio.Semaphore(self.max_concurrent)
tasks: list[tuple[str, Any]] = [] # (track_name, task)
for comp_case in comparative_cases:
for track_name, track_config in comp_case.track_configs.items():
registry = self._track_manager.get_registry(track_name)
# We validated above that all registries exist, so this should never be None
if registry is None:
raise RuntimeError(
f"Registry for '{track_name}' unexpectedly None after validation"
)
# Create EvalCase from comparative case + track config
expected_tool_calls = [
self._convert_to_named_expected_tool_call(tc)
for tc in track_config.expected_tool_calls
]
critics = self._add_none_critics(expected_tool_calls, track_config.critics or [])
eval_case = self._create_eval_case(
name=comp_case.name,
system_message=comp_case.system_message,
user_message=comp_case.user_message,
expected_tool_calls=expected_tool_calls,
rubric=comp_case.rubric or self.rubric,
critics=critics,
additional_messages=comp_case.additional_messages,
)
# Create task for this case+track combination
async def run_track_case(
_case: Any, # EvalCase
_reg: EvalSuiteToolRegistry,
_t_name: str,
) -> dict[str, Any]:
async with semaphore:
start = time.time()
print(f" [TASK START] {_case.name} @ {_t_name}", flush=True)
if provider == "anthropic":
predicted_args = await self._run_anthropic(
client, model, _case, registry=_reg
)
else:
predicted_args = await self._run_openai(
client, model, _case, registry=_reg
)
elapsed = time.time() - start
print(
f" [TASK DONE] {_case.name} @ {_t_name} ({elapsed:.1f}s)",
flush=True,
)
filled_actual_tool_calls = self._process_tool_calls(
predicted_args, registry=_reg
)
evaluation = _case.evaluate(filled_actual_tool_calls)
return {
"name": _case.name,
"track": _t_name,
"input": _case.user_message,
"system_message": _case.system_message,
"additional_messages": _case.additional_messages,
"expected_tool_calls": [
{"name": tc.name, "args": tc.args}
for tc in _case.expected_tool_calls
],
"predicted_tool_calls": [
{"name": name, "args": args}
for name, args in filled_actual_tool_calls
],
"evaluation": evaluation,
}
task = run_track_case(eval_case, registry, track_name)
tasks.append((track_name, task))
# Execute all tasks in parallel (respecting max_concurrent via semaphore)
results = await asyncio.gather(*[task for _, task in tasks])
# Organize results by track
for (track_name, _), result in zip(tasks, results):
track_results[track_name]["cases"].append(result)
return track_results