mcplint-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcplint/__about__.py +1 -0
- mcplint/__init__.py +3 -0
- mcplint/benchmark/__init__.py +0 -0
- mcplint/benchmark/dataset.py +34 -0
- mcplint/benchmark/providers/__init__.py +0 -0
- mcplint/benchmark/providers/anthropic_provider.py +92 -0
- mcplint/benchmark/providers/base.py +26 -0
- mcplint/benchmark/providers/factory.py +51 -0
- mcplint/benchmark/providers/fake.py +26 -0
- mcplint/benchmark/providers/openai_provider.py +25 -0
- mcplint/benchmark/runner.py +43 -0
- mcplint/benchmark/scorer.py +147 -0
- mcplint/cli/__init__.py +0 -0
- mcplint/cli/commands/__init__.py +0 -0
- mcplint/cli/commands/benchmark_cmd.py +88 -0
- mcplint/cli/commands/compare_cmd.py +139 -0
- mcplint/cli/commands/fix_cmd.py +60 -0
- mcplint/cli/commands/inspect_cmd.py +44 -0
- mcplint/cli/commands/rules_cmd.py +30 -0
- mcplint/cli/commands/scan_cmd.py +113 -0
- mcplint/cli/commands/snapshot_cmd.py +33 -0
- mcplint/cli/main.py +49 -0
- mcplint/compare/__init__.py +0 -0
- mcplint/compare/differ.py +148 -0
- mcplint/config/__init__.py +0 -0
- mcplint/config/loader.py +36 -0
- mcplint/config/schema.py +40 -0
- mcplint/core/__init__.py +0 -0
- mcplint/core/engine.py +40 -0
- mcplint/core/registry.py +52 -0
- mcplint/core/rules/__init__.py +0 -0
- mcplint/core/rules/ambiguity.py +157 -0
- mcplint/core/rules/ambiguity_rules.py +109 -0
- mcplint/core/rules/base.py +39 -0
- mcplint/core/rules/builtin.py +45 -0
- mcplint/core/rules/completeness_rules.py +217 -0
- mcplint/core/rules/description_rules.py +110 -0
- mcplint/core/rules/safety_rules.py +139 -0
- mcplint/core/rules/schema_rules.py +101 -0
- mcplint/core/score.py +132 -0
- mcplint/fix/__init__.py +0 -0
- mcplint/fix/suggest.py +183 -0
- mcplint/mcp_client/__init__.py +0 -0
- mcplint/mcp_client/canonical.py +25 -0
- mcplint/mcp_client/persistence.py +17 -0
- mcplint/mcp_client/session.py +80 -0
- mcplint/mcp_client/stdio.py +17 -0
- mcplint/models/__init__.py +0 -0
- mcplint/models/benchmark.py +67 -0
- mcplint/models/common.py +23 -0
- mcplint/models/comparison.py +53 -0
- mcplint/models/contracts.py +39 -0
- mcplint/models/findings.py +44 -0
- mcplint/models/fixes.py +13 -0
- mcplint/models/score.py +25 -0
- mcplint/models/snapshot.py +27 -0
- mcplint/py.typed +0 -0
- mcplint/reporters/__init__.py +0 -0
- mcplint/reporters/benchmark_terminal.py +46 -0
- mcplint/reporters/comparison_terminal.py +61 -0
- mcplint/reporters/fix_markdown.py +34 -0
- mcplint/reporters/html.py +71 -0
- mcplint/reporters/json_reporter.py +9 -0
- mcplint/reporters/sarif.py +77 -0
- mcplint/reporters/templates/report.html.j2 +176 -0
- mcplint/reporters/terminal.py +52 -0
- mcplint_cli-0.1.0.dist-info/METADATA +392 -0
- mcplint_cli-0.1.0.dist-info/RECORD +71 -0
- mcplint_cli-0.1.0.dist-info/WHEEL +4 -0
- mcplint_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mcplint_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
mcplint/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
mcplint/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Load and validate a benchmark dataset YAML file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from mcplint.models.benchmark import BenchmarkDataset
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatasetError(Exception):
|
|
14
|
+
"""Raised when a benchmark dataset file is missing or fails validation."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_dataset(path: Path) -> BenchmarkDataset:
|
|
18
|
+
if not path.exists():
|
|
19
|
+
raise DatasetError(f"Benchmark dataset not found: {path}")
|
|
20
|
+
|
|
21
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
22
|
+
if not isinstance(raw, dict):
|
|
23
|
+
raise DatasetError(
|
|
24
|
+
f"{path}: expected a YAML mapping at the top level, got {type(raw).__name__}"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
return BenchmarkDataset.model_validate(raw)
|
|
29
|
+
except ValidationError as exc:
|
|
30
|
+
messages = "\n".join(
|
|
31
|
+
f" - {'.'.join(str(loc) for loc in error['loc'])}: {error['msg']}"
|
|
32
|
+
for error in exc.errors()
|
|
33
|
+
)
|
|
34
|
+
raise DatasetError(f"{path}: invalid benchmark dataset:\n{messages}") from exc
|
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Anthropic ToolCallingProvider adapter.
|
|
2
|
+
|
|
3
|
+
Requires the `anthropic` extra: `pip install "mcplint[anthropic]"`. The
|
|
4
|
+
`anthropic` package is imported lazily in `__init__` so importing this
|
|
5
|
+
module (or the rest of mcplint) never requires it to be installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from mcplint.benchmark.providers.base import ProviderResult
|
|
14
|
+
from mcplint.models.contracts import ToolContract
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import anthropic
|
|
18
|
+
|
|
19
|
+
# Illustrative only — not authoritative pricing. USD per 1M tokens.
|
|
20
|
+
_PRICING_PER_MILLION_TOKENS: dict[str, tuple[float, float]] = {
|
|
21
|
+
"claude-sonnet-5": (3.0, 15.0),
|
|
22
|
+
"claude-opus-5": (15.0, 75.0),
|
|
23
|
+
"claude-haiku-4-5-20251001": (0.8, 4.0),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnthropicProvider:
|
|
28
|
+
def __init__(self, model: str, *, api_key: str | None = None, max_tokens: int = 1024) -> None:
|
|
29
|
+
try:
|
|
30
|
+
import anthropic as anthropic_module
|
|
31
|
+
except ImportError as exc:
|
|
32
|
+
raise ImportError(
|
|
33
|
+
"The 'anthropic' package is required for --provider anthropic. "
|
|
34
|
+
'Install it with: pip install "mcplint[anthropic]"'
|
|
35
|
+
) from exc
|
|
36
|
+
|
|
37
|
+
self.name = "anthropic"
|
|
38
|
+
self.model = model
|
|
39
|
+
self.max_tokens = max_tokens
|
|
40
|
+
self.client: anthropic.AsyncAnthropic = anthropic_module.AsyncAnthropic(api_key=api_key)
|
|
41
|
+
|
|
42
|
+
async def run(self, prompt: str, tools: list[ToolContract]) -> ProviderResult:
|
|
43
|
+
anthropic_tools: list[dict[str, Any]] = [
|
|
44
|
+
{
|
|
45
|
+
"name": tool.name,
|
|
46
|
+
"description": tool.description or "",
|
|
47
|
+
"input_schema": tool.input_schema,
|
|
48
|
+
}
|
|
49
|
+
for tool in tools
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
start = time.monotonic()
|
|
53
|
+
try:
|
|
54
|
+
message = await self.client.messages.create(
|
|
55
|
+
model=self.model,
|
|
56
|
+
max_tokens=self.max_tokens,
|
|
57
|
+
messages=[{"role": "user", "content": prompt}],
|
|
58
|
+
tools=anthropic_tools, # type: ignore[arg-type]
|
|
59
|
+
)
|
|
60
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a scored trial error, not a crash
|
|
61
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
62
|
+
return ProviderResult(tool=None, latency_ms=latency_ms, error=str(exc))
|
|
63
|
+
|
|
64
|
+
latency_ms = (time.monotonic() - start) * 1000
|
|
65
|
+
tool_use = next((block for block in message.content if block.type == "tool_use"), None)
|
|
66
|
+
input_tokens = message.usage.input_tokens
|
|
67
|
+
output_tokens = message.usage.output_tokens
|
|
68
|
+
|
|
69
|
+
if tool_use is None:
|
|
70
|
+
return ProviderResult(
|
|
71
|
+
tool=None,
|
|
72
|
+
latency_ms=latency_ms,
|
|
73
|
+
input_tokens=input_tokens,
|
|
74
|
+
output_tokens=output_tokens,
|
|
75
|
+
estimated_cost=self._estimate_cost(input_tokens, output_tokens),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return ProviderResult(
|
|
79
|
+
tool=tool_use.name,
|
|
80
|
+
arguments=dict(tool_use.input),
|
|
81
|
+
latency_ms=latency_ms,
|
|
82
|
+
input_tokens=input_tokens,
|
|
83
|
+
output_tokens=output_tokens,
|
|
84
|
+
estimated_cost=self._estimate_cost(input_tokens, output_tokens),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _estimate_cost(self, input_tokens: int, output_tokens: int) -> float | None:
|
|
88
|
+
pricing = _PRICING_PER_MILLION_TOKENS.get(self.model)
|
|
89
|
+
if pricing is None:
|
|
90
|
+
return None
|
|
91
|
+
input_price, output_price = pricing
|
|
92
|
+
return (input_tokens * input_price + output_tokens * output_price) / 1_000_000
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Provider interface every benchmark backend (fake, Anthropic, OpenAI) implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from mcplint.models.contracts import ToolContract
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProviderResult(BaseModel):
|
|
13
|
+
tool: str | None
|
|
14
|
+
arguments: dict[str, object] = Field(default_factory=dict)
|
|
15
|
+
latency_ms: float = 0.0
|
|
16
|
+
input_tokens: int | None = None
|
|
17
|
+
output_tokens: int | None = None
|
|
18
|
+
estimated_cost: float | None = None
|
|
19
|
+
error: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ToolCallingProvider(Protocol):
|
|
23
|
+
name: str
|
|
24
|
+
model: str
|
|
25
|
+
|
|
26
|
+
async def run(self, prompt: str, tools: list[ToolContract]) -> ProviderResult: ...
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Resolves a --provider/--model CLI selection to a ToolCallingProvider instance.
|
|
2
|
+
|
|
3
|
+
"fake" and "anthropic" are implemented. "openai" is a typed stub — see
|
|
4
|
+
`openai_provider.py` — that raises NotImplementedError only when actually
|
|
5
|
+
invoked, per the spec's "don't let OpenAI delay Anthropic" guidance.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from mcplint.benchmark.providers.base import ProviderResult, ToolCallingProvider
|
|
11
|
+
from mcplint.benchmark.providers.fake import FakeProvider
|
|
12
|
+
from mcplint.models.contracts import ToolContract
|
|
13
|
+
|
|
14
|
+
SUPPORTED_PROVIDERS = ("fake", "anthropic", "openai")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderNotAvailableError(Exception):
|
|
18
|
+
"""Raised when a requested provider isn't implemented yet or misconfigured."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _echo_first_tool(prompt: str, tools: list[ToolContract]) -> ProviderResult:
|
|
22
|
+
if not tools:
|
|
23
|
+
return ProviderResult(tool=None, error="no tools available")
|
|
24
|
+
return ProviderResult(tool=tools[0].name, arguments={})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_provider(provider: str, model: str | None) -> ToolCallingProvider:
|
|
28
|
+
if provider == "fake":
|
|
29
|
+
return FakeProvider(model=model or "fake-echo-model", responder=_echo_first_tool)
|
|
30
|
+
|
|
31
|
+
if provider == "anthropic":
|
|
32
|
+
if model is None:
|
|
33
|
+
raise ProviderNotAvailableError(
|
|
34
|
+
"--model is required for --provider anthropic (e.g. claude-sonnet-5)."
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
from mcplint.benchmark.providers.anthropic_provider import AnthropicProvider
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
raise ProviderNotAvailableError(str(exc)) from exc
|
|
40
|
+
return AnthropicProvider(model=model)
|
|
41
|
+
|
|
42
|
+
if provider == "openai":
|
|
43
|
+
from mcplint.benchmark.providers.openai_provider import OpenAIProvider
|
|
44
|
+
|
|
45
|
+
if model is None:
|
|
46
|
+
raise ProviderNotAvailableError("--model is required for --provider openai.")
|
|
47
|
+
return OpenAIProvider(model=model)
|
|
48
|
+
|
|
49
|
+
raise ProviderNotAvailableError(
|
|
50
|
+
f"Unknown provider '{provider}'. Supported: {', '.join(SUPPORTED_PROVIDERS)}"
|
|
51
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""A deterministic, no-network provider used by scorer tests and offline demos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from mcplint.benchmark.providers.base import ProviderResult
|
|
8
|
+
from mcplint.models.contracts import ToolContract
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FakeProvider:
|
|
12
|
+
"""Wraps a plain Python function so tests never need real API calls."""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
model: str,
|
|
17
|
+
responder: Callable[[str, list[ToolContract]], ProviderResult],
|
|
18
|
+
*,
|
|
19
|
+
name: str = "fake",
|
|
20
|
+
) -> None:
|
|
21
|
+
self.name = name
|
|
22
|
+
self.model = model
|
|
23
|
+
self._responder = responder
|
|
24
|
+
|
|
25
|
+
async def run(self, prompt: str, tools: list[ToolContract]) -> ProviderResult:
|
|
26
|
+
return self._responder(prompt, tools)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""OpenAI ToolCallingProvider adapter.
|
|
2
|
+
|
|
3
|
+
TODO(Phase 5 follow-up): implement against the OpenAI Responses/Chat
|
|
4
|
+
Completions tool-calling API, mirroring AnthropicProvider. Structured as a
|
|
5
|
+
separate adapter per spec so it does not block the Anthropic implementation.
|
|
6
|
+
Requires the `openai` extra: `pip install "mcplint[openai]"`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from mcplint.benchmark.providers.base import ProviderResult
|
|
12
|
+
from mcplint.models.contracts import ToolContract
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OpenAIProvider:
|
|
16
|
+
def __init__(self, model: str, *, api_key: str | None = None) -> None:
|
|
17
|
+
self.name = "openai"
|
|
18
|
+
self.model = model
|
|
19
|
+
self.api_key = api_key
|
|
20
|
+
|
|
21
|
+
async def run(self, prompt: str, tools: list[ToolContract]) -> ProviderResult:
|
|
22
|
+
raise NotImplementedError(
|
|
23
|
+
"OpenAI provider is not implemented yet. Use --provider anthropic or "
|
|
24
|
+
"--provider fake. Tracked in IMPLEMENTATION_STATUS.md."
|
|
25
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Runs a benchmark dataset against a provider and a fixed set of tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcplint.benchmark.providers.base import ToolCallingProvider
|
|
6
|
+
from mcplint.benchmark.scorer import (
|
|
7
|
+
BENCHMARK_RESULT_SCHEMA_VERSION,
|
|
8
|
+
aggregate_metrics,
|
|
9
|
+
make_trial,
|
|
10
|
+
to_actual_tool_call,
|
|
11
|
+
)
|
|
12
|
+
from mcplint.models.benchmark import BenchmarkDataset, BenchmarkResult, BenchmarkTrial
|
|
13
|
+
from mcplint.models.common import ArtifactMetadata
|
|
14
|
+
from mcplint.models.contracts import ToolContract
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def run_benchmark(
|
|
18
|
+
dataset: BenchmarkDataset,
|
|
19
|
+
tools: list[ToolContract],
|
|
20
|
+
provider: ToolCallingProvider,
|
|
21
|
+
*,
|
|
22
|
+
runs: int = 3,
|
|
23
|
+
) -> BenchmarkResult:
|
|
24
|
+
trials: list[BenchmarkTrial] = []
|
|
25
|
+
expected_by_case = {case.id: case.expected for case in dataset.cases}
|
|
26
|
+
|
|
27
|
+
for case in dataset.cases:
|
|
28
|
+
for trial_index in range(runs):
|
|
29
|
+
result = await provider.run(case.prompt, tools)
|
|
30
|
+
actual = to_actual_tool_call(result, tools)
|
|
31
|
+
trials.append(make_trial(case.id, trial_index, case.expected, actual))
|
|
32
|
+
|
|
33
|
+
metrics = aggregate_metrics(trials, expected_by_case)
|
|
34
|
+
|
|
35
|
+
return BenchmarkResult(
|
|
36
|
+
metadata=ArtifactMetadata.create(schema_version=BENCHMARK_RESULT_SCHEMA_VERSION),
|
|
37
|
+
dataset_name=dataset.name,
|
|
38
|
+
provider=provider.name,
|
|
39
|
+
model=provider.model,
|
|
40
|
+
runs_per_case=runs,
|
|
41
|
+
trials=trials,
|
|
42
|
+
**metrics,
|
|
43
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Deterministic scoring of benchmark trials. No LLM judge — every metric here
|
|
2
|
+
is computed from exact tool-name/argument comparison and JSON Schema validation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import jsonschema
|
|
8
|
+
|
|
9
|
+
from mcplint.benchmark.providers.base import ProviderResult
|
|
10
|
+
from mcplint.models.benchmark import ActualToolCall, BenchmarkTrial, ExpectedToolCall
|
|
11
|
+
from mcplint.models.contracts import ToolContract
|
|
12
|
+
|
|
13
|
+
BENCHMARK_RESULT_SCHEMA_VERSION = "1.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_arguments(
|
|
17
|
+
tool_name: str | None, arguments: dict[str, object], tools: list[ToolContract]
|
|
18
|
+
) -> bool:
|
|
19
|
+
if tool_name is None:
|
|
20
|
+
return False
|
|
21
|
+
tool = next((t for t in tools if t.name == tool_name), None)
|
|
22
|
+
if tool is None:
|
|
23
|
+
return False
|
|
24
|
+
try:
|
|
25
|
+
jsonschema.validate(instance=arguments, schema=tool.input_schema)
|
|
26
|
+
except jsonschema.ValidationError:
|
|
27
|
+
return False
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_actual_tool_call(result: ProviderResult, tools: list[ToolContract]) -> ActualToolCall:
|
|
32
|
+
return ActualToolCall(
|
|
33
|
+
tool=result.tool,
|
|
34
|
+
arguments=result.arguments,
|
|
35
|
+
valid_arguments=validate_arguments(result.tool, result.arguments, tools),
|
|
36
|
+
error=result.error,
|
|
37
|
+
latency_ms=result.latency_ms,
|
|
38
|
+
input_tokens=result.input_tokens,
|
|
39
|
+
output_tokens=result.output_tokens,
|
|
40
|
+
estimated_cost=result.estimated_cost,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def score_trial(expected: ExpectedToolCall, actual: ActualToolCall) -> tuple[bool, list[str]]:
|
|
45
|
+
reasons: list[str] = []
|
|
46
|
+
|
|
47
|
+
if actual.tool is None:
|
|
48
|
+
reasons.append("no tool was selected")
|
|
49
|
+
elif actual.tool != expected.tool:
|
|
50
|
+
reasons.append(f"expected tool '{expected.tool}', got '{actual.tool}'")
|
|
51
|
+
|
|
52
|
+
if actual.tool is not None and actual.tool in expected.forbidden_tools:
|
|
53
|
+
reasons.append(f"invoked forbidden tool '{actual.tool}'")
|
|
54
|
+
|
|
55
|
+
if not actual.valid_arguments:
|
|
56
|
+
reasons.append("arguments do not validate against the tool's input schema")
|
|
57
|
+
|
|
58
|
+
for key, expected_value in expected.all_expected_arguments().items():
|
|
59
|
+
actual_value = actual.arguments.get(key)
|
|
60
|
+
if actual_value != expected_value:
|
|
61
|
+
reasons.append(f"argument '{key}': expected {expected_value!r}, got {actual_value!r}")
|
|
62
|
+
|
|
63
|
+
return (not reasons, reasons)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def make_trial(
|
|
67
|
+
case_id: str, trial_index: int, expected: ExpectedToolCall, actual: ActualToolCall
|
|
68
|
+
) -> BenchmarkTrial:
|
|
69
|
+
passed, reasons = score_trial(expected, actual)
|
|
70
|
+
return BenchmarkTrial(
|
|
71
|
+
case_id=case_id,
|
|
72
|
+
trial_index=trial_index,
|
|
73
|
+
actual=actual,
|
|
74
|
+
passed=passed,
|
|
75
|
+
failure_reasons=reasons,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _percentile(values: list[float], percentile: float) -> float:
|
|
80
|
+
if not values:
|
|
81
|
+
return 0.0
|
|
82
|
+
ordered = sorted(values)
|
|
83
|
+
index = min(len(ordered) - 1, int(round(percentile * (len(ordered) - 1))))
|
|
84
|
+
return ordered[index]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def aggregate_metrics(
|
|
88
|
+
trials: list[BenchmarkTrial],
|
|
89
|
+
expected_by_case: dict[str, ExpectedToolCall],
|
|
90
|
+
) -> dict[str, object]:
|
|
91
|
+
total = len(trials)
|
|
92
|
+
exact_matches = sum(1 for t in trials if t.actual.tool == expected_by_case[t.case_id].tool)
|
|
93
|
+
valid_args = sum(1 for t in trials if t.actual.valid_arguments)
|
|
94
|
+
forbidden = sum(
|
|
95
|
+
1 for t in trials if t.actual.tool in expected_by_case[t.case_id].forbidden_tools
|
|
96
|
+
)
|
|
97
|
+
no_tool = sum(1 for t in trials if t.actual.tool is None)
|
|
98
|
+
|
|
99
|
+
required_correct = 0
|
|
100
|
+
required_total = 0
|
|
101
|
+
for trial in trials:
|
|
102
|
+
expected = expected_by_case[trial.case_id]
|
|
103
|
+
if trial.actual.tool != expected.tool:
|
|
104
|
+
continue
|
|
105
|
+
required_total += 1
|
|
106
|
+
expected_args = expected.all_expected_arguments()
|
|
107
|
+
if all(trial.actual.arguments.get(k) == v for k, v in expected_args.items()):
|
|
108
|
+
required_correct += 1
|
|
109
|
+
|
|
110
|
+
latencies = [t.actual.latency_ms for t in trials]
|
|
111
|
+
costs = [t.actual.estimated_cost for t in trials if t.actual.estimated_cost is not None]
|
|
112
|
+
|
|
113
|
+
per_case_pass_rate: dict[str, float] = {}
|
|
114
|
+
stability: dict[str, float] = {}
|
|
115
|
+
by_case: dict[str, list[BenchmarkTrial]] = {}
|
|
116
|
+
for trial in trials:
|
|
117
|
+
by_case.setdefault(trial.case_id, []).append(trial)
|
|
118
|
+
for case_id, case_trials in by_case.items():
|
|
119
|
+
passed_count = sum(1 for t in case_trials if t.passed)
|
|
120
|
+
per_case_pass_rate[case_id] = passed_count / len(case_trials)
|
|
121
|
+
majority = max(passed_count, len(case_trials) - passed_count)
|
|
122
|
+
stability[case_id] = majority / len(case_trials)
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"exact_tool_selection_accuracy": exact_matches / total if total else 0.0,
|
|
126
|
+
"valid_argument_rate": valid_args / total if total else 0.0,
|
|
127
|
+
"required_argument_accuracy": (
|
|
128
|
+
required_correct / required_total if required_total else 0.0
|
|
129
|
+
),
|
|
130
|
+
"forbidden_tool_invocation_rate": forbidden / total if total else 0.0,
|
|
131
|
+
"no_tool_rate": no_tool / total if total else 0.0,
|
|
132
|
+
"mean_latency_ms": sum(latencies) / total if total else 0.0,
|
|
133
|
+
"p95_latency_ms": _percentile(latencies, 0.95),
|
|
134
|
+
"total_estimated_cost": sum(costs) if costs else None,
|
|
135
|
+
"per_case_pass_rate": per_case_pass_rate,
|
|
136
|
+
"stability": stability,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = [
|
|
141
|
+
"BENCHMARK_RESULT_SCHEMA_VERSION",
|
|
142
|
+
"aggregate_metrics",
|
|
143
|
+
"make_trial",
|
|
144
|
+
"score_trial",
|
|
145
|
+
"to_actual_tool_call",
|
|
146
|
+
"validate_arguments",
|
|
147
|
+
]
|
mcplint/cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""`mcplint benchmark` — run a benchmark dataset against a live MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from mcplint.benchmark.dataset import DatasetError, load_dataset
|
|
13
|
+
from mcplint.benchmark.providers.factory import ProviderNotAvailableError, create_provider
|
|
14
|
+
from mcplint.benchmark.runner import run_benchmark
|
|
15
|
+
from mcplint.mcp_client.persistence import load_snapshot
|
|
16
|
+
from mcplint.mcp_client.session import collect_stdio_snapshot
|
|
17
|
+
from mcplint.mcp_client.stdio import parse_command
|
|
18
|
+
from mcplint.models.benchmark import BenchmarkResult
|
|
19
|
+
from mcplint.reporters.benchmark_terminal import render_benchmark_terminal
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
error_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def benchmark_command(
|
|
26
|
+
dataset_path: Annotated[Path, typer.Argument(help="Path to the benchmark dataset YAML.")],
|
|
27
|
+
server: Annotated[
|
|
28
|
+
str | None, typer.Option("--server", help="Command line to launch the MCP server.")
|
|
29
|
+
] = None,
|
|
30
|
+
snapshot: Annotated[
|
|
31
|
+
Path | None, typer.Option("--snapshot", help="Path to a saved snapshot JSON file.")
|
|
32
|
+
] = None,
|
|
33
|
+
provider: Annotated[
|
|
34
|
+
str, typer.Option("--provider", help="Benchmark provider: fake, anthropic, or openai.")
|
|
35
|
+
] = "fake",
|
|
36
|
+
model: Annotated[
|
|
37
|
+
str | None, typer.Option("--model", help="Model name for the provider.")
|
|
38
|
+
] = None,
|
|
39
|
+
runs: Annotated[int, typer.Option("--runs", help="Trials per case.")] = 3,
|
|
40
|
+
format: Annotated[str, typer.Option("--format", help="Output format.")] = "terminal",
|
|
41
|
+
output: Annotated[
|
|
42
|
+
Path | None, typer.Option("--output", help="Path to write the BenchmarkResult JSON to.")
|
|
43
|
+
] = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
if (server is None) == (snapshot is None):
|
|
46
|
+
error_console.print(
|
|
47
|
+
"[bold red]Exactly one of --server or --snapshot is required.[/bold red]"
|
|
48
|
+
)
|
|
49
|
+
raise typer.Exit(code=2)
|
|
50
|
+
if format not in ("terminal", "json"):
|
|
51
|
+
error_console.print(f"[bold red]Unknown format: {format}[/bold red]")
|
|
52
|
+
raise typer.Exit(code=2)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
dataset = load_dataset(dataset_path)
|
|
56
|
+
except DatasetError as exc:
|
|
57
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
58
|
+
raise typer.Exit(code=2) from exc
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
chosen_provider = create_provider(provider, model)
|
|
62
|
+
except ProviderNotAvailableError as exc:
|
|
63
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
64
|
+
raise typer.Exit(code=2) from exc
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
if snapshot is not None:
|
|
68
|
+
server_snapshot = load_snapshot(snapshot)
|
|
69
|
+
else:
|
|
70
|
+
assert server is not None
|
|
71
|
+
command, args = parse_command(server)
|
|
72
|
+
server_snapshot = anyio.run(collect_stdio_snapshot, command, args)
|
|
73
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a CI-friendly CLI error
|
|
74
|
+
error_console.print(f"[bold red]Failed to load server contract:[/bold red] {exc}")
|
|
75
|
+
raise typer.Exit(code=1) from exc
|
|
76
|
+
|
|
77
|
+
async def _run() -> BenchmarkResult:
|
|
78
|
+
return await run_benchmark(dataset, server_snapshot.tools, chosen_provider, runs=runs)
|
|
79
|
+
|
|
80
|
+
result = anyio.run(_run)
|
|
81
|
+
|
|
82
|
+
if format == "json":
|
|
83
|
+
console.print(result.model_dump_json(indent=2))
|
|
84
|
+
else:
|
|
85
|
+
console.print(render_benchmark_terminal(result))
|
|
86
|
+
|
|
87
|
+
if output is not None:
|
|
88
|
+
output.write_text(result.model_dump_json(indent=2) + "\n", encoding="utf-8")
|