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.
Files changed (71) hide show
  1. mcplint/__about__.py +1 -0
  2. mcplint/__init__.py +3 -0
  3. mcplint/benchmark/__init__.py +0 -0
  4. mcplint/benchmark/dataset.py +34 -0
  5. mcplint/benchmark/providers/__init__.py +0 -0
  6. mcplint/benchmark/providers/anthropic_provider.py +92 -0
  7. mcplint/benchmark/providers/base.py +26 -0
  8. mcplint/benchmark/providers/factory.py +51 -0
  9. mcplint/benchmark/providers/fake.py +26 -0
  10. mcplint/benchmark/providers/openai_provider.py +25 -0
  11. mcplint/benchmark/runner.py +43 -0
  12. mcplint/benchmark/scorer.py +147 -0
  13. mcplint/cli/__init__.py +0 -0
  14. mcplint/cli/commands/__init__.py +0 -0
  15. mcplint/cli/commands/benchmark_cmd.py +88 -0
  16. mcplint/cli/commands/compare_cmd.py +139 -0
  17. mcplint/cli/commands/fix_cmd.py +60 -0
  18. mcplint/cli/commands/inspect_cmd.py +44 -0
  19. mcplint/cli/commands/rules_cmd.py +30 -0
  20. mcplint/cli/commands/scan_cmd.py +113 -0
  21. mcplint/cli/commands/snapshot_cmd.py +33 -0
  22. mcplint/cli/main.py +49 -0
  23. mcplint/compare/__init__.py +0 -0
  24. mcplint/compare/differ.py +148 -0
  25. mcplint/config/__init__.py +0 -0
  26. mcplint/config/loader.py +36 -0
  27. mcplint/config/schema.py +40 -0
  28. mcplint/core/__init__.py +0 -0
  29. mcplint/core/engine.py +40 -0
  30. mcplint/core/registry.py +52 -0
  31. mcplint/core/rules/__init__.py +0 -0
  32. mcplint/core/rules/ambiguity.py +157 -0
  33. mcplint/core/rules/ambiguity_rules.py +109 -0
  34. mcplint/core/rules/base.py +39 -0
  35. mcplint/core/rules/builtin.py +45 -0
  36. mcplint/core/rules/completeness_rules.py +217 -0
  37. mcplint/core/rules/description_rules.py +110 -0
  38. mcplint/core/rules/safety_rules.py +139 -0
  39. mcplint/core/rules/schema_rules.py +101 -0
  40. mcplint/core/score.py +132 -0
  41. mcplint/fix/__init__.py +0 -0
  42. mcplint/fix/suggest.py +183 -0
  43. mcplint/mcp_client/__init__.py +0 -0
  44. mcplint/mcp_client/canonical.py +25 -0
  45. mcplint/mcp_client/persistence.py +17 -0
  46. mcplint/mcp_client/session.py +80 -0
  47. mcplint/mcp_client/stdio.py +17 -0
  48. mcplint/models/__init__.py +0 -0
  49. mcplint/models/benchmark.py +67 -0
  50. mcplint/models/common.py +23 -0
  51. mcplint/models/comparison.py +53 -0
  52. mcplint/models/contracts.py +39 -0
  53. mcplint/models/findings.py +44 -0
  54. mcplint/models/fixes.py +13 -0
  55. mcplint/models/score.py +25 -0
  56. mcplint/models/snapshot.py +27 -0
  57. mcplint/py.typed +0 -0
  58. mcplint/reporters/__init__.py +0 -0
  59. mcplint/reporters/benchmark_terminal.py +46 -0
  60. mcplint/reporters/comparison_terminal.py +61 -0
  61. mcplint/reporters/fix_markdown.py +34 -0
  62. mcplint/reporters/html.py +71 -0
  63. mcplint/reporters/json_reporter.py +9 -0
  64. mcplint/reporters/sarif.py +77 -0
  65. mcplint/reporters/templates/report.html.j2 +176 -0
  66. mcplint/reporters/terminal.py +52 -0
  67. mcplint_cli-0.1.0.dist-info/METADATA +392 -0
  68. mcplint_cli-0.1.0.dist-info/RECORD +71 -0
  69. mcplint_cli-0.1.0.dist-info/WHEEL +4 -0
  70. mcplint_cli-0.1.0.dist-info/entry_points.txt +2 -0
  71. 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
@@ -0,0 +1,3 @@
1
+ from mcplint.__about__ import __version__
2
+
3
+ __all__ = ["__version__"]
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
+ ]
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")