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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""`mcplint compare` — diff two snapshots and optionally re-run a benchmark against both."""
|
|
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.compare.differ import (
|
|
16
|
+
diff_ambiguity,
|
|
17
|
+
diff_benchmarks,
|
|
18
|
+
diff_findings,
|
|
19
|
+
diff_tool_contracts,
|
|
20
|
+
diff_tool_names,
|
|
21
|
+
)
|
|
22
|
+
from mcplint.core.engine import lint_snapshot
|
|
23
|
+
from mcplint.core.registry import RuleRegistry
|
|
24
|
+
from mcplint.mcp_client.persistence import load_snapshot
|
|
25
|
+
from mcplint.models.benchmark import BenchmarkResult
|
|
26
|
+
from mcplint.models.common import ArtifactMetadata
|
|
27
|
+
from mcplint.models.comparison import ComparisonReport
|
|
28
|
+
from mcplint.reporters.comparison_terminal import render_comparison_terminal
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
error_console = Console(stderr=True)
|
|
32
|
+
|
|
33
|
+
COMPARISON_SCHEMA_VERSION = "1.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compare_command(
|
|
37
|
+
baseline: Annotated[Path, typer.Option("--baseline", help="Baseline snapshot JSON path.")],
|
|
38
|
+
candidate: Annotated[Path, typer.Option("--candidate", help="Candidate snapshot JSON path.")],
|
|
39
|
+
dataset_path: Annotated[
|
|
40
|
+
Path | None,
|
|
41
|
+
typer.Option("--dataset", help="Benchmark dataset YAML to re-run against both."),
|
|
42
|
+
] = None,
|
|
43
|
+
provider: Annotated[str, typer.Option("--provider", help="Benchmark provider.")] = "fake",
|
|
44
|
+
model: Annotated[
|
|
45
|
+
str | None, typer.Option("--model", help="Model name for the provider.")
|
|
46
|
+
] = None,
|
|
47
|
+
runs: Annotated[int, typer.Option("--runs", help="Trials per case.")] = 3,
|
|
48
|
+
min_accuracy_delta: Annotated[
|
|
49
|
+
float | None,
|
|
50
|
+
typer.Option("--min-accuracy-delta", help="Fail if accuracy delta falls below this."),
|
|
51
|
+
] = None,
|
|
52
|
+
format: Annotated[str, typer.Option("--format", help="Output format.")] = "terminal",
|
|
53
|
+
output: Annotated[
|
|
54
|
+
Path | None, typer.Option("--output", help="Path to write the ComparisonReport JSON to.")
|
|
55
|
+
] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
if format not in ("terminal", "json"):
|
|
58
|
+
error_console.print(f"[bold red]Unknown format: {format}[/bold red]")
|
|
59
|
+
raise typer.Exit(code=2)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
baseline_snapshot = load_snapshot(baseline)
|
|
63
|
+
candidate_snapshot = load_snapshot(candidate)
|
|
64
|
+
except FileNotFoundError as exc:
|
|
65
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
66
|
+
raise typer.Exit(code=2) from exc
|
|
67
|
+
|
|
68
|
+
registry = RuleRegistry.with_builtin_rules()
|
|
69
|
+
baseline_report = lint_snapshot(baseline_snapshot, registry)
|
|
70
|
+
candidate_report = lint_snapshot(candidate_snapshot, registry)
|
|
71
|
+
|
|
72
|
+
added_tools, removed_tools = diff_tool_names(baseline_snapshot, candidate_snapshot)
|
|
73
|
+
schema_changes, description_changes = diff_tool_contracts(baseline_snapshot, candidate_snapshot)
|
|
74
|
+
new_findings, resolved_findings = diff_findings(baseline_report, candidate_report)
|
|
75
|
+
ambiguity_changes = diff_ambiguity(baseline_snapshot, candidate_snapshot)
|
|
76
|
+
|
|
77
|
+
report = ComparisonReport(
|
|
78
|
+
metadata=ArtifactMetadata.create(schema_version=COMPARISON_SCHEMA_VERSION),
|
|
79
|
+
baseline_server_name=baseline_snapshot.server_name,
|
|
80
|
+
candidate_server_name=candidate_snapshot.server_name,
|
|
81
|
+
added_tools=added_tools,
|
|
82
|
+
removed_tools=removed_tools,
|
|
83
|
+
schema_changes=schema_changes,
|
|
84
|
+
description_changes=description_changes,
|
|
85
|
+
new_findings=new_findings,
|
|
86
|
+
resolved_findings=resolved_findings,
|
|
87
|
+
ambiguity_score_changes=ambiguity_changes,
|
|
88
|
+
min_accuracy_delta_threshold=min_accuracy_delta,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if dataset_path is not None:
|
|
92
|
+
try:
|
|
93
|
+
dataset = load_dataset(dataset_path)
|
|
94
|
+
except DatasetError as exc:
|
|
95
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
96
|
+
raise typer.Exit(code=2) from exc
|
|
97
|
+
try:
|
|
98
|
+
chosen_provider = create_provider(provider, model)
|
|
99
|
+
except ProviderNotAvailableError as exc:
|
|
100
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
101
|
+
raise typer.Exit(code=2) from exc
|
|
102
|
+
|
|
103
|
+
async def _run_both() -> tuple[BenchmarkResult, BenchmarkResult]:
|
|
104
|
+
baseline_result = await run_benchmark(
|
|
105
|
+
dataset, baseline_snapshot.tools, chosen_provider, runs=runs
|
|
106
|
+
)
|
|
107
|
+
candidate_result = await run_benchmark(
|
|
108
|
+
dataset, candidate_snapshot.tools, chosen_provider, runs=runs
|
|
109
|
+
)
|
|
110
|
+
return baseline_result, candidate_result
|
|
111
|
+
|
|
112
|
+
baseline_result, candidate_result = anyio.run(_run_both)
|
|
113
|
+
deltas = diff_benchmarks(baseline_result, candidate_result)
|
|
114
|
+
|
|
115
|
+
report = report.model_copy(
|
|
116
|
+
update={
|
|
117
|
+
"benchmark_dataset_name": dataset.name,
|
|
118
|
+
"baseline_accuracy": baseline_result.exact_tool_selection_accuracy,
|
|
119
|
+
"candidate_accuracy": candidate_result.exact_tool_selection_accuracy,
|
|
120
|
+
**deltas,
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
if min_accuracy_delta is not None:
|
|
124
|
+
accuracy_delta = deltas["benchmark_accuracy_delta"]
|
|
125
|
+
assert isinstance(accuracy_delta, float)
|
|
126
|
+
report = report.model_copy(
|
|
127
|
+
update={"passes_ci_threshold": accuracy_delta >= min_accuracy_delta}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if format == "json":
|
|
131
|
+
console.print(report.model_dump_json(indent=2))
|
|
132
|
+
else:
|
|
133
|
+
console.print(render_comparison_terminal(report))
|
|
134
|
+
|
|
135
|
+
if output is not None:
|
|
136
|
+
output.write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
if report.passes_ci_threshold is False:
|
|
139
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`mcplint fix` — propose deterministic rewrite suggestions as a Markdown patch report.
|
|
2
|
+
|
|
3
|
+
Never writes to the MCP server's source files. Only ever writes the patch
|
|
4
|
+
report itself, and only when --output is given.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
from mcplint.core.engine import lint_snapshot
|
|
16
|
+
from mcplint.core.registry import RuleRegistry
|
|
17
|
+
from mcplint.fix.suggest import build_suggestions
|
|
18
|
+
from mcplint.mcp_client.persistence import load_snapshot
|
|
19
|
+
from mcplint.reporters.fix_markdown import render_fix_markdown
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
error_console = Console(stderr=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fix_command(
|
|
26
|
+
snapshot: Annotated[
|
|
27
|
+
Path, typer.Option("--snapshot", help="Path to a saved snapshot JSON file.")
|
|
28
|
+
],
|
|
29
|
+
llm_provider: Annotated[
|
|
30
|
+
str | None,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--llm-provider",
|
|
33
|
+
help="Enable LLM-assisted rewriting via this provider (not yet available).",
|
|
34
|
+
),
|
|
35
|
+
] = None,
|
|
36
|
+
output: Annotated[
|
|
37
|
+
Path | None, typer.Option("--output", help="Path to write the Markdown patch report to.")
|
|
38
|
+
] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
if llm_provider is not None:
|
|
41
|
+
error_console.print(
|
|
42
|
+
"[bold red]LLM-assisted rewriting is not implemented yet.[/bold red] "
|
|
43
|
+
"Omit --llm-provider to use deterministic suggestions only."
|
|
44
|
+
)
|
|
45
|
+
raise typer.Exit(code=2)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
server_snapshot = load_snapshot(snapshot)
|
|
49
|
+
except FileNotFoundError as exc:
|
|
50
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
51
|
+
raise typer.Exit(code=2) from exc
|
|
52
|
+
|
|
53
|
+
report = lint_snapshot(server_snapshot, RuleRegistry.with_builtin_rules())
|
|
54
|
+
suggestions = build_suggestions(server_snapshot, report)
|
|
55
|
+
markdown = render_fix_markdown(server_snapshot.server_name, suggestions)
|
|
56
|
+
|
|
57
|
+
console.print(markdown)
|
|
58
|
+
|
|
59
|
+
if output is not None:
|
|
60
|
+
output.write_text(markdown, encoding="utf-8")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""`mcplint inspect` — connect to an MCP server and print its tool contracts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from mcplint.mcp_client.session import collect_stdio_snapshot
|
|
13
|
+
from mcplint.mcp_client.stdio import parse_command
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
error_console = Console(stderr=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def inspect_command(
|
|
20
|
+
server: Annotated[str, typer.Option("--server", help="Command line to launch the MCP server.")],
|
|
21
|
+
) -> None:
|
|
22
|
+
command, args = parse_command(server)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
snapshot = anyio.run(collect_stdio_snapshot, command, args)
|
|
26
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a CI-friendly CLI error
|
|
27
|
+
error_console.print(f"[bold red]Failed to inspect server:[/bold red] {exc}")
|
|
28
|
+
raise typer.Exit(code=1) from exc
|
|
29
|
+
|
|
30
|
+
table = Table(title=f"Tools on {snapshot.server_name}")
|
|
31
|
+
table.add_column("Name")
|
|
32
|
+
table.add_column("Description")
|
|
33
|
+
table.add_column("Params", justify="right")
|
|
34
|
+
table.add_column("Destructive", justify="center")
|
|
35
|
+
|
|
36
|
+
for tool in snapshot.tools:
|
|
37
|
+
table.add_row(
|
|
38
|
+
tool.name,
|
|
39
|
+
tool.description or "[dim]—[/dim]",
|
|
40
|
+
str(len(tool.parameters)),
|
|
41
|
+
"yes" if tool.annotations.destructive_hint else "",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
console.print(table)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""`mcplint rules` — list every built-in rule."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from mcplint.core.registry import RuleRegistry
|
|
9
|
+
|
|
10
|
+
console = Console(width=160)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rules_command() -> None:
|
|
14
|
+
registry = RuleRegistry.with_builtin_rules()
|
|
15
|
+
table = Table(title="MCPLint rule catalogue")
|
|
16
|
+
table.add_column("ID")
|
|
17
|
+
table.add_column("Title")
|
|
18
|
+
table.add_column("Default severity")
|
|
19
|
+
table.add_column("Tags")
|
|
20
|
+
|
|
21
|
+
for rule in registry.all():
|
|
22
|
+
metadata = rule.metadata()
|
|
23
|
+
table.add_row(
|
|
24
|
+
metadata.id,
|
|
25
|
+
metadata.title,
|
|
26
|
+
metadata.default_severity.value,
|
|
27
|
+
", ".join(metadata.tags),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
console.print(table)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`mcplint scan` — lint a live MCP server or a saved snapshot."""
|
|
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.config.loader import ConfigError, load_config
|
|
13
|
+
from mcplint.core.engine import lint_snapshot
|
|
14
|
+
from mcplint.core.registry import RuleRegistry
|
|
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.findings import LintReport, Severity
|
|
19
|
+
from mcplint.reporters.html import render_html_report
|
|
20
|
+
from mcplint.reporters.json_reporter import render_json
|
|
21
|
+
from mcplint.reporters.sarif import render_sarif
|
|
22
|
+
from mcplint.reporters.terminal import render_terminal
|
|
23
|
+
|
|
24
|
+
_FORMATS = ("terminal", "json", "sarif", "html")
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
error_console = Console(stderr=True)
|
|
28
|
+
|
|
29
|
+
_SEVERITY_RANK = {Severity.INFO: 0, Severity.WARNING: 1, Severity.ERROR: 2}
|
|
30
|
+
_FAIL_ON_THRESHOLD = {
|
|
31
|
+
"error": _SEVERITY_RANK[Severity.ERROR],
|
|
32
|
+
"warning": _SEVERITY_RANK[Severity.WARNING],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _should_fail(report: LintReport, fail_on: str) -> bool:
|
|
37
|
+
if fail_on == "never":
|
|
38
|
+
return False
|
|
39
|
+
threshold = _FAIL_ON_THRESHOLD[fail_on]
|
|
40
|
+
return any(_SEVERITY_RANK[finding.severity] >= threshold for finding in report.findings)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def scan_command(
|
|
44
|
+
server: Annotated[
|
|
45
|
+
str | None, typer.Option("--server", help="Command line to launch the MCP server.")
|
|
46
|
+
] = None,
|
|
47
|
+
snapshot: Annotated[
|
|
48
|
+
Path | None, typer.Option("--snapshot", help="Path to a saved snapshot JSON file.")
|
|
49
|
+
] = None,
|
|
50
|
+
format: Annotated[str, typer.Option("--format", help="Output format.")] = "terminal",
|
|
51
|
+
fail_on: Annotated[
|
|
52
|
+
str, typer.Option("--fail-on", help="Minimum severity that fails the command.")
|
|
53
|
+
] = "error",
|
|
54
|
+
config: Annotated[Path, typer.Option("--config", help="Path to mcplint.yaml.")] = Path(
|
|
55
|
+
"mcplint.yaml"
|
|
56
|
+
),
|
|
57
|
+
output: Annotated[
|
|
58
|
+
Path | None, typer.Option("--output", help="Path to also write the report to.")
|
|
59
|
+
] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
if (server is None) == (snapshot is None):
|
|
62
|
+
error_console.print(
|
|
63
|
+
"[bold red]Exactly one of --server or --snapshot is required.[/bold red]"
|
|
64
|
+
)
|
|
65
|
+
raise typer.Exit(code=2)
|
|
66
|
+
if format not in _FORMATS:
|
|
67
|
+
error_console.print(
|
|
68
|
+
f"[bold red]Unknown format: {format}. Supported: {', '.join(_FORMATS)}[/bold red]"
|
|
69
|
+
)
|
|
70
|
+
raise typer.Exit(code=2)
|
|
71
|
+
if fail_on not in ("error", "warning", "never"):
|
|
72
|
+
error_console.print(f"[bold red]Unknown --fail-on: {fail_on}[/bold red]")
|
|
73
|
+
raise typer.Exit(code=2)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
mcplint_config = load_config(config)
|
|
77
|
+
except ConfigError as exc:
|
|
78
|
+
error_console.print(f"[bold red]{exc}[/bold red]")
|
|
79
|
+
raise typer.Exit(code=2) from exc
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
if snapshot is not None:
|
|
83
|
+
server_snapshot = load_snapshot(snapshot)
|
|
84
|
+
else:
|
|
85
|
+
assert server is not None
|
|
86
|
+
command, args = parse_command(server)
|
|
87
|
+
server_snapshot = anyio.run(collect_stdio_snapshot, command, args)
|
|
88
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a CI-friendly CLI error
|
|
89
|
+
error_console.print(f"[bold red]Failed to load server contract:[/bold red] {exc}")
|
|
90
|
+
raise typer.Exit(code=1) from exc
|
|
91
|
+
|
|
92
|
+
report = lint_snapshot(
|
|
93
|
+
server_snapshot, RuleRegistry.with_builtin_rules(mcplint_config), mcplint_config
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if format == "json":
|
|
97
|
+
rendered = render_json(report)
|
|
98
|
+
console.print(rendered)
|
|
99
|
+
elif format == "sarif":
|
|
100
|
+
rendered = render_sarif(report)
|
|
101
|
+
console.print(rendered)
|
|
102
|
+
elif format == "html":
|
|
103
|
+
rendered = render_html_report(server_snapshot, report)
|
|
104
|
+
console.print(f"[green]HTML report generated ({len(rendered)} bytes).[/green]")
|
|
105
|
+
else:
|
|
106
|
+
rendered = render_terminal(report)
|
|
107
|
+
console.print(rendered)
|
|
108
|
+
|
|
109
|
+
if output is not None:
|
|
110
|
+
output.write_text(rendered, encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
if _should_fail(report, fail_on):
|
|
113
|
+
raise typer.Exit(code=1)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""`mcplint snapshot` — connect to an MCP server and persist its contract as JSON."""
|
|
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.mcp_client.persistence import save_snapshot
|
|
13
|
+
from mcplint.mcp_client.session import collect_stdio_snapshot
|
|
14
|
+
from mcplint.mcp_client.stdio import parse_command
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
error_console = Console(stderr=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def snapshot_command(
|
|
21
|
+
server: Annotated[str, typer.Option("--server", help="Command line to launch the MCP server.")],
|
|
22
|
+
output: Annotated[Path, typer.Option("--output", help="Path to write the snapshot JSON to.")],
|
|
23
|
+
) -> None:
|
|
24
|
+
command, args = parse_command(server)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
snapshot = anyio.run(collect_stdio_snapshot, command, args)
|
|
28
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a CI-friendly CLI error
|
|
29
|
+
error_console.print(f"[bold red]Failed to snapshot server:[/bold red] {exc}")
|
|
30
|
+
raise typer.Exit(code=1) from exc
|
|
31
|
+
|
|
32
|
+
save_snapshot(snapshot, output)
|
|
33
|
+
console.print(f"[green]Wrote snapshot for {snapshot.server_name} to {output}[/green]")
|
mcplint/cli/main.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""MCPLint CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from mcplint.__about__ import __version__
|
|
10
|
+
from mcplint.cli.commands.benchmark_cmd import benchmark_command
|
|
11
|
+
from mcplint.cli.commands.compare_cmd import compare_command
|
|
12
|
+
from mcplint.cli.commands.fix_cmd import fix_command
|
|
13
|
+
from mcplint.cli.commands.inspect_cmd import inspect_command
|
|
14
|
+
from mcplint.cli.commands.rules_cmd import rules_command
|
|
15
|
+
from mcplint.cli.commands.scan_cmd import scan_command
|
|
16
|
+
from mcplint.cli.commands.snapshot_cmd import snapshot_command
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(name="mcplint", help="ESLint for MCP tool contracts.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_version(value: bool) -> None:
|
|
22
|
+
if value:
|
|
23
|
+
typer.echo(f"mcplint {__version__}")
|
|
24
|
+
raise typer.Exit
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.callback()
|
|
28
|
+
def _callback(
|
|
29
|
+
version: Annotated[
|
|
30
|
+
bool,
|
|
31
|
+
typer.Option(
|
|
32
|
+
"--version", callback=_print_version, is_eager=True, help="Print the version and exit."
|
|
33
|
+
),
|
|
34
|
+
] = False,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""ESLint for MCP tool contracts."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
app.command("inspect")(inspect_command)
|
|
40
|
+
app.command("snapshot")(snapshot_command)
|
|
41
|
+
app.command("scan")(scan_command)
|
|
42
|
+
app.command("rules")(rules_command)
|
|
43
|
+
app.command("benchmark")(benchmark_command)
|
|
44
|
+
app.command("compare")(compare_command)
|
|
45
|
+
app.command("fix")(fix_command)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Pure diff functions between two snapshots, lint reports, and benchmark results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcplint.core.rules.ambiguity import compute_ambiguity
|
|
6
|
+
from mcplint.models.benchmark import BenchmarkResult
|
|
7
|
+
from mcplint.models.comparison import AmbiguityScoreChange, DescriptionChange, SchemaChange
|
|
8
|
+
from mcplint.models.findings import Finding, LintReport
|
|
9
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
10
|
+
|
|
11
|
+
_FLOAT_EPSILON = 1e-9
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def diff_tool_names(
|
|
15
|
+
baseline: MCPServerSnapshot, candidate: MCPServerSnapshot
|
|
16
|
+
) -> tuple[list[str], list[str]]:
|
|
17
|
+
baseline_names = {tool.name for tool in baseline.tools}
|
|
18
|
+
candidate_names = {tool.name for tool in candidate.tools}
|
|
19
|
+
added = sorted(candidate_names - baseline_names)
|
|
20
|
+
removed = sorted(baseline_names - candidate_names)
|
|
21
|
+
return added, removed
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def diff_tool_contracts(
|
|
25
|
+
baseline: MCPServerSnapshot, candidate: MCPServerSnapshot
|
|
26
|
+
) -> tuple[list[SchemaChange], list[DescriptionChange]]:
|
|
27
|
+
baseline_names = {tool.name for tool in baseline.tools}
|
|
28
|
+
candidate_names = {tool.name for tool in candidate.tools}
|
|
29
|
+
common = sorted(baseline_names & candidate_names)
|
|
30
|
+
|
|
31
|
+
schema_changes: list[SchemaChange] = []
|
|
32
|
+
description_changes: list[DescriptionChange] = []
|
|
33
|
+
|
|
34
|
+
for name in common:
|
|
35
|
+
before_tool = baseline.get_tool(name)
|
|
36
|
+
after_tool = candidate.get_tool(name)
|
|
37
|
+
assert before_tool is not None and after_tool is not None
|
|
38
|
+
|
|
39
|
+
if before_tool.description != after_tool.description:
|
|
40
|
+
description_changes.append(
|
|
41
|
+
DescriptionChange(
|
|
42
|
+
tool_name=name, before=before_tool.description, after=after_tool.description
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
if before_tool.input_schema != after_tool.input_schema:
|
|
46
|
+
schema_changes.append(
|
|
47
|
+
SchemaChange(
|
|
48
|
+
tool_name=name,
|
|
49
|
+
json_path="$.inputSchema",
|
|
50
|
+
before=before_tool.input_schema,
|
|
51
|
+
after=after_tool.input_schema,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
if before_tool.output_schema != after_tool.output_schema:
|
|
55
|
+
schema_changes.append(
|
|
56
|
+
SchemaChange(
|
|
57
|
+
tool_name=name,
|
|
58
|
+
json_path="$.outputSchema",
|
|
59
|
+
before=before_tool.output_schema,
|
|
60
|
+
after=after_tool.output_schema,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return schema_changes, description_changes
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _finding_key(finding: Finding) -> tuple[str, str, str, str]:
|
|
68
|
+
return (
|
|
69
|
+
finding.rule_id,
|
|
70
|
+
finding.location.tool_name,
|
|
71
|
+
finding.location.json_path,
|
|
72
|
+
finding.message,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def diff_findings(
|
|
77
|
+
baseline_report: LintReport, candidate_report: LintReport
|
|
78
|
+
) -> tuple[list[Finding], list[Finding]]:
|
|
79
|
+
baseline_keys = {_finding_key(f) for f in baseline_report.findings}
|
|
80
|
+
candidate_keys = {_finding_key(f) for f in candidate_report.findings}
|
|
81
|
+
|
|
82
|
+
new_findings = [f for f in candidate_report.findings if _finding_key(f) not in baseline_keys]
|
|
83
|
+
resolved_findings = [
|
|
84
|
+
f for f in baseline_report.findings if _finding_key(f) not in candidate_keys
|
|
85
|
+
]
|
|
86
|
+
return new_findings, resolved_findings
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def diff_ambiguity(
|
|
90
|
+
baseline: MCPServerSnapshot, candidate: MCPServerSnapshot
|
|
91
|
+
) -> list[AmbiguityScoreChange]:
|
|
92
|
+
baseline_names = {tool.name for tool in baseline.tools}
|
|
93
|
+
candidate_names = {tool.name for tool in candidate.tools}
|
|
94
|
+
common = sorted(baseline_names & candidate_names)
|
|
95
|
+
|
|
96
|
+
changes: list[AmbiguityScoreChange] = []
|
|
97
|
+
for index, name_a in enumerate(common):
|
|
98
|
+
for name_b in common[index + 1 :]:
|
|
99
|
+
baseline_a, baseline_b = baseline.get_tool(name_a), baseline.get_tool(name_b)
|
|
100
|
+
candidate_a, candidate_b = candidate.get_tool(name_a), candidate.get_tool(name_b)
|
|
101
|
+
assert baseline_a is not None and baseline_b is not None
|
|
102
|
+
assert candidate_a is not None and candidate_b is not None
|
|
103
|
+
|
|
104
|
+
before_score = compute_ambiguity(baseline_a, baseline_b).score
|
|
105
|
+
after_score = compute_ambiguity(candidate_a, candidate_b).score
|
|
106
|
+
if abs(before_score - after_score) > _FLOAT_EPSILON:
|
|
107
|
+
changes.append(
|
|
108
|
+
AmbiguityScoreChange(
|
|
109
|
+
tool_a=name_a, tool_b=name_b, before=before_score, after=after_score
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
return changes
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def diff_benchmarks(
|
|
116
|
+
baseline_result: BenchmarkResult, candidate_result: BenchmarkResult
|
|
117
|
+
) -> dict[str, object]:
|
|
118
|
+
baseline_accuracy = baseline_result.exact_tool_selection_accuracy
|
|
119
|
+
candidate_accuracy = candidate_result.exact_tool_selection_accuracy
|
|
120
|
+
accuracy_delta = candidate_accuracy - baseline_accuracy
|
|
121
|
+
argument_validity_delta = (
|
|
122
|
+
candidate_result.valid_argument_rate - baseline_result.valid_argument_rate
|
|
123
|
+
)
|
|
124
|
+
latency_delta_ms = candidate_result.mean_latency_ms - baseline_result.mean_latency_ms
|
|
125
|
+
|
|
126
|
+
baseline_cost = baseline_result.total_estimated_cost
|
|
127
|
+
candidate_cost = candidate_result.total_estimated_cost
|
|
128
|
+
cost_delta: float | None = None
|
|
129
|
+
if baseline_cost is not None or candidate_cost is not None:
|
|
130
|
+
cost_delta = (candidate_cost or 0.0) - (baseline_cost or 0.0)
|
|
131
|
+
|
|
132
|
+
regressions_by_case: dict[str, str] = {}
|
|
133
|
+
baseline_cases = set(baseline_result.per_case_pass_rate)
|
|
134
|
+
candidate_cases = set(candidate_result.per_case_pass_rate)
|
|
135
|
+
common_cases = baseline_cases & candidate_cases
|
|
136
|
+
for case_id in sorted(common_cases):
|
|
137
|
+
before_rate = baseline_result.per_case_pass_rate[case_id]
|
|
138
|
+
after_rate = candidate_result.per_case_pass_rate[case_id]
|
|
139
|
+
if before_rate > after_rate:
|
|
140
|
+
regressions_by_case[case_id] = f"pass rate {before_rate:.0%} -> {after_rate:.0%}"
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"benchmark_accuracy_delta": accuracy_delta,
|
|
144
|
+
"argument_validity_delta": argument_validity_delta,
|
|
145
|
+
"latency_delta_ms": latency_delta_ms,
|
|
146
|
+
"cost_delta": cost_delta,
|
|
147
|
+
"regressions_by_case": regressions_by_case,
|
|
148
|
+
}
|
|
File without changes
|
mcplint/config/loader.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Load and validate mcplint.yaml."""
|
|
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.config.schema import MCPLintConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigError(Exception):
|
|
14
|
+
"""Raised when mcplint.yaml exists but fails validation."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config(path: Path | None) -> MCPLintConfig:
|
|
18
|
+
if path is None:
|
|
19
|
+
return MCPLintConfig()
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return MCPLintConfig()
|
|
22
|
+
|
|
23
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
24
|
+
if not isinstance(raw, dict):
|
|
25
|
+
raise ConfigError(
|
|
26
|
+
f"{path}: expected a YAML mapping at the top level, got {type(raw).__name__}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
return MCPLintConfig.model_validate(raw)
|
|
31
|
+
except ValidationError as exc:
|
|
32
|
+
messages = "\n".join(
|
|
33
|
+
f" - {'.'.join(str(loc) for loc in error['loc'])}: {error['msg']}"
|
|
34
|
+
for error in exc.errors()
|
|
35
|
+
)
|
|
36
|
+
raise ConfigError(f"{path}: invalid configuration:\n{messages}") from exc
|