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

353 lines
13 KiB
Python

from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, ClassVar
import pytz
from dateutil import parser
from arcade_evals.errors import WeightError
from arcade_evals.weights import FuzzyWeight, Weight, resolve_weight
@dataclass
class Critic(ABC):
"""
Base class for all critics.
Attributes:
critic_field: The field name this critic evaluates.
weight: The weight for this critic. Can be a float (0.0-1.0) or FuzzyWeight enum.
When using FuzzyWeight, weights are auto-normalized to sum to 1.0.
"""
critic_field: str
weight: Weight
def __post_init__(self) -> None:
if isinstance(self.weight, FuzzyWeight):
return
if self.weight < 0:
raise WeightError(f"Critic weight must be non-negative, got {self.weight}")
@property
def resolved_weight(self) -> float:
"""Get the weight as a float value."""
return resolve_weight(self.weight)
@abstractmethod
def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]:
pass
@dataclass
class NoneCritic(Critic):
"""
A critic that has no effect on the evaluation results and does not actually evaluate.
If a critic is not found for an evaluation case's field, then
a NoneCritic is used to indicate that the field was not criticized.
"""
# Marker attribute to identify placeholder critics without isinstance checks
# (avoids circular imports in weights.py)
_is_placeholder: ClassVar[bool] = True
weight: float = 0.0
def __post_init__(self) -> None:
self.weight = 0.0
super().__post_init__()
def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]:
return {"match": None, "score": self.weight, "is_criticized": False}
@dataclass
class BinaryCritic(Critic):
"""
A critic for performing exact equality comparisons between expected and actual values.
This critic evaluates whether the expected and actual values are exactly equal.
It's useful for scenarios where only an exact match is acceptable.
Returns:
A dict with:
- "match": True if expected == actual, otherwise False.
- "score": The full weight if there's a match, otherwise 0.0.
"""
def cast_actual(self, expected: Any, actual: Any) -> Any:
"""
Casts the actual value to the type of the expected value.
Args:
expected (Any): The expected value whose type will be used for casting.
actual (Any): The actual value to be cast.
Returns:
Any: The actual value cast to the type of the expected value.
Raises:
TypeError: If the casting is not possible.
"""
# In case both are strings.
if actual == "None":
actual = None
if expected == "None":
expected = None
if expected is None:
# No need to cast; return actual as is
return actual
if actual is None:
# No need to cast; return None
return None
expected_type = type(expected)
try:
return expected_type(actual)
except (ValueError, TypeError) as e:
raise TypeError(
f"Cannot cast actual value '{actual}' to type {expected_type.__name__}: {e}"
) from e
def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]:
"""
Evaluates whether the expected and actual values are exactly equal after casting.
Args:
expected: The expected value.
actual: The actual value to compare, cast to the type of expected.
Returns:
dict: A dictionary containing the match status and score.
"""
# Cast actual to the type of expected
try:
actual_casted = self.cast_actual(expected, actual)
# TODO log or something better here
except TypeError:
actual_casted = actual
match = expected == actual_casted
return {"match": match, "score": self.resolved_weight if match else 0.0}
@dataclass
class NumericCritic(Critic):
"""
A critic for evaluating numeric values within a specified range.
This critic performs a "fuzzy" comparison of numeric values, where values closer
to each other (relative to the specified range) result in higher scores. It's
useful for scenarios where exact matches aren't necessary, but closeness within
a certain tolerance is rewarded.
Attributes:
value_range: The min and max values of the expected range.
match_threshold: The threshold for considering a match (default 0.8).
The evaluation process:
1. Normalizes both expected and actual values to a 0-1 scale based on value_range.
2. Calculates the absolute difference between these normalized values.
3. Subtracts this difference from 1 to get a similarity score (closer to 1 is more similar).
4. Multiplies the similarity by the critic's weight for the final score.
Returns:
A dict with:
- "match": True if the score >= match_threshold, otherwise False.
- "score": The calculated score (similarity * weight).
"""
value_range: tuple[float, float]
match_threshold: float = 0.8
def __init__(
self,
critic_field: str,
weight: float,
value_range: tuple[float, float],
match_threshold: float = 0.8,
):
super().__init__(critic_field, weight)
if value_range[0] >= value_range[1]:
raise ValueError("Invalid value_range: minimum must be less than maximum.")
self.value_range = value_range
self.match_threshold = match_threshold
def evaluate(self, expected: Any, actual: Any) -> dict[str, Any]:
min_val, max_val = self.value_range
normalized_expected = float((float(expected) - min_val) / (max_val - min_val))
normalized_actual = float((float(actual) - min_val) / (max_val - min_val))
score = float(1 - abs(normalized_expected - normalized_actual))
return {
"match": bool(score >= self.match_threshold),
"score": float(score * self.resolved_weight),
}
@dataclass
class SimilarityCritic(Critic):
"""
A critic for evaluating the similarity between two strings.
This critic uses a specified similarity metric to compare the expected and actual
string values. Currently, it supports cosine similarity using TF-IDF vectorization.
Args:
metric: The similarity metric to use (default is "cosine").
similarity_threshold: The threshold for considering a match (default 0.8).
The evaluation process:
1. Converts both expected and actual values to strings.
2. Calculates the similarity score using the specified metric.
3. Determines a match based on the similarity_threshold.
4. Calculates the final score by multiplying the similarity by the critic's weight.
Returns:
A dict with:
- "match": True if similarity >= similarity_threshold, otherwise False.
- "score": The calculated score (similarity * weight).
Raises:
ImportError: If scikit-learn is not installed (required for cosine similarity).
ValueError: If an unsupported similarity metric is specified.
"""
metric: str = "cosine"
similarity_threshold: float = 0.8
SUPPORTED_METRICS: ClassVar[list[str]] = ["cosine"]
def __init__(
self,
critic_field: str,
weight: float,
similarity_threshold: float = 0.8,
metric: str = "cosine",
):
super().__init__(critic_field, weight)
if metric not in self.SUPPORTED_METRICS:
raise ValueError(f"Unsupported similarity metric: {metric}")
self.similarity_threshold = similarity_threshold
self.metric = metric
def evaluate(self, expected: Any, actual: Any) -> dict[str, float | bool]:
# IMPORTANT: Convert non-string values to strings before TF-IDF comparison.
# sklearn's TfidfVectorizer calls .lower() on inputs, which fails on lists/dicts.
# This commonly occurs when SimilarityCritic is used for tool arguments that are
# lists (e.g., teams_to_add=["Engineering", "Platform"]) instead of strings.
# Lists are joined with spaces to create comparable text representations.
if not isinstance(expected, str):
expected = (
" ".join(str(item) for item in expected)
if isinstance(expected, list)
else str(expected)
)
if not isinstance(actual, str):
actual = (
" ".join(str(item) for item in actual) if isinstance(actual, list) else str(actual)
)
if self.metric == "cosine":
try:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
except ImportError:
raise ImportError(
"Use `pip install 'arcade-evals` to install the required dependencies for similarity metrics."
)
# Handle edge case: empty strings or strings with no valid tokens
# TfidfVectorizer fails with "empty vocabulary" for such inputs
if not expected.strip() or not actual.strip():
# Both empty = match, one empty = no match
is_match = expected.strip() == actual.strip()
return {
"match": is_match,
"score": self.resolved_weight if is_match else 0.0,
}
try:
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform([expected, actual])
similarity = float(cosine_similarity(tfidf_matrix[0], tfidf_matrix[1])[0][0])
except ValueError:
# TfidfVectorizer raises ValueError for empty vocabulary
# (e.g., only numbers/punctuation which get filtered as stop words)
# Fall back to exact string match
is_match = expected == actual
return {
"match": is_match,
"score": self.resolved_weight if is_match else 0.0,
}
else:
raise ValueError(f"Unsupported similarity metric: {self.metric}")
return {
"match": similarity >= self.similarity_threshold,
"score": min(similarity * self.resolved_weight, self.resolved_weight),
}
@dataclass
class DatetimeCritic(Critic):
"""
A critic that evaluates the closeness of datetime values within a specified tolerance.
Attributes:
tolerance: Acceptable timedelta between expected and actual datetimes.
max_difference: Maximum timedelta for a partial score.
"""
critic_field: str
weight: float
tolerance: timedelta = timedelta(seconds=500)
max_difference: timedelta = timedelta(hours=2)
def evaluate(self, expected: str, actual: str) -> dict[str, float | bool]:
"""Evaluates the closeness of datetime values within a specified tolerance."""
# Attempt to parse expected and actual datetime strings
try:
expected_dt = parser.parse(expected)
actual_dt = parser.parse(actual)
except (ValueError, TypeError):
# If parsing fails, return score 0
return {"match": False, "score": 0.0}
# Handle cases based on presence of tzinfo
if expected_dt.tzinfo is None and actual_dt.tzinfo is None:
# Both datetimes are naive, compare directly
time_diff_seconds = abs((expected_dt - actual_dt).total_seconds())
elif expected_dt.tzinfo is not None and actual_dt.tzinfo is not None:
# Both datetimes have tzinfo, compare in UTC
expected_utc = expected_dt.astimezone(pytz.utc)
actual_utc = actual_dt.astimezone(pytz.utc)
time_diff_seconds = abs((expected_utc - actual_utc).total_seconds())
else:
# One datetime has tzinfo and the other doesn't
# Compare naive datetime with the other's naive equivalent
if expected_dt.tzinfo is not None:
expected_naive = expected_dt.replace(tzinfo=None)
time_diff_seconds = abs((expected_naive - actual_dt).total_seconds())
else:
actual_naive = actual_dt.replace(tzinfo=None)
time_diff_seconds = abs((expected_dt - actual_naive).total_seconds())
# Convert tolerances to seconds
tolerance_seconds = self.tolerance.total_seconds()
max_difference_seconds = self.max_difference.total_seconds()
if time_diff_seconds <= tolerance_seconds:
# Full score if within tolerance
return {"match": True, "score": self.resolved_weight}
elif time_diff_seconds >= max_difference_seconds:
# No score if beyond max_difference
return {"match": False, "score": 0.0}
else:
# Partial score based on time difference
ratio = 1 - (time_diff_seconds / max_difference_seconds)
# Ensure ratio is not negative
ratio = max(ratio, 0)
score = self.resolved_weight * ratio
return {"match": False, "score": score}