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
@@ -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
@@ -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