# 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>
1028 lines
38 KiB
Python
1028 lines
38 KiB
Python
"""Tests for MCP tool schema converters (OpenAI and Anthropic formats)."""
|
|
|
|
import pytest
|
|
from arcade_evals._evalsuite._anthropic_schema import (
|
|
convert_mcp_to_anthropic_tool,
|
|
convert_mcp_tools_to_anthropic,
|
|
)
|
|
from arcade_evals._evalsuite._openai_schema import (
|
|
SchemaConversionError,
|
|
convert_to_strict_mode_schema,
|
|
)
|
|
from arcade_evals._evalsuite._tool_registry import EvalSuiteToolRegistry
|
|
|
|
# Mark all tests in this module as requiring evals dependencies
|
|
pytestmark = pytest.mark.evals
|
|
|
|
|
|
class TestOpenAISchemaConversion:
|
|
"""Tests for OpenAI strict mode schema conversion."""
|
|
|
|
def test_basic_schema_conversion(self):
|
|
"""Test basic schema conversion to OpenAI strict mode."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "Search query"},
|
|
},
|
|
"required": ["query"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
assert result["type"] == "object"
|
|
assert result["additionalProperties"] is False
|
|
assert "query" in result["properties"]
|
|
assert result["required"] == ["query"]
|
|
|
|
def test_optional_params_get_null_union(self):
|
|
"""Test that optional parameters get null union type."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"required_param": {"type": "string"},
|
|
"optional_param": {"type": "integer"},
|
|
},
|
|
"required": ["required_param"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
# Required param should have single type
|
|
assert result["properties"]["required_param"]["type"] == "string"
|
|
|
|
# Optional param should have null union
|
|
assert result["properties"]["optional_param"]["type"] == ["integer", "null"]
|
|
|
|
# Both should be in required (OpenAI strict mode requirement)
|
|
assert set(result["required"]) == {"required_param", "optional_param"}
|
|
|
|
def test_unsupported_keywords_stripped(self):
|
|
"""Test that unsupported JSON Schema keywords are stripped."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"count": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"maximum": 100,
|
|
"default": 10,
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
"minLength": 1,
|
|
"maxLength": 50,
|
|
"pattern": "^[a-z]+$",
|
|
"format": "hostname",
|
|
},
|
|
},
|
|
"required": ["count", "name"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
# These keywords should be stripped
|
|
count_prop = result["properties"]["count"]
|
|
assert "minimum" not in count_prop
|
|
assert "maximum" not in count_prop
|
|
assert "default" not in count_prop
|
|
|
|
name_prop = result["properties"]["name"]
|
|
assert "minLength" not in name_prop
|
|
assert "maxLength" not in name_prop
|
|
assert "pattern" not in name_prop
|
|
assert "format" not in name_prop
|
|
|
|
def test_enum_values_converted_to_strings(self):
|
|
"""Test that enum values are converted to strings."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"status": {
|
|
"type": "string",
|
|
"enum": [1, 2, "three"],
|
|
},
|
|
},
|
|
"required": ["status"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
assert result["properties"]["status"]["enum"] == ["1", "2", "three"]
|
|
|
|
def test_integer_enum_type_changed_to_string(self):
|
|
"""Test that integer enums have their type changed to string.
|
|
|
|
OpenAI strict mode validates enum values against the declared type.
|
|
When enum values are converted to strings, the type must also change.
|
|
|
|
Example error without fix:
|
|
"enum value 0 does not validate against {'type': ['integer', 'null']}"
|
|
"""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"priority": {
|
|
"type": "integer",
|
|
"enum": [0, 1, 2, 3, 4],
|
|
"description": "Priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low",
|
|
},
|
|
},
|
|
"required": ["priority"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
# Enum values should be strings
|
|
assert result["properties"]["priority"]["enum"] == ["0", "1", "2", "3", "4"]
|
|
# Type should be changed to string to match
|
|
assert result["properties"]["priority"]["type"] == "string"
|
|
|
|
def test_optional_integer_enum_type_changed_to_string_null_union(self):
|
|
"""Test that optional integer enums get type ["string", "null"].
|
|
|
|
When an integer enum is optional:
|
|
1. Enum values are converted to strings
|
|
2. Type changes from "integer" to ["string", "null"]
|
|
|
|
This fixes: "enum value 0 does not validate against {'type': ['integer', 'null']}"
|
|
"""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"priority": {
|
|
"type": "integer",
|
|
"enum": [0, 1, 2, 3, 4],
|
|
},
|
|
},
|
|
"required": [], # priority is optional
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
# Enum values should be strings
|
|
assert result["properties"]["priority"]["enum"] == ["0", "1", "2", "3", "4"]
|
|
# Type should be ["string", "null"] for optional param
|
|
assert result["properties"]["priority"]["type"] == ["string", "null"]
|
|
|
|
def test_string_enum_type_unchanged(self):
|
|
"""Test that string enums keep their type as string."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"status": {
|
|
"type": "string",
|
|
"enum": ["active", "inactive", "pending"],
|
|
},
|
|
},
|
|
"required": ["status"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
# Enum values unchanged
|
|
assert result["properties"]["status"]["enum"] == ["active", "inactive", "pending"]
|
|
# Type remains string
|
|
assert result["properties"]["status"]["type"] == "string"
|
|
|
|
def test_boolean_enum_type_changed_to_string(self):
|
|
"""Test that boolean enums have their type changed to string."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"flag": {
|
|
"type": "boolean",
|
|
"enum": [True, False],
|
|
},
|
|
},
|
|
"required": ["flag"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
# Boolean values converted to strings
|
|
assert result["properties"]["flag"]["enum"] == ["True", "False"]
|
|
# Type changed to string
|
|
assert result["properties"]["flag"]["type"] == "string"
|
|
|
|
def test_enum_with_list_type_no_null(self):
|
|
"""Test that enums with list type but no null are converted to single string type."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"priority": {
|
|
"type": ["integer"],
|
|
"enum": [1, 2, 3],
|
|
},
|
|
},
|
|
"required": ["priority"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
assert result["properties"]["priority"]["enum"] == ["1", "2", "3"]
|
|
# Should be "string", not ["string"]
|
|
assert result["properties"]["priority"]["type"] == "string"
|
|
|
|
def test_nested_object_enum_type_conversion(self):
|
|
"""Test that enum type conversion works in nested objects."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"config": {
|
|
"type": "object",
|
|
"properties": {
|
|
"level": {
|
|
"type": "integer",
|
|
"enum": [1, 2, 3],
|
|
},
|
|
},
|
|
"required": ["level"],
|
|
},
|
|
},
|
|
"required": ["config"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
nested = result["properties"]["config"]["properties"]["level"]
|
|
assert nested["enum"] == ["1", "2", "3"]
|
|
assert nested["type"] == "string"
|
|
|
|
def test_nested_object_gets_strict_mode(self):
|
|
"""Test that nested objects also get strict mode treatment."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"user": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"age": {"type": "integer"},
|
|
},
|
|
"required": ["name"],
|
|
},
|
|
},
|
|
"required": ["user"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
nested = result["properties"]["user"]
|
|
assert nested["additionalProperties"] is False
|
|
# Both should be in required for nested object too
|
|
assert set(nested["required"]) == {"name", "age"}
|
|
# age is optional so should have null union
|
|
assert nested["properties"]["age"]["type"] == ["integer", "null"]
|
|
|
|
def test_array_items_processed(self):
|
|
"""Test that array items schema is processed."""
|
|
input_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"items": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer", "minimum": 0},
|
|
},
|
|
"required": ["id"],
|
|
},
|
|
},
|
|
},
|
|
"required": ["items"],
|
|
}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
array_items = result["properties"]["items"]["items"]
|
|
assert array_items["additionalProperties"] is False
|
|
# minimum should be stripped from nested object property
|
|
assert "minimum" not in array_items["properties"]["id"]
|
|
|
|
def test_empty_schema(self):
|
|
"""Test conversion of empty schema."""
|
|
input_schema = {"type": "object", "properties": {}}
|
|
|
|
result = convert_to_strict_mode_schema(input_schema)
|
|
|
|
assert result["type"] == "object"
|
|
assert result["properties"] == {}
|
|
assert result["additionalProperties"] is False
|
|
assert result["required"] == []
|
|
|
|
def test_max_depth_protection(self):
|
|
"""Test that deeply nested schemas raise an error."""
|
|
# Create a deeply nested schema that exceeds max depth
|
|
schema: dict = {"type": "object", "properties": {}}
|
|
current = schema
|
|
for i in range(60): # Exceeds _MAX_SCHEMA_DEPTH of 50
|
|
current["properties"] = {"nested": {"type": "object", "properties": {}}}
|
|
current["required"] = ["nested"]
|
|
current = current["properties"]["nested"]
|
|
|
|
with pytest.raises(SchemaConversionError, match="maximum depth"):
|
|
convert_to_strict_mode_schema(schema)
|
|
|
|
|
|
class TestAnthropicSchemaConversion:
|
|
"""Tests for Anthropic schema conversion."""
|
|
|
|
def test_basic_conversion(self):
|
|
"""Test basic MCP to Anthropic tool conversion."""
|
|
mcp_tool = {
|
|
"name": "search_files",
|
|
"description": "Search for files",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "Search query"},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
assert result["name"] == "search_files"
|
|
assert result["description"] == "Search for files"
|
|
assert "input_schema" in result
|
|
assert "inputSchema" not in result
|
|
# Schema should be unchanged
|
|
assert result["input_schema"]["properties"]["query"]["type"] == "string"
|
|
|
|
def test_schema_preserved_as_is(self):
|
|
"""Test that the schema is preserved without modifications."""
|
|
mcp_tool = {
|
|
"name": "test",
|
|
"description": "Test",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"count": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"maximum": 100,
|
|
"default": 10,
|
|
},
|
|
},
|
|
"required": ["count"],
|
|
},
|
|
}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
# Unlike OpenAI, these keywords should be preserved
|
|
schema = result["input_schema"]
|
|
assert schema["properties"]["count"]["minimum"] == 0
|
|
assert schema["properties"]["count"]["maximum"] == 100
|
|
assert schema["properties"]["count"]["default"] == 10
|
|
|
|
def test_tool_name_dots_normalized_to_underscores(self):
|
|
"""Test that dots in tool names are converted to underscores.
|
|
|
|
Anthropic tool names must match pattern: ^[a-zA-Z0-9_-]{1,64}$
|
|
Dots are not allowed, so they must be converted.
|
|
"""
|
|
mcp_tool = {
|
|
"name": "Google.Search",
|
|
"description": "Search Google",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
assert result["name"] == "Google_Search"
|
|
|
|
def test_tool_name_hyphens_preserved(self):
|
|
"""Test that hyphens in tool names are preserved (they're valid)."""
|
|
mcp_tool = {
|
|
"name": "search-files",
|
|
"description": "Search files",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
assert result["name"] == "search-files"
|
|
|
|
def test_tool_name_multiple_dots(self):
|
|
"""Test that multiple dots are all converted to underscores."""
|
|
mcp_tool = {
|
|
"name": "Google.Gmail.Send.Email",
|
|
"description": "Send email",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
assert result["name"] == "Google_Gmail_Send_Email"
|
|
|
|
def test_missing_description_defaults_to_empty(self):
|
|
"""Test that missing description defaults to empty string."""
|
|
mcp_tool = {
|
|
"name": "test",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
assert result["description"] == ""
|
|
|
|
def test_missing_schema_defaults_to_empty_object(self):
|
|
"""Test that missing inputSchema defaults to empty object schema."""
|
|
mcp_tool = {"name": "test"}
|
|
|
|
result = convert_mcp_to_anthropic_tool(mcp_tool)
|
|
|
|
assert result["input_schema"] == {"type": "object", "properties": {}}
|
|
|
|
def test_convert_multiple_tools(self):
|
|
"""Test converting a list of MCP tools."""
|
|
mcp_tools = [
|
|
{"name": "tool1", "description": "First tool"},
|
|
{"name": "tool2", "description": "Second tool"},
|
|
]
|
|
|
|
result = convert_mcp_tools_to_anthropic(mcp_tools)
|
|
|
|
assert len(result) == 2
|
|
assert result[0]["name"] == "tool1"
|
|
assert result[1]["name"] == "tool2"
|
|
|
|
|
|
class TestToolRegistryOpenAIFormat:
|
|
"""Tests for EvalSuiteToolRegistry OpenAI format output."""
|
|
|
|
def test_list_tools_openai_format(self):
|
|
"""Test listing tools in OpenAI format."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True)
|
|
registry.add_tool({
|
|
"name": "search",
|
|
"description": "Search function",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("openai")
|
|
|
|
assert len(tools) == 1
|
|
tool = tools[0]
|
|
assert tool["type"] == "function"
|
|
assert tool["function"]["name"] == "search"
|
|
assert tool["function"]["strict"] is True
|
|
assert tool["function"]["parameters"]["additionalProperties"] is False
|
|
|
|
def test_list_tools_openai_without_strict_mode(self):
|
|
"""Test OpenAI format without strict mode."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=False)
|
|
registry.add_tool({
|
|
"name": "search",
|
|
"description": "Search",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "minLength": 1},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("openai")
|
|
|
|
tool = tools[0]
|
|
# No strict flag when strict_mode is False
|
|
assert "strict" not in tool["function"]
|
|
# Schema keywords should be preserved when strict_mode is False
|
|
assert tool["function"]["parameters"]["properties"]["query"]["minLength"] == 1
|
|
|
|
|
|
class TestToolRegistryAnthropicFormat:
|
|
"""Tests for EvalSuiteToolRegistry Anthropic format output."""
|
|
|
|
def test_list_tools_anthropic_format(self):
|
|
"""Test listing tools in Anthropic format."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "search",
|
|
"description": "Search function",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("anthropic")
|
|
|
|
assert len(tools) == 1
|
|
tool = tools[0]
|
|
# Anthropic format - flat structure
|
|
assert "type" not in tool
|
|
assert "function" not in tool
|
|
assert tool["name"] == "search"
|
|
assert tool["description"] == "Search function"
|
|
assert "input_schema" in tool
|
|
|
|
def test_anthropic_format_preserves_schema(self):
|
|
"""Test that Anthropic format preserves JSON Schema keywords."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True) # strict_mode shouldn't affect Anthropic
|
|
registry.add_tool({
|
|
"name": "test",
|
|
"description": "Test",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"count": {
|
|
"type": "integer",
|
|
"minimum": 0,
|
|
"maximum": 100,
|
|
},
|
|
},
|
|
"required": ["count"],
|
|
},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("anthropic")
|
|
|
|
# Schema should be preserved as-is for Anthropic
|
|
schema = tools[0]["input_schema"]
|
|
assert schema["properties"]["count"]["minimum"] == 0
|
|
assert schema["properties"]["count"]["maximum"] == 100
|
|
|
|
def test_anthropic_format_no_null_union(self):
|
|
"""Test that Anthropic format doesn't add null union types."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True)
|
|
registry.add_tool({
|
|
"name": "test",
|
|
"description": "Test",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"required_param": {"type": "string"},
|
|
"optional_param": {"type": "integer"},
|
|
},
|
|
"required": ["required_param"], # optional_param is optional
|
|
},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("anthropic")
|
|
|
|
# Optional param should NOT have null union for Anthropic
|
|
optional_type = tools[0]["input_schema"]["properties"]["optional_param"]["type"]
|
|
assert optional_type == "integer"
|
|
assert not isinstance(optional_type, list)
|
|
|
|
def test_anthropic_format_normalizes_tool_names(self):
|
|
"""Test that Anthropic format normalizes tool names (dots to underscores)."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Google.Gmail.Send",
|
|
"description": "Send email via Gmail",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("anthropic")
|
|
|
|
# Dots should be converted to underscores
|
|
assert tools[0]["name"] == "Google_Gmail_Send"
|
|
|
|
|
|
class TestToolRegistryOpenAINameNormalization:
|
|
"""Tests for OpenAI format tool name normalization."""
|
|
|
|
def test_openai_format_normalizes_tool_names(self):
|
|
"""Test that OpenAI format normalizes tool names (dots to underscores).
|
|
|
|
OpenAI function names don't allow dots, so they must be converted.
|
|
"""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True)
|
|
registry.add_tool({
|
|
"name": "Google.Search",
|
|
"description": "Search Google",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("openai")
|
|
|
|
# Dots should be converted to underscores
|
|
assert tools[0]["function"]["name"] == "Google_Search"
|
|
|
|
def test_openai_format_normalizes_multiple_dots(self):
|
|
"""Test that multiple dots are all converted to underscores for OpenAI."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True)
|
|
registry.add_tool({
|
|
"name": "Google.Gmail.Send.Email",
|
|
"description": "Send email",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("openai")
|
|
|
|
assert tools[0]["function"]["name"] == "Google_Gmail_Send_Email"
|
|
|
|
def test_openai_format_preserves_underscores(self):
|
|
"""Test that underscores in tool names are preserved for OpenAI."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True)
|
|
registry.add_tool({
|
|
"name": "search_files",
|
|
"description": "Search files",
|
|
"inputSchema": {"type": "object", "properties": {}},
|
|
})
|
|
|
|
tools = registry.list_tools_for_model("openai")
|
|
|
|
assert tools[0]["function"]["name"] == "search_files"
|
|
|
|
|
|
class TestToolRegistryFormatComparison:
|
|
"""Tests comparing OpenAI and Anthropic format outputs."""
|
|
|
|
def test_same_tool_different_formats(self):
|
|
"""Test that the same tool produces correct different formats."""
|
|
registry = EvalSuiteToolRegistry(strict_mode=True)
|
|
registry.add_tool({
|
|
"name": "search",
|
|
"description": "Search for items",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "Search query"},
|
|
"limit": {"type": "integer", "default": 10},
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
})
|
|
|
|
openai_tools = registry.list_tools_for_model("openai")
|
|
anthropic_tools = registry.list_tools_for_model("anthropic")
|
|
|
|
# OpenAI format
|
|
openai_tool = openai_tools[0]
|
|
assert openai_tool["type"] == "function"
|
|
assert openai_tool["function"]["strict"] is True
|
|
openai_params = openai_tool["function"]["parameters"]
|
|
assert openai_params["additionalProperties"] is False
|
|
# limit should have null union in OpenAI
|
|
assert openai_params["properties"]["limit"]["type"] == ["integer", "null"]
|
|
# default should be stripped in OpenAI
|
|
assert "default" not in openai_params["properties"]["limit"]
|
|
|
|
# Anthropic format
|
|
anthropic_tool = anthropic_tools[0]
|
|
assert "type" not in anthropic_tool
|
|
assert "function" not in anthropic_tool
|
|
anthropic_schema = anthropic_tool["input_schema"]
|
|
# limit should have simple type in Anthropic
|
|
assert anthropic_schema["properties"]["limit"]["type"] == "integer"
|
|
# default should be preserved in Anthropic
|
|
assert anthropic_schema["properties"]["limit"]["default"] == 10
|
|
|
|
def test_invalid_format_raises(self):
|
|
"""Test that invalid format raises ValueError."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "test"})
|
|
|
|
with pytest.raises(ValueError, match="not supported"):
|
|
registry.list_tools_for_model("invalid") # type: ignore
|
|
|
|
|
|
class TestToolRegistryMultipleTools:
|
|
"""Tests for registry with multiple tools."""
|
|
|
|
def test_multiple_tools_both_formats(self):
|
|
"""Test multiple tools converted to both formats."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tools([
|
|
{"name": "tool1", "description": "First"},
|
|
{"name": "tool2", "description": "Second"},
|
|
{"name": "tool3", "description": "Third"},
|
|
])
|
|
|
|
openai_tools = registry.list_tools_for_model("openai")
|
|
anthropic_tools = registry.list_tools_for_model("anthropic")
|
|
|
|
assert len(openai_tools) == 3
|
|
assert len(anthropic_tools) == 3
|
|
|
|
# Verify names are preserved
|
|
openai_names = {t["function"]["name"] for t in openai_tools}
|
|
anthropic_names = {t["name"] for t in anthropic_tools}
|
|
assert openai_names == {"tool1", "tool2", "tool3"}
|
|
assert anthropic_names == {"tool1", "tool2", "tool3"}
|
|
|
|
|
|
class TestToolNameResolution:
|
|
"""Tests for tool name resolution (handling Anthropic normalized names)."""
|
|
|
|
def test_resolve_original_name(self):
|
|
"""Test that original names resolve correctly."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search"})
|
|
|
|
assert registry.resolve_tool_name("Google.Search") == "Google.Search"
|
|
|
|
def test_resolve_normalized_name(self):
|
|
"""Test that normalized names (underscores) resolve to original."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search"})
|
|
|
|
# Anthropic returns "Google_Search" but tool is stored as "Google.Search"
|
|
assert registry.resolve_tool_name("Google_Search") == "Google.Search"
|
|
|
|
def test_resolve_unknown_name_returns_none(self):
|
|
"""Test that unknown names return None."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search"})
|
|
|
|
assert registry.resolve_tool_name("Unknown.Tool") is None
|
|
assert registry.resolve_tool_name("Unknown_Tool") is None
|
|
|
|
def test_has_tool_with_normalized_name(self):
|
|
"""Test has_tool works with normalized names."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Slack.Post"})
|
|
|
|
assert registry.has_tool("Slack.Post") is True
|
|
assert registry.has_tool("Slack_Post") is True # Normalized
|
|
assert registry.has_tool("Unknown") is False
|
|
|
|
def test_get_tool_schema_with_normalized_name(self):
|
|
"""Test get_tool_schema works with normalized names."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Email.Send",
|
|
"description": "Send email",
|
|
"inputSchema": {"type": "object", "properties": {"to": {"type": "string"}}},
|
|
})
|
|
|
|
# Original name
|
|
schema = registry.get_tool_schema("Email.Send")
|
|
assert schema is not None
|
|
assert schema["name"] == "Email.Send"
|
|
|
|
# Normalized name
|
|
schema = registry.get_tool_schema("Email_Send")
|
|
assert schema is not None
|
|
assert schema["name"] == "Email.Send"
|
|
|
|
def test_normalize_args_with_normalized_tool_name(self):
|
|
"""Test normalize_args works when called with normalized name."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Calendar.Create",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"duration": {"type": "integer", "default": 30},
|
|
},
|
|
},
|
|
})
|
|
|
|
# Call normalize_args with the Anthropic-returned name
|
|
result = registry.normalize_args("Calendar_Create", {"title": "Meeting"})
|
|
|
|
# Should apply defaults even though lookup was by normalized name
|
|
assert result["title"] == "Meeting"
|
|
assert result["duration"] == 30
|
|
|
|
def test_normalize_args_replaces_null_with_default(self):
|
|
"""Test normalize_args replaces null (None) values with defaults.
|
|
|
|
OpenAI strict mode sends null for optional parameters that weren't provided.
|
|
This test verifies that null values are replaced with schema defaults.
|
|
"""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Search",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer", "default": 10},
|
|
"offset": {"type": "integer", "default": 0},
|
|
},
|
|
},
|
|
})
|
|
|
|
# OpenAI strict mode might send null for optional params
|
|
result = registry.normalize_args("Search", {"query": "test", "limit": None, "offset": None})
|
|
|
|
# Null values should be replaced with defaults
|
|
assert result["query"] == "test"
|
|
assert result["limit"] == 10
|
|
assert result["offset"] == 0
|
|
|
|
def test_normalize_args_preserves_explicit_values(self):
|
|
"""Test normalize_args preserves explicitly set values (non-null)."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Search",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer", "default": 10},
|
|
},
|
|
},
|
|
})
|
|
|
|
# Explicit value should be preserved
|
|
result = registry.normalize_args("Search", {"query": "test", "limit": 50})
|
|
|
|
assert result["query"] == "test"
|
|
assert result["limit"] == 50 # Not replaced with default
|
|
|
|
def test_multiple_dots_in_name(self):
|
|
"""Test tools with multiple dots in name."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Gmail.Send"})
|
|
|
|
# Should normalize all dots
|
|
assert registry.resolve_tool_name("Google_Gmail_Send") == "Google.Gmail.Send"
|
|
assert registry.has_tool("Google_Gmail_Send") is True
|
|
|
|
def test_no_dot_in_name_no_mapping(self):
|
|
"""Test that tools without dots don't create unnecessary mappings."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "simple_tool"})
|
|
|
|
# Direct lookup works
|
|
assert registry.resolve_tool_name("simple_tool") == "simple_tool"
|
|
# No false positives
|
|
assert registry.resolve_tool_name("simple.tool") is None
|
|
|
|
def test_mixed_tools_resolution(self):
|
|
"""Test registry with mix of dotted and non-dotted names."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tools([
|
|
{"name": "Google.Search"},
|
|
{"name": "simple_search"},
|
|
{"name": "Slack.Channel.Create"},
|
|
])
|
|
|
|
# All originals resolve
|
|
assert registry.resolve_tool_name("Google.Search") == "Google.Search"
|
|
assert registry.resolve_tool_name("simple_search") == "simple_search"
|
|
assert registry.resolve_tool_name("Slack.Channel.Create") == "Slack.Channel.Create"
|
|
|
|
# Normalized versions resolve to originals
|
|
assert registry.resolve_tool_name("Google_Search") == "Google.Search"
|
|
assert registry.resolve_tool_name("Slack_Channel_Create") == "Slack.Channel.Create"
|
|
|
|
|
|
class TestProcessToolCall:
|
|
"""Tests for EvalSuiteToolRegistry.process_tool_call combined method."""
|
|
|
|
def test_process_tool_call_resolves_and_normalizes(self):
|
|
"""Test that process_tool_call resolves name and applies defaults."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Google.Search",
|
|
"description": "Search",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer", "default": 10},
|
|
},
|
|
},
|
|
})
|
|
|
|
# Anthropic-style name with missing default arg
|
|
resolved_name, args = registry.process_tool_call("Google_Search", {"query": "test"})
|
|
|
|
assert resolved_name == "Google.Search"
|
|
assert args == {"query": "test", "limit": 10}
|
|
|
|
def test_process_tool_call_unknown_tool(self):
|
|
"""Test that unknown tools keep original name."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "KnownTool"})
|
|
|
|
resolved_name, args = registry.process_tool_call("UnknownTool", {"arg": "value"})
|
|
|
|
assert resolved_name == "UnknownTool"
|
|
assert args == {"arg": "value"}
|
|
|
|
def test_process_tool_call_no_defaults_needed(self):
|
|
"""Test when all args provided."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({
|
|
"name": "Tool",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {"a": {"type": "string", "default": "x"}, "b": {"type": "string"}},
|
|
},
|
|
})
|
|
|
|
resolved_name, args = registry.process_tool_call("Tool", {"a": "provided", "b": "also"})
|
|
|
|
assert resolved_name == "Tool"
|
|
assert args == {"a": "provided", "b": "also"}
|
|
|
|
|
|
class TestToolRegistryErrors:
|
|
"""Tests for EvalSuiteToolRegistry error handling."""
|
|
|
|
def test_duplicate_tool_registration_raises_error(self):
|
|
"""Test that registering the same tool twice raises ValueError."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search", "description": "Search"})
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
registry.add_tool({"name": "Google.Search", "description": "Search again"})
|
|
|
|
assert "already registered" in str(exc_info.value)
|
|
assert "Google.Search" in str(exc_info.value)
|
|
|
|
def test_tool_without_name_raises_error(self):
|
|
"""Test that registering a tool without name raises ValueError."""
|
|
registry = EvalSuiteToolRegistry()
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
registry.add_tool({"description": "No name tool"})
|
|
|
|
assert "name" in str(exc_info.value).lower()
|
|
|
|
def test_empty_registry_tool_count(self):
|
|
"""Test that empty registry has zero tools."""
|
|
registry = EvalSuiteToolRegistry()
|
|
assert registry.tool_count() == 0
|
|
assert registry.tool_names() == []
|
|
|
|
def test_empty_registry_list_tools(self):
|
|
"""Test that empty registry returns empty list for both formats."""
|
|
registry = EvalSuiteToolRegistry()
|
|
assert registry.list_tools_for_model("openai") == []
|
|
assert registry.list_tools_for_model("anthropic") == []
|
|
|
|
def test_invalid_format_raises_error(self):
|
|
"""Test that invalid tool format raises ValueError."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "test"})
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
registry.list_tools_for_model("invalid_format") # type: ignore
|
|
|
|
assert "not supported" in str(exc_info.value)
|
|
|
|
|
|
class TestToolNameCollisions:
|
|
"""Tests for handling tool name collisions during normalization."""
|
|
|
|
def test_different_original_names_same_normalized(self):
|
|
"""Test that tools with different original names but same normalized name are both registered.
|
|
|
|
This is the expected behavior: `Google.Search` and `Google_Search` are treated as
|
|
different tools because the registry uses original names as keys.
|
|
The normalized name mapping only helps with lookup (for Anthropic format).
|
|
"""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search", "description": "Dot version"})
|
|
registry.add_tool({"name": "Google_Search", "description": "Underscore version"})
|
|
|
|
# Both tools should be registered
|
|
assert registry.tool_count() == 2
|
|
assert "Google.Search" in registry.tool_names()
|
|
assert "Google_Search" in registry.tool_names()
|
|
|
|
def test_normalized_name_resolution_prefers_underscore_version(self):
|
|
"""Test that when both Google.Search and Google_Search exist,
|
|
resolving 'Google_Search' returns the explicit underscore version.
|
|
"""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search", "description": "Dot version"})
|
|
registry.add_tool({"name": "Google_Search", "description": "Underscore version"})
|
|
|
|
# "Google_Search" should resolve to itself (explicit match)
|
|
resolved = registry.resolve_tool_name("Google_Search")
|
|
assert resolved == "Google_Search"
|
|
|
|
# "Google.Search" should resolve to itself (exact match)
|
|
resolved = registry.resolve_tool_name("Google.Search")
|
|
assert resolved == "Google.Search"
|
|
|
|
def test_normalized_lookup_when_only_dot_version_exists(self):
|
|
"""Test that normalized name lookup works when only dot version exists."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search", "description": "Dot version"})
|
|
|
|
# "Google_Search" should resolve to "Google.Search"
|
|
resolved = registry.resolve_tool_name("Google_Search")
|
|
assert resolved == "Google.Search"
|
|
|
|
def test_anthropic_format_normalizes_names(self):
|
|
"""Test that Anthropic format output uses normalized names (underscores)."""
|
|
registry = EvalSuiteToolRegistry()
|
|
registry.add_tool({"name": "Google.Search", "description": "Search"})
|
|
|
|
tools = registry.list_tools_for_model("anthropic")
|
|
|
|
# Anthropic format should have normalized name
|
|
assert tools[0]["name"] == "Google_Search"
|