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

283 lines
11 KiB
Python

"""EvalSuite internal unified tool registry (not part of the public API)."""
from __future__ import annotations
from typing import Any, Literal, TypedDict
from arcade_core.converters.anthropic import to_anthropic
from arcade_core.converters.utils import normalize_tool_name
from arcade_evals._evalsuite._anthropic_schema import convert_mcp_to_anthropic_tool
from arcade_evals._evalsuite._openai_schema import convert_to_strict_mode_schema
ToolFormat = Literal["openai", "anthropic"]
class _MCPToolDefinitionRequired(TypedDict):
"""Required fields for MCP-style tool definition."""
name: str
class MCPToolDefinition(_MCPToolDefinitionRequired, total=False):
"""MCP-style tool definition structure.
This is the format expected by `add_tool_definitions()` and used internally
by the EvalSuiteToolRegistry.
Required:
name: The unique tool name.
Optional:
description: Human-readable description (defaults to "").
inputSchema: JSON Schema for input parameters
(defaults to {"type": "object", "properties": {}}).
"""
description: str
inputSchema: dict[str, Any]
class EvalSuiteToolRegistry:
"""
A minimal internal registry that stores tools in MCP-style descriptors:
{
"name": "...",
"description": "...",
"inputSchema": { ... JSON Schema ... }
}
EvalSuite converts Python tools into this format too, so there is only one
runtime path for OpenAI tool formatting.
Note: Tools are stored with their original names (e.g., "Google.Search"),
but Anthropic requires underscores (e.g., "Google_Search"). The registry
maintains a mapping to look up tools by either format.
"""
def __init__(self, *, strict_mode: bool = True) -> None:
self._tools: dict[str, dict[str, Any]] = {}
self._strict_mode = strict_mode
# Mapping from normalized names (underscores) to original names (dots)
# e.g., {"Google_Search": "Google.Search"}
self._normalized_to_original: dict[str, str] = {}
# Store original MaterializedTool objects for direct Anthropic conversion (Python tools only)
self._materialized_tools: dict[str, Any] = {}
@property
def strict_mode(self) -> bool:
return self._strict_mode
@strict_mode.setter
def strict_mode(self, value: bool) -> None:
self._strict_mode = value
def add_tool(
self,
tool_descriptor: MCPToolDefinition | dict[str, Any],
materialized_tool: Any = None,
) -> None:
"""Add a tool to the registry.
Args:
tool_descriptor: MCP-style tool definition.
materialized_tool: Optional MaterializedTool for direct Anthropic conversion (Python tools only).
"""
if "name" not in tool_descriptor:
raise ValueError("Tool descriptor must have a 'name' field")
name = tool_descriptor["name"]
if name in self._tools:
raise ValueError(
f"Tool '{name}' already registered. "
"Each tool name must be unique across all sources (MCP servers, gateways, catalogs)."
)
self._tools[name] = dict(tool_descriptor)
# Store MaterializedTool if provided (for direct Anthropic conversion)
if materialized_tool is not None:
self._materialized_tools[name] = materialized_tool
# Build normalized name mapping for Anthropic/OpenAI lookups
# e.g., "Google.Search" -> normalized key "Google_Search"
normalized_name = normalize_tool_name(name)
if normalized_name != name:
# Check for collision: if the normalized name already exists as a direct tool
# (e.g., registering "Google.Search" when "Google_Search" already exists),
# the normalized lookup would be ambiguous
if normalized_name in self._tools:
# The underscore version is registered directly, so normalized lookups
# should prefer that. Don't add to mapping to avoid ambiguity.
pass
elif normalized_name in self._normalized_to_original:
# Another dotted tool already maps to this normalized name
# e.g., "A.B" and "A_B" (as "A.B") would both normalize to "A_B"
# Keep the first mapping to avoid silent overwrites
pass
else:
self._normalized_to_original[normalized_name] = name
def add_tools(self, tools: list[MCPToolDefinition] | list[dict[str, Any]]) -> None:
for tool in tools:
self.add_tool(tool)
def list_tools_for_model(self, tool_format: ToolFormat = "openai") -> list[dict[str, Any]]:
if tool_format == "openai":
return self._to_openai_format()
elif tool_format == "anthropic":
return self._to_anthropic_format()
else:
raise ValueError(f"Tool format '{tool_format}' is not supported")
def _to_openai_format(self) -> list[dict[str, Any]]:
"""Convert stored MCP tools to OpenAI function calling format.
Note: Tool names are normalized (dots replaced with underscores) because
OpenAI function names don't allow dots.
"""
openai_tools: list[dict[str, Any]] = []
for tool in self._tools.values():
parameters = tool.get("inputSchema", {"type": "object", "properties": {}})
if self._strict_mode and isinstance(parameters, dict):
parameters = convert_to_strict_mode_schema(parameters)
# Normalize tool name for OpenAI (e.g., "Google.Search" -> "Google_Search")
# OpenAI function names don't allow dots
tool_name = normalize_tool_name(tool["name"])
openai_tool: dict[str, Any] = {
"type": "function",
"function": {
"name": tool_name,
"description": tool.get("description", ""),
"parameters": parameters,
},
}
if self._strict_mode:
openai_tool["function"]["strict"] = True
openai_tools.append(openai_tool)
return openai_tools
def _to_anthropic_format(self) -> list[dict[str, Any]]:
"""Convert stored tools to Anthropic format.
Uses direct to_anthropic() from arcade-core for Python tools (when MaterializedTool available),
falls back to convert_mcp_to_anthropic_tool() for MCP/remote tools (JSON descriptors only).
"""
anthropic_tools: list[dict[str, Any]] = []
for tool_name, tool_descriptor in self._tools.items():
# Python tools: use direct converter (we have MaterializedTool)
if tool_name in self._materialized_tools:
anthropic_tool = to_anthropic(self._materialized_tools[tool_name])
anthropic_tools.append(dict(anthropic_tool))
else:
# MCP/remote tools: convert from JSON descriptor (no MaterializedTool available)
# Used for tools from: load_mcp_remote_async(), load_from_stdio_async(),
# load_arcade_mcp_gateway_async(), or add_tool_definitions()
anthropic_tools.append(convert_mcp_to_anthropic_tool(tool_descriptor))
return anthropic_tools
def _resolve_tool_name(self, tool_name: str) -> str | None:
"""Resolve a tool name to its original registry key.
Handles both original names (e.g., "Google.Search") and
normalized names (e.g., "Google_Search" from Anthropic).
Args:
tool_name: The tool name to resolve.
Returns:
The original tool name if found, None otherwise.
"""
# First, try direct lookup
if tool_name in self._tools:
return tool_name
# Then, check if it's a normalized name (from Anthropic)
original_name = self._normalized_to_original.get(tool_name)
if original_name and original_name in self._tools:
return original_name
return None
def normalize_args(self, tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
"""Apply schema defaults to arguments.
Fills in default values from the tool schema for:
- Missing parameters (key not in args)
- Null parameters (value is None), which OpenAI strict mode sends for optional params
This ensures that optional parameters with defaults are properly filled
even when the model sends null values.
"""
resolved_name = self._resolve_tool_name(tool_name)
tool = self._tools.get(resolved_name) if resolved_name else None
if not tool:
return args
schema = tool.get("inputSchema", {})
if not isinstance(schema, dict):
return args
properties = schema.get("properties", {})
if not isinstance(properties, dict):
return args
normalized = dict(args)
for prop_name, prop_schema in properties.items():
# Apply default if parameter is missing OR if it's null (None)
# OpenAI strict mode sends null for optional parameters that weren't provided
should_apply_default = (
isinstance(prop_schema, dict)
and "default" in prop_schema
and (prop_name not in normalized or normalized[prop_name] is None)
)
if should_apply_default:
normalized[prop_name] = prop_schema["default"]
return normalized
def get_tool_schema(self, tool_name: str) -> dict[str, Any] | None:
resolved_name = self._resolve_tool_name(tool_name)
return self._tools.get(resolved_name) if resolved_name else None
def has_tool(self, tool_name: str) -> bool:
return self._resolve_tool_name(tool_name) is not None
def resolve_tool_name(self, tool_name: str) -> str | None:
"""Public method to resolve a tool name to its original registry key.
This is useful for callers that need to look up tools by names
returned from providers (e.g., Anthropic returns underscore names).
Args:
tool_name: The tool name to resolve.
Returns:
The original tool name if found, None otherwise.
"""
return self._resolve_tool_name(tool_name)
def process_tool_call(self, tool_name: str, args: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Resolve tool name and apply schema defaults in one step.
This combines name resolution (for Anthropic underscore -> dot conversion)
with schema default application.
Args:
tool_name: The tool name (may be in provider format like "Google_Search").
args: The arguments from the tool call.
Returns:
Tuple of (resolved_name, args_with_defaults).
resolved_name will be the original registered name (e.g., "Google.Search")
or the input name if not found in registry.
"""
resolved_name = self._resolve_tool_name(tool_name) or tool_name
args_with_defaults = self.normalize_args(tool_name, args)
return resolved_name, args_with_defaults
def tool_names(self) -> list[str]:
return list(self._tools.keys())
def tool_count(self) -> int:
return len(self._tools)