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/core/score.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Computes the explainable 0-100 overall score documented in models/score.py.
|
|
2
|
+
|
|
3
|
+
Weighting (out of 100, each category capped independently, then summed and
|
|
4
|
+
clamped to [0, 100]):
|
|
5
|
+
|
|
6
|
+
- critical/error findings (not already counted below): up to 40 points,
|
|
7
|
+
8 points per finding.
|
|
8
|
+
- warning/info findings (not already counted below): up to 20 points,
|
|
9
|
+
2 points per finding.
|
|
10
|
+
- ambiguity (`ambiguous-tool-overlap`, `missing-tool-distinction`):
|
|
11
|
+
up to 15 points, 5 points per finding.
|
|
12
|
+
- schema completeness (`missing-parameter-description`,
|
|
13
|
+
`undocumented-required-constraint`, `schema-description-type-conflict`):
|
|
14
|
+
up to 15 points, 3 points per finding.
|
|
15
|
+
- safety clarity (`tool-name-action-conflict`,
|
|
16
|
+
`destructive-tool-without-warning`, `state-changing-tool-marked-read-only`):
|
|
17
|
+
up to 15 points, 5 points per finding.
|
|
18
|
+
- benchmark accuracy (only when a BenchmarkResult is supplied): up to 15
|
|
19
|
+
points, proportional to (1 - exact_tool_selection_accuracy).
|
|
20
|
+
|
|
21
|
+
These weights are a documented, adjustable heuristic — not a scientifically
|
|
22
|
+
derived formula. They exist so a regression in any one category is visible
|
|
23
|
+
without being able to silently zero out the score.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from mcplint.models.benchmark import BenchmarkResult
|
|
29
|
+
from mcplint.models.findings import Finding, LintReport, Severity
|
|
30
|
+
from mcplint.models.score import ScoreBreakdown, ScoreDeduction
|
|
31
|
+
|
|
32
|
+
AMBIGUITY_RULE_IDS = frozenset({"ambiguous-tool-overlap", "missing-tool-distinction"})
|
|
33
|
+
SCHEMA_RULE_IDS = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"missing-parameter-description",
|
|
36
|
+
"undocumented-required-constraint",
|
|
37
|
+
"schema-description-type-conflict",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
SAFETY_RULE_IDS = frozenset(
|
|
41
|
+
{
|
|
42
|
+
"tool-name-action-conflict",
|
|
43
|
+
"destructive-tool-without-warning",
|
|
44
|
+
"state-changing-tool-marked-read-only",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_ERROR_POINTS_PER_FINDING = 8.0
|
|
49
|
+
_ERROR_CATEGORY_CAP = 40.0
|
|
50
|
+
_WARNING_POINTS_PER_FINDING = 2.0
|
|
51
|
+
_WARNING_CATEGORY_CAP = 20.0
|
|
52
|
+
_AMBIGUITY_POINTS_PER_FINDING = 5.0
|
|
53
|
+
_AMBIGUITY_CATEGORY_CAP = 15.0
|
|
54
|
+
_SCHEMA_POINTS_PER_FINDING = 3.0
|
|
55
|
+
_SCHEMA_CATEGORY_CAP = 15.0
|
|
56
|
+
_SAFETY_POINTS_PER_FINDING = 5.0
|
|
57
|
+
_SAFETY_CATEGORY_CAP = 15.0
|
|
58
|
+
_BENCHMARK_CATEGORY_CAP = 15.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _category(finding: Finding) -> str:
|
|
62
|
+
if finding.rule_id in AMBIGUITY_RULE_IDS:
|
|
63
|
+
return "ambiguity"
|
|
64
|
+
if finding.rule_id in SCHEMA_RULE_IDS:
|
|
65
|
+
return "schema_completeness"
|
|
66
|
+
if finding.rule_id in SAFETY_RULE_IDS:
|
|
67
|
+
return "safety_clarity"
|
|
68
|
+
if finding.severity == Severity.ERROR:
|
|
69
|
+
return "critical_error"
|
|
70
|
+
return "warning_info"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compute_score(
|
|
74
|
+
report: LintReport, benchmark_result: BenchmarkResult | None = None
|
|
75
|
+
) -> ScoreBreakdown:
|
|
76
|
+
counts: dict[str, int] = {}
|
|
77
|
+
for finding in report.findings:
|
|
78
|
+
category = _category(finding)
|
|
79
|
+
counts[category] = counts.get(category, 0) + 1
|
|
80
|
+
|
|
81
|
+
deductions: list[ScoreDeduction] = []
|
|
82
|
+
|
|
83
|
+
def _add(category: str, label: str, per_finding: float, cap: float) -> None:
|
|
84
|
+
count = counts.get(category, 0)
|
|
85
|
+
if count == 0:
|
|
86
|
+
return
|
|
87
|
+
points = min(cap, count * per_finding)
|
|
88
|
+
deductions.append(
|
|
89
|
+
ScoreDeduction(
|
|
90
|
+
category=category,
|
|
91
|
+
points_lost=points,
|
|
92
|
+
finding_count=count,
|
|
93
|
+
explanation=(
|
|
94
|
+
f"{count} {label} finding(s) x {per_finding:.0f} pts (capped at {cap:.0f})"
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_add("critical_error", "critical/error", _ERROR_POINTS_PER_FINDING, _ERROR_CATEGORY_CAP)
|
|
100
|
+
_add("warning_info", "warning/info", _WARNING_POINTS_PER_FINDING, _WARNING_CATEGORY_CAP)
|
|
101
|
+
_add("ambiguity", "ambiguity", _AMBIGUITY_POINTS_PER_FINDING, _AMBIGUITY_CATEGORY_CAP)
|
|
102
|
+
_add(
|
|
103
|
+
"schema_completeness",
|
|
104
|
+
"schema-completeness",
|
|
105
|
+
_SCHEMA_POINTS_PER_FINDING,
|
|
106
|
+
_SCHEMA_CATEGORY_CAP,
|
|
107
|
+
)
|
|
108
|
+
_add("safety_clarity", "safety-clarity", _SAFETY_POINTS_PER_FINDING, _SAFETY_CATEGORY_CAP)
|
|
109
|
+
|
|
110
|
+
benchmark_accuracy: float | None = None
|
|
111
|
+
if benchmark_result is not None:
|
|
112
|
+
benchmark_accuracy = benchmark_result.exact_tool_selection_accuracy
|
|
113
|
+
benchmark_points = round((1 - benchmark_accuracy) * _BENCHMARK_CATEGORY_CAP, 1)
|
|
114
|
+
if benchmark_points > 0:
|
|
115
|
+
deductions.append(
|
|
116
|
+
ScoreDeduction(
|
|
117
|
+
category="benchmark_accuracy",
|
|
118
|
+
points_lost=benchmark_points,
|
|
119
|
+
finding_count=0,
|
|
120
|
+
explanation=(
|
|
121
|
+
f"exact tool-selection accuracy {benchmark_accuracy:.0%} "
|
|
122
|
+
f"(up to {_BENCHMARK_CATEGORY_CAP:.0f} pts)"
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
total_deducted = sum(d.points_lost for d in deductions)
|
|
128
|
+
total_score = max(0, min(100, round(100 - total_deducted)))
|
|
129
|
+
|
|
130
|
+
return ScoreBreakdown(
|
|
131
|
+
total_score=total_score, deductions=deductions, benchmark_accuracy=benchmark_accuracy
|
|
132
|
+
)
|
mcplint/fix/__init__.py
ADDED
|
File without changes
|
mcplint/fix/suggest.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Deterministic rewrite suggestions built directly from JSON Schema and
|
|
2
|
+
annotations — no LLM. Each fixable finding contributes one clause appended
|
|
3
|
+
to the tool's existing description (or, for excessive-description-length,
|
|
4
|
+
a deterministic truncation). Purely semantic issues (vague wording, a
|
|
5
|
+
description that just restates the name, or a missing description) cannot
|
|
6
|
+
be fixed with fabricated prose in deterministic mode: they get a low-
|
|
7
|
+
confidence TODO placeholder instead, honestly flagged as needing manual or
|
|
8
|
+
LLM-assisted rewriting.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from mcplint.models.contracts import ToolContract
|
|
16
|
+
from mcplint.models.findings import Finding, LintReport
|
|
17
|
+
from mcplint.models.fixes import RewriteSuggestion
|
|
18
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
19
|
+
|
|
20
|
+
_SEMANTIC_PLACEHOLDER_RULES = frozenset(
|
|
21
|
+
{"missing-tool-description", "description-repeats-name", "vague-tool-description"}
|
|
22
|
+
)
|
|
23
|
+
_ACTIONABLE_RULE_IDS = _SEMANTIC_PLACEHOLDER_RULES | frozenset(
|
|
24
|
+
{
|
|
25
|
+
"missing-return-semantics",
|
|
26
|
+
"undocumented-required-constraint",
|
|
27
|
+
"destructive-tool-without-warning",
|
|
28
|
+
"missing-tool-distinction",
|
|
29
|
+
"excessive-description-length",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_OTHER_TOOL_PATTERN = re.compile(r"'([a-zA-Z0-9_-]+)'")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _describe_output_schema(schema: dict[str, object]) -> str:
|
|
37
|
+
properties = schema.get("properties")
|
|
38
|
+
if isinstance(properties, dict) and properties:
|
|
39
|
+
fields = ", ".join(sorted(properties))
|
|
40
|
+
return f"Returns an object with fields: {fields}."
|
|
41
|
+
schema_type = schema.get("type")
|
|
42
|
+
if isinstance(schema_type, str):
|
|
43
|
+
return f"Returns a value of type '{schema_type}'."
|
|
44
|
+
return "Returns a result."
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _constraint_clause(param_name: str, schema: dict[str, object]) -> str | None:
|
|
48
|
+
enum_values = schema.get("enum")
|
|
49
|
+
if isinstance(enum_values, list):
|
|
50
|
+
values = ", ".join(str(v) for v in enum_values)
|
|
51
|
+
return f"Parameter '{param_name}' accepts: {values}."
|
|
52
|
+
minimum = schema.get("minimum")
|
|
53
|
+
maximum = schema.get("maximum")
|
|
54
|
+
if minimum is not None and maximum is not None:
|
|
55
|
+
return f"Parameter '{param_name}' must be between {minimum} and {maximum}."
|
|
56
|
+
if minimum is not None:
|
|
57
|
+
return f"Parameter '{param_name}' must be at least {minimum}."
|
|
58
|
+
if maximum is not None:
|
|
59
|
+
return f"Parameter '{param_name}' must be at most {maximum}."
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _other_tool_name(message: str, this_tool: str) -> str | None:
|
|
64
|
+
names = [name for name in _OTHER_TOOL_PATTERN.findall(message) if name != this_tool]
|
|
65
|
+
return names[0] if names else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _truncate_description(description: str, max_characters: int) -> str:
|
|
69
|
+
if len(description) <= max_characters:
|
|
70
|
+
return description
|
|
71
|
+
truncated = description[:max_characters]
|
|
72
|
+
last_period = truncated.rfind(". ")
|
|
73
|
+
if last_period > 0:
|
|
74
|
+
return truncated[: last_period + 1]
|
|
75
|
+
return truncated.rstrip() + "…"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def suggest_for_tool(tool: ToolContract, findings: list[Finding]) -> RewriteSuggestion | None:
|
|
79
|
+
actionable = [f for f in findings if f.rule_id in _ACTIONABLE_RULE_IDS]
|
|
80
|
+
if not actionable:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
base = (tool.description or "").strip()
|
|
84
|
+
clauses: list[str] = []
|
|
85
|
+
resolved_rule_ids: list[str] = []
|
|
86
|
+
explanations: list[str] = []
|
|
87
|
+
confidences: list[float] = []
|
|
88
|
+
|
|
89
|
+
for finding in actionable:
|
|
90
|
+
rule_id = finding.rule_id
|
|
91
|
+
|
|
92
|
+
if rule_id == "missing-return-semantics":
|
|
93
|
+
if tool.output_schema:
|
|
94
|
+
clauses.append(_describe_output_schema(tool.output_schema))
|
|
95
|
+
explanations.append("Documented the return shape from outputSchema.")
|
|
96
|
+
confidences.append(0.9)
|
|
97
|
+
else:
|
|
98
|
+
clauses.append("Document what this tool returns (e.g. field names and types).")
|
|
99
|
+
explanations.append("Flagged the missing return description for manual detail.")
|
|
100
|
+
confidences.append(0.4)
|
|
101
|
+
resolved_rule_ids.append(rule_id)
|
|
102
|
+
|
|
103
|
+
elif rule_id == "undocumented-required-constraint":
|
|
104
|
+
parts = finding.location.json_path.split(".")
|
|
105
|
+
param_name = parts[3] if len(parts) > 3 else None
|
|
106
|
+
param = next((p for p in tool.parameters if p.name == param_name), None)
|
|
107
|
+
clause = _constraint_clause(param.name, param.json_schema) if param else None
|
|
108
|
+
if clause:
|
|
109
|
+
clauses.append(clause)
|
|
110
|
+
resolved_rule_ids.append(rule_id)
|
|
111
|
+
explanations.append(f"Stated the '{param_name}' constraint from its JSON Schema.")
|
|
112
|
+
confidences.append(0.85)
|
|
113
|
+
|
|
114
|
+
elif rule_id == "destructive-tool-without-warning":
|
|
115
|
+
clauses.append("This action is permanent and cannot be undone.")
|
|
116
|
+
resolved_rule_ids.append(rule_id)
|
|
117
|
+
explanations.append("Added a destructive-operation warning.")
|
|
118
|
+
confidences.append(0.9)
|
|
119
|
+
|
|
120
|
+
elif rule_id == "missing-tool-distinction":
|
|
121
|
+
other = _other_tool_name(finding.message, tool.name)
|
|
122
|
+
if other:
|
|
123
|
+
clauses.append(
|
|
124
|
+
f"See {other} for a related but different operation — check both "
|
|
125
|
+
"descriptions before choosing."
|
|
126
|
+
)
|
|
127
|
+
resolved_rule_ids.append(rule_id)
|
|
128
|
+
explanations.append(
|
|
129
|
+
f"Added a placeholder distinction between '{tool.name}' and '{other}'; "
|
|
130
|
+
"refine the wording manually or with LLM-assisted rewriting."
|
|
131
|
+
)
|
|
132
|
+
confidences.append(0.35)
|
|
133
|
+
|
|
134
|
+
elif rule_id in _SEMANTIC_PLACEHOLDER_RULES:
|
|
135
|
+
placeholder = (
|
|
136
|
+
"[TODO: describe the specific action, inputs, and output of this tool, "
|
|
137
|
+
"and when to use it instead of similar tools]"
|
|
138
|
+
)
|
|
139
|
+
if placeholder not in clauses:
|
|
140
|
+
clauses.append(placeholder)
|
|
141
|
+
resolved_rule_ids.append(rule_id)
|
|
142
|
+
explanations.append(
|
|
143
|
+
"Flagged for manual or LLM-assisted rewriting — deterministic mode "
|
|
144
|
+
"cannot fabricate semantic content."
|
|
145
|
+
)
|
|
146
|
+
confidences.append(0.2)
|
|
147
|
+
|
|
148
|
+
# Truncation takes precedence over appended clauses: adding more text would
|
|
149
|
+
# defeat the point of shortening an over-long description.
|
|
150
|
+
length_finding = next(
|
|
151
|
+
(f for f in actionable if f.rule_id == "excessive-description-length"), None
|
|
152
|
+
)
|
|
153
|
+
if length_finding is not None and base and length_finding.rule_id not in resolved_rule_ids:
|
|
154
|
+
proposed = _truncate_description(base, 800)
|
|
155
|
+
resolved_rule_ids.append(length_finding.rule_id)
|
|
156
|
+
explanations.append("Truncated the description to the configured character limit.")
|
|
157
|
+
confidences.append(0.8)
|
|
158
|
+
else:
|
|
159
|
+
proposed = " ".join([base, *clauses]).strip() if base or clauses else ""
|
|
160
|
+
|
|
161
|
+
if not resolved_rule_ids:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
return RewriteSuggestion(
|
|
165
|
+
tool_name=tool.name,
|
|
166
|
+
proposed_description=proposed,
|
|
167
|
+
resolved_rule_ids=resolved_rule_ids,
|
|
168
|
+
explanation=" ".join(explanations),
|
|
169
|
+
confidence=round(min(confidences), 2),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_suggestions(snapshot: MCPServerSnapshot, report: LintReport) -> list[RewriteSuggestion]:
|
|
174
|
+
findings_by_tool: dict[str, list[Finding]] = {}
|
|
175
|
+
for finding in report.findings:
|
|
176
|
+
findings_by_tool.setdefault(finding.location.tool_name, []).append(finding)
|
|
177
|
+
|
|
178
|
+
suggestions = []
|
|
179
|
+
for tool in snapshot.tools:
|
|
180
|
+
suggestion = suggest_for_tool(tool, findings_by_tool.get(tool.name, []))
|
|
181
|
+
if suggestion is not None:
|
|
182
|
+
suggestions.append(suggestion)
|
|
183
|
+
return suggestions
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Single source of truth for deterministic IDs and byte-stable snapshot JSON.
|
|
2
|
+
|
|
3
|
+
MCP servers are untrusted local (or remote) processes; this module only ever
|
|
4
|
+
touches data already parsed into typed models, never raw process output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def stable_tool_id(server_name: str, tool_name: str) -> str:
|
|
18
|
+
digest = hashlib.sha256(f"{server_name}::{tool_name}".encode()).hexdigest()
|
|
19
|
+
return digest[:16]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def canonical_json(snapshot: MCPServerSnapshot) -> str:
|
|
23
|
+
payload = snapshot.model_dump(mode="json")
|
|
24
|
+
payload["metadata"].pop("generated_at", None)
|
|
25
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Read/write MCPServerSnapshot to/from disk as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_snapshot(snapshot: MCPServerSnapshot, path: Path) -> None:
|
|
11
|
+
path.write_text(snapshot.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_snapshot(path: Path) -> MCPServerSnapshot:
|
|
15
|
+
if not path.exists():
|
|
16
|
+
raise FileNotFoundError(f"Snapshot file not found: {path}")
|
|
17
|
+
return MCPServerSnapshot.model_validate_json(path.read_text(encoding="utf-8"))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Connects to a stdio MCP server and produces an MCPServerSnapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
from mcp.client.stdio import stdio_client
|
|
9
|
+
|
|
10
|
+
from mcplint.mcp_client.canonical import stable_tool_id
|
|
11
|
+
from mcplint.mcp_client.stdio import parse_command
|
|
12
|
+
from mcplint.models.common import ArtifactMetadata
|
|
13
|
+
from mcplint.models.contracts import ParameterContract, ToolAnnotation, ToolContract
|
|
14
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from mcp.types import Tool as SDKTool
|
|
18
|
+
|
|
19
|
+
SNAPSHOT_SCHEMA_VERSION = "1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = ["collect_stdio_snapshot", "tool_from_mcp", "parse_command"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tool_from_mcp(server_name: str, tool: SDKTool) -> ToolContract:
|
|
25
|
+
schema = tool.inputSchema or {"type": "object"}
|
|
26
|
+
properties: dict[str, object] = schema.get("properties", {})
|
|
27
|
+
required: list[str] = schema.get("required", [])
|
|
28
|
+
|
|
29
|
+
parameters = [
|
|
30
|
+
ParameterContract(
|
|
31
|
+
name=name,
|
|
32
|
+
json_schema=prop_schema if isinstance(prop_schema, dict) else {},
|
|
33
|
+
required=name in required,
|
|
34
|
+
description=(prop_schema.get("description") if isinstance(prop_schema, dict) else None),
|
|
35
|
+
)
|
|
36
|
+
for name, prop_schema in properties.items()
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
annotations = ToolAnnotation()
|
|
40
|
+
if tool.annotations is not None:
|
|
41
|
+
annotations = ToolAnnotation(
|
|
42
|
+
title=tool.annotations.title,
|
|
43
|
+
read_only_hint=tool.annotations.readOnlyHint,
|
|
44
|
+
destructive_hint=tool.annotations.destructiveHint,
|
|
45
|
+
idempotent_hint=tool.annotations.idempotentHint,
|
|
46
|
+
open_world_hint=tool.annotations.openWorldHint,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return ToolContract(
|
|
50
|
+
id=stable_tool_id(server_name, tool.name),
|
|
51
|
+
name=tool.name,
|
|
52
|
+
description=tool.description,
|
|
53
|
+
input_schema=schema,
|
|
54
|
+
output_schema=tool.outputSchema,
|
|
55
|
+
parameters=parameters,
|
|
56
|
+
annotations=annotations,
|
|
57
|
+
raw=tool.model_dump(mode="json"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def collect_stdio_snapshot(
|
|
62
|
+
command: str, args: list[str], *, env: dict[str, str] | None = None
|
|
63
|
+
) -> MCPServerSnapshot:
|
|
64
|
+
params = StdioServerParameters(command=command, args=args, env=env)
|
|
65
|
+
async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
|
|
66
|
+
init_result = await session.initialize()
|
|
67
|
+
server_name = init_result.serverInfo.name
|
|
68
|
+
server_version = init_result.serverInfo.version
|
|
69
|
+
listed = await session.list_tools()
|
|
70
|
+
tools = [tool_from_mcp(server_name, t) for t in listed.tools]
|
|
71
|
+
|
|
72
|
+
command_line = " ".join([command, *args])
|
|
73
|
+
return MCPServerSnapshot(
|
|
74
|
+
metadata=ArtifactMetadata.create(schema_version=SNAPSHOT_SCHEMA_VERSION),
|
|
75
|
+
server_name=server_name,
|
|
76
|
+
server_version=server_version,
|
|
77
|
+
transport="stdio",
|
|
78
|
+
command=command_line,
|
|
79
|
+
tools=tools,
|
|
80
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Process-spawning helpers for stdio MCP servers.
|
|
2
|
+
|
|
3
|
+
MCP servers invoked here are treated as untrusted local processes: the
|
|
4
|
+
command/args are passed as a list (never shell=True) and no output beyond
|
|
5
|
+
the MCP protocol stream is trusted or persisted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shlex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_command(command_line: str) -> tuple[str, list[str]]:
|
|
14
|
+
parts = shlex.split(command_line)
|
|
15
|
+
if not parts:
|
|
16
|
+
raise ValueError("Empty server command")
|
|
17
|
+
return parts[0], parts[1:]
|
|
File without changes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Typed models for the benchmark dataset format, provider results, and scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from mcplint.models.common import ArtifactMetadata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ExpectedToolCall(BaseModel):
|
|
11
|
+
tool: str
|
|
12
|
+
arguments: dict[str, object] = Field(default_factory=dict)
|
|
13
|
+
forbidden_tools: list[str] = Field(default_factory=list)
|
|
14
|
+
argument_assertions: dict[str, object] = Field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def all_expected_arguments(self) -> dict[str, object]:
|
|
17
|
+
return {**self.arguments, **self.argument_assertions}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BenchmarkCase(BaseModel):
|
|
21
|
+
id: str
|
|
22
|
+
prompt: str
|
|
23
|
+
expected: ExpectedToolCall
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BenchmarkDataset(BaseModel):
|
|
27
|
+
name: str
|
|
28
|
+
version: str
|
|
29
|
+
cases: list[BenchmarkCase]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ActualToolCall(BaseModel):
|
|
33
|
+
tool: str | None
|
|
34
|
+
arguments: dict[str, object] = Field(default_factory=dict)
|
|
35
|
+
valid_arguments: bool = True
|
|
36
|
+
error: str | None = None
|
|
37
|
+
latency_ms: float = 0.0
|
|
38
|
+
input_tokens: int | None = None
|
|
39
|
+
output_tokens: int | None = None
|
|
40
|
+
estimated_cost: float | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class BenchmarkTrial(BaseModel):
|
|
44
|
+
case_id: str
|
|
45
|
+
trial_index: int
|
|
46
|
+
actual: ActualToolCall
|
|
47
|
+
passed: bool
|
|
48
|
+
failure_reasons: list[str] = Field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BenchmarkResult(BaseModel):
|
|
52
|
+
metadata: ArtifactMetadata
|
|
53
|
+
dataset_name: str
|
|
54
|
+
provider: str
|
|
55
|
+
model: str
|
|
56
|
+
runs_per_case: int
|
|
57
|
+
trials: list[BenchmarkTrial]
|
|
58
|
+
exact_tool_selection_accuracy: float
|
|
59
|
+
valid_argument_rate: float
|
|
60
|
+
required_argument_accuracy: float
|
|
61
|
+
forbidden_tool_invocation_rate: float
|
|
62
|
+
no_tool_rate: float
|
|
63
|
+
mean_latency_ms: float
|
|
64
|
+
p95_latency_ms: float
|
|
65
|
+
total_estimated_cost: float | None
|
|
66
|
+
per_case_pass_rate: dict[str, float]
|
|
67
|
+
stability: dict[str, float]
|
mcplint/models/common.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Shared metadata mixin every persisted MCPLint artifact embeds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from mcplint.__about__ import __version__ as _MCPLINT_VERSION
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ArtifactMetadata(BaseModel):
|
|
13
|
+
schema_version: str
|
|
14
|
+
generated_at: datetime
|
|
15
|
+
mcplint_version: str
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def create(cls, schema_version: str) -> ArtifactMetadata:
|
|
19
|
+
return cls(
|
|
20
|
+
schema_version=schema_version,
|
|
21
|
+
generated_at=datetime.now(UTC),
|
|
22
|
+
mcplint_version=_MCPLINT_VERSION,
|
|
23
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Typed models for comparing two MCPServerSnapshots (and optionally two benchmark runs)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from mcplint.models.common import ArtifactMetadata
|
|
8
|
+
from mcplint.models.findings import Finding
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SchemaChange(BaseModel):
|
|
12
|
+
tool_name: str
|
|
13
|
+
json_path: str
|
|
14
|
+
before: object
|
|
15
|
+
after: object
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DescriptionChange(BaseModel):
|
|
19
|
+
tool_name: str
|
|
20
|
+
before: str | None
|
|
21
|
+
after: str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmbiguityScoreChange(BaseModel):
|
|
25
|
+
tool_a: str
|
|
26
|
+
tool_b: str
|
|
27
|
+
before: float
|
|
28
|
+
after: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ComparisonReport(BaseModel):
|
|
32
|
+
metadata: ArtifactMetadata
|
|
33
|
+
baseline_server_name: str
|
|
34
|
+
candidate_server_name: str
|
|
35
|
+
added_tools: list[str] = Field(default_factory=list)
|
|
36
|
+
removed_tools: list[str] = Field(default_factory=list)
|
|
37
|
+
schema_changes: list[SchemaChange] = Field(default_factory=list)
|
|
38
|
+
description_changes: list[DescriptionChange] = Field(default_factory=list)
|
|
39
|
+
new_findings: list[Finding] = Field(default_factory=list)
|
|
40
|
+
resolved_findings: list[Finding] = Field(default_factory=list)
|
|
41
|
+
ambiguity_score_changes: list[AmbiguityScoreChange] = Field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
benchmark_dataset_name: str | None = None
|
|
44
|
+
baseline_accuracy: float | None = None
|
|
45
|
+
candidate_accuracy: float | None = None
|
|
46
|
+
benchmark_accuracy_delta: float | None = None
|
|
47
|
+
argument_validity_delta: float | None = None
|
|
48
|
+
latency_delta_ms: float | None = None
|
|
49
|
+
cost_delta: float | None = None
|
|
50
|
+
regressions_by_case: dict[str, str] = Field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
min_accuracy_delta_threshold: float | None = None
|
|
53
|
+
passes_ci_threshold: bool | None = None
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Typed representations of an MCP tool contract, independent of the wire format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SourceLocation(BaseModel):
|
|
9
|
+
tool_name: str
|
|
10
|
+
json_path: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ParameterContract(BaseModel):
|
|
14
|
+
name: str
|
|
15
|
+
json_schema: dict[str, object]
|
|
16
|
+
required: bool
|
|
17
|
+
description: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ToolAnnotation(BaseModel):
|
|
21
|
+
title: str | None = None
|
|
22
|
+
read_only_hint: bool | None = None
|
|
23
|
+
destructive_hint: bool | None = None
|
|
24
|
+
idempotent_hint: bool | None = None
|
|
25
|
+
open_world_hint: bool | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolContract(BaseModel):
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
description: str | None
|
|
32
|
+
input_schema: dict[str, object]
|
|
33
|
+
output_schema: dict[str, object] | None = None
|
|
34
|
+
parameters: list[ParameterContract]
|
|
35
|
+
annotations: ToolAnnotation
|
|
36
|
+
raw: dict[str, object]
|
|
37
|
+
|
|
38
|
+
def parameter_names(self) -> set[str]:
|
|
39
|
+
return {param.name for param in self.parameters}
|