evalstone/evalscope/tests/api/test_mcp_atlas_benchmark.py
sora 13274243a0 Bump vendored EvalScope and add K3-ready DPV4 configs.
Keep K3 suite selection and report-schema scoring in bash, merge K3/vision dataset_args into dpv4 yamls, and pin EvalScope at 735d920ee911 with local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 07:30:48 +00:00

558 lines
18 KiB
Python

import asyncio
import csv
import json
from pathlib import Path
from typing import Any, Dict, List
import pytest
from evalscope.api.benchmark import BenchmarkMeta
from evalscope.api.dataset import Sample
from evalscope.api.evaluator import TaskState
from evalscope.api.metric import SampleScore, Score
from evalscope.api.model import ModelOutput
from evalscope.api.registry import get_benchmark
from evalscope.api.tool import ToolCall, ToolFunction
from evalscope.benchmarks.mcp_atlas.mcp_atlas_adapter import MCPAtlasAdapter
from evalscope.benchmarks.mcp_atlas.utils import (
MCPAtlasClient,
MCPAtlasServerUnavailable,
extract_claims,
extract_required_servers,
is_transport_error,
mcp_tool_to_tool_info,
parse_enabled_tools,
)
from evalscope.config import TaskConfig
from evalscope.constants import HubType, ScoreStatus
class FakeClient:
def __init__(self) -> None:
self.calls: List[Dict[str, Any]] = []
def enabled_servers(self) -> List[str]:
return ['wikipedia']
def list_tools(self) -> List[Dict[str, Any]]:
return [
{
'name': 'wikipedia_get_article',
'description': 'Fetch a Wikipedia article.',
'inputSchema': {
'type': 'object',
'properties': {
'title': {
'type': 'string',
'description': 'Article title.',
}
},
'required': ['title'],
},
},
{
'name': 'github_search_repositories',
'description': 'Search repositories.',
'inputSchema': {
'type': 'object',
'properties': {},
},
},
]
def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> str:
self.calls.append({'tool_name': tool_name, 'tool_args': tool_args})
return 'tool result'
class PromptJudge:
model_id = 'judge-model'
judge_id = 'judge-model'
def generate(self, messages: Any) -> ModelOutput:
text = messages[-1].content if messages else ''
outcome = 'not_fulfilled'
if 'claim one' in text:
outcome = 'fulfilled'
elif 'claim two' in text:
outcome = 'partially_fulfilled'
return ModelOutput.from_content(self.model_id, json.dumps({
'coverage_outcome': outcome,
'justification': outcome,
'confidence_level': 0.9,
}))
def make_adapter(limit: Any = None, local_path: str = '', **extra_params: Any) -> MCPAtlasAdapter:
base_extra_params = {
'mcp_server_url': 'http://localhost:1984',
'filter_enabled_servers': True,
'max_tool_calls': 100,
'request_timeout': 60.0,
'list_tools_timeout': 180.0,
'use_system_prompt': False,
'pass_threshold': 0.75,
}
base_extra_params.update(extra_params)
meta = BenchmarkMeta(
name='mcp_atlas',
dataset_id='ScaleAI/MCP-Atlas',
subset_list=['default'],
default_subset='default',
eval_split='train',
prompt_template='{question}',
metric_list=['coverage_score', 'pass_rate'],
# Mirrors the adapter: a benchmark reporting several metrics must say which one carries
# the conclusion, so the fixture has to declare it too.
primary_metric='pass_rate',
extra_params=base_extra_params,
)
dataset_args = {'extra_params': extra_params}
if local_path:
dataset_args['local_path'] = local_path
meta._update({'local_path': local_path})
cfg = TaskConfig(
datasets=['mcp_atlas'],
dataset_args={'mcp_atlas': dataset_args},
dataset_hub=HubType.MODELSCOPE,
limit=limit,
)
return MCPAtlasAdapter(benchmark_meta=meta, task_config=cfg)
def write_rows(path: Path, rows: List[Dict[str, Any]]) -> None:
with path.open('w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['TASK', 'PROMPT', 'ENABLED_TOOLS', 'TRAJECTORY', 'GTFA_CLAIMS'])
writer.writeheader()
writer.writerows(rows)
def test_mcp_atlas_registered_under_short_name() -> None:
cfg = TaskConfig(datasets=['mcp_atlas'], judge={'models': {'model_id': 'judge'}})
adapter = get_benchmark('mcp_atlas', cfg)
assert isinstance(adapter, MCPAtlasAdapter)
assert adapter.name == 'mcp_atlas'
def test_mcp_atlas_rejects_rule_scoring_before_generating() -> None:
cfg = TaskConfig(datasets=['mcp_atlas'], judge={'strategy': 'rule'})
adapter = get_benchmark('mcp_atlas', cfg)
with pytest.raises(ValueError, match='no usable rule-based scoring'):
adapter.validate_judge_strategy()
def test_parse_enabled_tools_accepts_strings_and_objects() -> None:
tools = parse_enabled_tools(
json.dumps([
'wikipedia_get_article',
{
'name': 'github_search_repositories'
},
'wikipedia_get_article',
123,
])
)
assert tools == ['wikipedia_get_article', 'github_search_repositories']
def test_extract_required_servers_uses_trajectory_tool_calls() -> None:
trajectory = json.dumps([{
'role': 'assistant',
'tool_calls': [
{
'function': {
'name': 'wikipedia_get_article'
}
},
{
'function': {
'name': 'MongoDB_find'
}
},
{
'function': {
'name': 'brave_brave_web_search'
}
},
],
}])
assert extract_required_servers(trajectory) == ['brave-search', 'mongodb', 'wikipedia']
def test_record_to_sample_sets_metadata_and_claims() -> None:
adapter = make_adapter()
adapter._client = FakeClient()
sample = adapter.record_to_sample({
'TASK': 'task-1',
'PROMPT': 'Find the answer.',
'ENABLED_TOOLS': json.dumps(['wikipedia_get_article']),
'TRAJECTORY': json.dumps([]),
'GTFA_CLAIMS': json.dumps([{
'claim': 'The answer cites the page.'
}]),
})
assert sample.input == 'Find the answer.'
assert extract_claims(sample.target) == ['The answer cites the page.']
assert sample.metadata['task_id'] == 'task-1'
assert sample.metadata['enabled_tools'] == ['wikipedia_get_article']
assert sample.tools[0].name == 'wikipedia_get_article'
def test_extract_claims_accepts_python_list_literal() -> None:
claims = extract_claims("['claim one', 'claim two']")
assert claims == ['claim one', 'claim two']
def test_extract_claims_flattens_cached_single_literal_list() -> None:
claims = extract_claims(json.dumps(["['claim one', 'claim two']"]))
assert claims == ['claim one', 'claim two']
def test_tool_info_conversion_accepts_input_schema() -> None:
tool_info = mcp_tool_to_tool_info({
'name': 'wikipedia_get_article',
'description': 'Fetch a Wikipedia article.',
'inputSchema': {
'type': 'object',
'properties': {
'title': {
'type': 'string',
'description': 'Article title.',
}
},
'required': ['title'],
},
})
assert tool_info.name == 'wikipedia_get_article'
assert tool_info.parameters.required == ['title']
assert tool_info.parameters.properties['title'].type == 'string'
def test_client_uses_mcp_atlas_http_endpoints(monkeypatch: Any) -> None:
requests_seen: List[Dict[str, Any]] = []
class Response:
def __init__(self, status_code: int, payload: Any) -> None:
self.status_code = status_code
self.payload = payload
self.text = json.dumps(payload)
def raise_for_status(self) -> None:
pass
def json(self) -> Any:
return self.payload
def fake_get(url: str, timeout: float) -> Response:
requests_seen.append({'method': 'GET', 'url': url, 'timeout': timeout})
return Response(200, {'enabled_servers': ['wikipedia']})
def fake_post(url: str, **kwargs: Any) -> Response:
requests_seen.append({'method': 'POST', 'url': url, **kwargs})
if url.endswith('/list-tools'):
return Response(200, [{'name': 'wikipedia_get_article'}])
if url.endswith('/call-tool'):
return Response(200, [{'type': 'text', 'text': 'article text'}])
raise AssertionError(f'Unexpected URL: {url}')
monkeypatch.setattr('requests.get', fake_get)
monkeypatch.setattr('requests.post', fake_post)
client = MCPAtlasClient('http://localhost:1984/', request_timeout=7.0, list_tools_timeout=11.0)
assert client.enabled_servers() == ['wikipedia']
assert client.list_tools() == [{'name': 'wikipedia_get_article'}]
assert client.call_tool('wikipedia_get_article', {'title': 'MCP'}) == 'article text'
assert requests_seen[0] == {
'method': 'GET',
'url': 'http://localhost:1984/enabled-servers',
'timeout': 11.0,
}
assert requests_seen[1]['url'] == 'http://localhost:1984/list-tools'
assert requests_seen[2]['json'] == {
'tool_name': 'wikipedia_get_article',
'tool_args': {
'title': 'MCP'
},
}
def test_client_accepts_servers_dict_response(monkeypatch: Any) -> None:
class Response:
def raise_for_status(self) -> None:
pass
def json(self) -> Dict[str, Any]:
return {
'servers': {
'wikipedia': 'OK',
'github': 'ERROR_NOT_ONLINE',
}
}
def fake_get(url: str, timeout: float) -> Response:
return Response()
monkeypatch.setattr('requests.get', fake_get)
client = MCPAtlasClient('http://localhost:1984', request_timeout=7.0, list_tools_timeout=11.0)
assert client.enabled_servers() == ['wikipedia']
def test_client_marks_transport_500_as_unavailable(monkeypatch: Any) -> None:
class Response:
status_code = 500
text = '{"detail":"connect ECONNREFUSED 199.193.116.105:443"}'
def json(self) -> Any:
return {}
def fake_post(url: str, **kwargs: Any) -> Response:
return Response()
monkeypatch.setattr('requests.post', fake_post)
client = MCPAtlasClient('http://localhost:1984', request_timeout=7.0, list_tools_timeout=11.0)
with pytest.raises(MCPAtlasServerUnavailable) as exc_info:
client.call_tool('open-library_get_book_by_title', {'title': 'The Sins of the Wolf'})
assert exc_info.value.server_name == 'open-library'
assert is_transport_error(str(exc_info.value))
def test_client_returns_bounded_http_error(monkeypatch: Any) -> None:
class Response:
status_code = 500
text = 'x' * 1200
def json(self) -> Any:
return {}
def fake_post(url: str, **kwargs: Any) -> Response:
return Response()
monkeypatch.setattr('requests.post', fake_post)
client = MCPAtlasClient('http://localhost:1984', request_timeout=7.0, list_tools_timeout=11.0)
result = client.call_tool('wikipedia_get_article', {'title': 'MCP'})
assert result.startswith('Error calling tool wikipedia_get_article (HTTP 500): ')
assert result.endswith('...')
assert len(result) < 1100
def test_client_handles_malformed_json_response(monkeypatch: Any) -> None:
class Response:
status_code = 200
text = '{not json'
def json(self) -> Any:
raise ValueError('bad json')
def fake_post(url: str, **kwargs: Any) -> Response:
return Response()
monkeypatch.setattr('requests.post', fake_post)
client = MCPAtlasClient('http://localhost:1984', request_timeout=7.0, list_tools_timeout=11.0)
result = client.call_tool('wikipedia_get_article', {'title': 'MCP'})
assert result == 'Error decoding tool response JSON from wikipedia_get_article: bad json. Raw response: {not json'
def test_load_dataset_filters_missing_servers_and_attaches_tools(tmp_path: Path) -> None:
dataset_path = tmp_path / 'mcp_atlas.csv'
write_rows(
dataset_path, [
{
'TASK': 'keep',
'PROMPT': 'Use Wikipedia.',
'ENABLED_TOOLS': json.dumps(['wikipedia_get_article']),
'TRAJECTORY': json.dumps([{
'tool_calls': [{
'function': {
'name': 'wikipedia_get_article'
}
}]
}]),
'GTFA_CLAIMS': json.dumps(['Uses the Wikipedia result.']),
},
{
'TASK': 'drop',
'PROMPT': 'Use GitHub.',
'ENABLED_TOOLS': json.dumps(['github_search_repositories']),
'TRAJECTORY': json.dumps([{
'tool_calls': [{
'function': {
'name': 'github_search_repositories'
}
}]
}]),
'GTFA_CLAIMS': json.dumps(['Uses the GitHub result.']),
},
]
)
adapter = make_adapter(local_path=str(tmp_path))
adapter._client = FakeClient()
dataset = adapter.load_dataset()['default']
assert len(dataset) == 1
assert dataset[0].metadata['task_id'] == 'keep'
assert dataset[0].tools[0].name == 'wikipedia_get_article'
assert adapter._excluded_tasks == [{'task_id': 'drop', 'missing_servers': ['github']}]
def test_sample_filter_logs_missing_servers(monkeypatch: Any) -> None:
logs: List[Dict[str, Any]] = []
def fake_warning(message: str, task_id: str, missing: List[str]) -> None:
logs.append({
'message': message,
'task_id': task_id,
'missing': missing,
})
monkeypatch.setattr('evalscope.benchmarks.mcp_atlas.mcp_atlas_adapter.logger.warning', fake_warning)
adapter = make_adapter()
adapter._enabled_servers = ['wikipedia']
sample = Sample(input='prompt', metadata={'task_id': 'drop', 'required_servers': ['github']})
assert adapter.sample_filter(sample) is False
assert adapter._excluded_tasks == [{'task_id': 'drop', 'missing_servers': ['github']}]
assert logs == [{
'message': 'Skipping MCP-Atlas task %s because required servers are not enabled: %s',
'task_id': 'drop',
'missing': ['github'],
}]
def test_build_tools_enforces_tool_call_limit() -> None:
adapter = make_adapter(max_tool_calls=1)
fake_client = FakeClient()
adapter._client = fake_client
sample = Sample(
input='prompt',
id=3,
metadata={'enabled_tools': ['wikipedia_get_article']},
)
handler = adapter.build_tools(sample)['wikipedia_get_article']
call = ToolCall(
id='call-1',
function=ToolFunction(name='wikipedia_get_article', arguments={'title': 'MCP'}),
)
first = asyncio.run(handler(call, None))
second = asyncio.run(handler(call, None))
assert first == 'tool result'
assert second == 'MCP-Atlas tool call limit exceeded (1).'
assert fake_client.calls == [{'tool_name': 'wikipedia_get_article', 'tool_args': {'title': 'MCP'}}]
def test_build_tools_short_circuits_failed_server() -> None:
class FailingClient(FakeClient):
def call_tool(self, tool_name: str, tool_args: Dict[str, Any]) -> str:
self.calls.append({'tool_name': tool_name, 'tool_args': tool_args})
raise MCPAtlasServerUnavailable(tool_name, 'connect ECONNREFUSED 199.193.116.105:443')
adapter = make_adapter()
fake_client = FailingClient()
adapter._client = fake_client
sample = Sample(
input='prompt',
id=4,
metadata={'enabled_tools': ['open-library_get_book_by_title', 'open-library_get_authors_by_name']},
)
handlers = adapter.build_tools(sample)
first_call = ToolCall(
id='call-1',
function=ToolFunction(name='open-library_get_book_by_title', arguments={'title': 'The Sins of the Wolf'}),
)
second_call = ToolCall(
id='call-2',
function=ToolFunction(name='open-library_get_authors_by_name', arguments={'name': 'Anne Perry'}),
)
first = asyncio.run(handlers['open-library_get_book_by_title'](first_call, None))
second = asyncio.run(handlers['open-library_get_authors_by_name'](second_call, None))
assert "MCP server 'open-library' is unavailable" in first
assert "MCP server 'open-library' is unavailable" in second
assert fake_client.calls == [{
'tool_name': 'open-library_get_book_by_title',
'tool_args': {
'title': 'The Sins of the Wolf'
},
}]
def test_claim_coverage_averages_per_claim_verdicts() -> None:
adapter = make_adapter(pass_threshold=0.75)
adapter.llm_judge = PromptJudge()
sample = Sample(
input='question',
target=json.dumps(['claim one', 'claim two', 'claim three']),
id=0,
)
state = TaskState(
model='mock',
sample=sample,
output=ModelOutput.from_content(model='mock', content='answer'),
completed=True,
)
score = adapter.calculate_metrics(state).score
# fulfilled(1.0) + partially(0.5) + not(0.0) over 3 claims = 0.5
assert score.value['coverage_score'] == pytest.approx(0.5)
assert score.value['pass'] == 0.0
assert score.metadata['fully_covered_claims'] == 1
assert score.metadata['partially_covered_claims'] == 1
def test_claim_coverage_excludes_a_sample_whose_claim_cannot_be_parsed() -> None:
"""The old fallback set the outcome by any of fulfilled/partially_fulfilled/not_fulfilled
appearing anywhere in prose; an unparseable verdict now excludes the sample instead."""
class ProseJudge:
model_id = 'judge-model'
judge_id = 'judge-model'
def generate(self, messages: Any) -> ModelOutput:
return ModelOutput.from_content(self.model_id, 'The claim is clearly fulfilled by the response.')
adapter = make_adapter()
adapter.llm_judge = ProseJudge()
sample = Sample(input='q', target=json.dumps(['claim one']), id=0)
state = TaskState(
model='mock',
sample=sample,
output=ModelOutput.from_content(model='mock', content='answer'),
completed=True,
)
score = adapter.calculate_metrics(state).score
assert score.value == {}
assert score.status is ScoreStatus.EXCLUDED