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/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
+ )
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]
@@ -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}