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/config/schema.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Typed schema for mcplint.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from mcplint.core.rules.ambiguity import DEFAULT_AMBIGUITY_THRESHOLD
|
|
8
|
+
from mcplint.core.rules.completeness_rules import DEFAULT_MAX_DESCRIPTION_CHARACTERS
|
|
9
|
+
from mcplint.models.findings import Severity
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ThresholdsConfig(BaseModel):
|
|
13
|
+
ambiguity: float = Field(default=DEFAULT_AMBIGUITY_THRESHOLD, ge=0.0, le=1.0)
|
|
14
|
+
max_description_characters: int = Field(default=DEFAULT_MAX_DESCRIPTION_CHARACTERS, gt=0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IgnoreEntry(BaseModel):
|
|
18
|
+
tool: str
|
|
19
|
+
rules: list[str] = Field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BenchmarkConfig(BaseModel):
|
|
23
|
+
provider: str | None = None
|
|
24
|
+
model: str | None = None
|
|
25
|
+
runs: int = Field(default=3, ge=1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MCPLintConfig(BaseModel):
|
|
29
|
+
severity: dict[str, Severity] = Field(default_factory=dict)
|
|
30
|
+
thresholds: ThresholdsConfig = Field(default_factory=ThresholdsConfig)
|
|
31
|
+
ignore: list[IgnoreEntry] = Field(default_factory=list)
|
|
32
|
+
benchmark: BenchmarkConfig | None = None
|
|
33
|
+
|
|
34
|
+
def ignored_rule_ids_for_tool(self, tool_name: str, all_rule_ids: set[str]) -> set[str]:
|
|
35
|
+
ignored: set[str] = set()
|
|
36
|
+
for entry in self.ignore:
|
|
37
|
+
if entry.tool != tool_name:
|
|
38
|
+
continue
|
|
39
|
+
ignored |= set(entry.rules) if entry.rules else all_rule_ids
|
|
40
|
+
return ignored
|
mcplint/core/__init__.py
ADDED
|
File without changes
|
mcplint/core/engine.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Pure function that runs every registered rule over every tool in a snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from mcplint.core.registry import RuleRegistry
|
|
8
|
+
from mcplint.core.rules.base import RuleContext
|
|
9
|
+
from mcplint.models.common import ArtifactMetadata
|
|
10
|
+
from mcplint.models.findings import Finding, LintReport
|
|
11
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from mcplint.config.schema import MCPLintConfig
|
|
15
|
+
|
|
16
|
+
REPORT_SCHEMA_VERSION = "1.0"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def lint_snapshot(
|
|
20
|
+
snapshot: MCPServerSnapshot,
|
|
21
|
+
registry: RuleRegistry,
|
|
22
|
+
config: MCPLintConfig | None = None,
|
|
23
|
+
) -> LintReport:
|
|
24
|
+
context = RuleContext(snapshot=snapshot)
|
|
25
|
+
all_rule_ids = {rule.id for rule in registry.all()}
|
|
26
|
+
findings: list[Finding] = []
|
|
27
|
+
for tool in snapshot.tools:
|
|
28
|
+
ignored = config.ignored_rule_ids_for_tool(tool.name, all_rule_ids) if config else set()
|
|
29
|
+
for rule in registry.all():
|
|
30
|
+
if rule.id in ignored:
|
|
31
|
+
continue
|
|
32
|
+
for finding in rule.check(tool, context):
|
|
33
|
+
if config and rule.id in config.severity:
|
|
34
|
+
finding = finding.model_copy(update={"severity": config.severity[rule.id]})
|
|
35
|
+
findings.append(finding)
|
|
36
|
+
return LintReport(
|
|
37
|
+
metadata=ArtifactMetadata.create(schema_version=REPORT_SCHEMA_VERSION),
|
|
38
|
+
server_name=snapshot.server_name,
|
|
39
|
+
findings=findings,
|
|
40
|
+
)
|
mcplint/core/registry.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Collects built-in and plugin rules for the lint engine to run."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from mcplint.core.rules.base import Rule
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from mcplint.config.schema import MCPLintConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RuleRegistry:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._rules: dict[str, Rule] = {}
|
|
17
|
+
|
|
18
|
+
def register(self, rule: Rule) -> None:
|
|
19
|
+
if rule.id in self._rules:
|
|
20
|
+
raise ValueError(f"Rule '{rule.id}' is already registered")
|
|
21
|
+
self._rules[rule.id] = rule
|
|
22
|
+
|
|
23
|
+
def get(self, rule_id: str) -> Rule | None:
|
|
24
|
+
return self._rules.get(rule_id)
|
|
25
|
+
|
|
26
|
+
def all(self) -> list[Rule]:
|
|
27
|
+
return [self._rules[key] for key in sorted(self._rules)]
|
|
28
|
+
|
|
29
|
+
def load_entry_point_plugins(self) -> None:
|
|
30
|
+
for entry_point in entry_points(group="mcplint.rules"):
|
|
31
|
+
rule_cls = entry_point.load()
|
|
32
|
+
self.register(rule_cls())
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def with_builtin_rules(cls, config: MCPLintConfig | None = None) -> RuleRegistry:
|
|
36
|
+
from mcplint.core.rules.ambiguity_rules import (
|
|
37
|
+
AmbiguousToolOverlapRule,
|
|
38
|
+
MissingToolDistinctionRule,
|
|
39
|
+
)
|
|
40
|
+
from mcplint.core.rules.builtin import BUILTIN_RULES
|
|
41
|
+
from mcplint.core.rules.completeness_rules import ExcessiveDescriptionLengthRule
|
|
42
|
+
|
|
43
|
+
registry = cls()
|
|
44
|
+
for rule_cls in BUILTIN_RULES:
|
|
45
|
+
rule = rule_cls()
|
|
46
|
+
if config is not None:
|
|
47
|
+
if isinstance(rule, AmbiguousToolOverlapRule | MissingToolDistinctionRule):
|
|
48
|
+
rule.threshold = config.thresholds.ambiguity
|
|
49
|
+
elif isinstance(rule, ExcessiveDescriptionLengthRule):
|
|
50
|
+
rule.max_characters = config.thresholds.max_description_characters
|
|
51
|
+
registry.register(rule)
|
|
52
|
+
return registry
|
|
File without changes
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Cross-tool semantic ambiguity engine.
|
|
2
|
+
|
|
3
|
+
Computes a 0-1 ambiguity score between every pair of tools from normalised
|
|
4
|
+
token overlap, name similarity, description similarity, and input-schema
|
|
5
|
+
(parameter name) similarity, plus optional sentence-transformer embeddings
|
|
6
|
+
when the `semantic` extra is installed. Every flagged pair carries structured
|
|
7
|
+
evidence (shared verbs/entities/parameters, absent distinctions) so the
|
|
8
|
+
result is inspectable rather than an opaque score.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from mcplint.core.rules.safety_rules import READ_VERBS, WRITE_VERBS, first_word
|
|
17
|
+
from mcplint.models.contracts import ToolContract
|
|
18
|
+
|
|
19
|
+
DEFAULT_AMBIGUITY_THRESHOLD = 0.55
|
|
20
|
+
|
|
21
|
+
_STOPWORDS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"a",
|
|
24
|
+
"an",
|
|
25
|
+
"the",
|
|
26
|
+
"of",
|
|
27
|
+
"for",
|
|
28
|
+
"to",
|
|
29
|
+
"and",
|
|
30
|
+
"or",
|
|
31
|
+
"in",
|
|
32
|
+
"on",
|
|
33
|
+
"by",
|
|
34
|
+
"with",
|
|
35
|
+
"is",
|
|
36
|
+
"are",
|
|
37
|
+
"this",
|
|
38
|
+
"that",
|
|
39
|
+
"it",
|
|
40
|
+
"its",
|
|
41
|
+
"your",
|
|
42
|
+
"you",
|
|
43
|
+
"from",
|
|
44
|
+
"as",
|
|
45
|
+
"at",
|
|
46
|
+
"be",
|
|
47
|
+
"will",
|
|
48
|
+
"if",
|
|
49
|
+
"not",
|
|
50
|
+
"can",
|
|
51
|
+
"does",
|
|
52
|
+
"do",
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
_MANY_HINTS = frozenset({"list", "search", "find", "query"})
|
|
56
|
+
_ONE_HINTS = frozenset({"get", "fetch", "show", "view"})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _tokens(text: str, *, drop_stopwords: bool) -> set[str]:
|
|
60
|
+
cleaned = re.sub(r"[^a-z0-9\s]", " ", text.lower())
|
|
61
|
+
words = [w for w in cleaned.split() if w]
|
|
62
|
+
if drop_stopwords:
|
|
63
|
+
words = [w for w in words if w not in _STOPWORDS]
|
|
64
|
+
return set(words)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
68
|
+
if not a and not b:
|
|
69
|
+
return 0.0
|
|
70
|
+
union = a | b
|
|
71
|
+
if not union:
|
|
72
|
+
return 0.0
|
|
73
|
+
return len(a & b) / len(union)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class AmbiguityEvidence:
|
|
78
|
+
shared_verbs: tuple[str, ...] = ()
|
|
79
|
+
shared_entities: tuple[str, ...] = ()
|
|
80
|
+
overlapping_parameters: tuple[str, ...] = ()
|
|
81
|
+
absent_exact_vs_search_distinction: bool = False
|
|
82
|
+
absent_one_vs_many_distinction: bool = False
|
|
83
|
+
absent_read_vs_write_distinction: bool = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class AmbiguityPairResult:
|
|
88
|
+
tool_a: str
|
|
89
|
+
tool_b: str
|
|
90
|
+
score: float
|
|
91
|
+
name_similarity: float
|
|
92
|
+
description_similarity: float
|
|
93
|
+
schema_similarity: float
|
|
94
|
+
evidence: AmbiguityEvidence = field(default_factory=AmbiguityEvidence)
|
|
95
|
+
|
|
96
|
+
def has_missing_distinction(self) -> bool:
|
|
97
|
+
return (
|
|
98
|
+
self.evidence.absent_exact_vs_search_distinction
|
|
99
|
+
or self.evidence.absent_one_vs_many_distinction
|
|
100
|
+
or self.evidence.absent_read_vs_write_distinction
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compute_ambiguity(tool_a: ToolContract, tool_b: ToolContract) -> AmbiguityPairResult:
|
|
105
|
+
name_tokens_a = _tokens(tool_a.name.replace("_", " ").replace("-", " "), drop_stopwords=False)
|
|
106
|
+
name_tokens_b = _tokens(tool_b.name.replace("_", " ").replace("-", " "), drop_stopwords=False)
|
|
107
|
+
name_similarity = _jaccard(name_tokens_a, name_tokens_b)
|
|
108
|
+
|
|
109
|
+
desc_tokens_a = _tokens(tool_a.description or "", drop_stopwords=True)
|
|
110
|
+
desc_tokens_b = _tokens(tool_b.description or "", drop_stopwords=True)
|
|
111
|
+
description_similarity = _jaccard(desc_tokens_a, desc_tokens_b)
|
|
112
|
+
|
|
113
|
+
schema_similarity = _jaccard(tool_a.parameter_names(), tool_b.parameter_names())
|
|
114
|
+
|
|
115
|
+
score = 0.25 * name_similarity + 0.45 * description_similarity + 0.30 * schema_similarity
|
|
116
|
+
|
|
117
|
+
verb_a, verb_b = first_word(tool_a.name), first_word(tool_b.name)
|
|
118
|
+
action_verbs = READ_VERBS | WRITE_VERBS
|
|
119
|
+
shared_verbs = tuple(sorted({verb_a} & {verb_b} & action_verbs))
|
|
120
|
+
shared_entities = tuple(sorted((name_tokens_a & name_tokens_b) - action_verbs))
|
|
121
|
+
overlapping_parameters = tuple(sorted(tool_a.parameter_names() & tool_b.parameter_names()))
|
|
122
|
+
|
|
123
|
+
combined_description = f"{tool_a.description or ''} {tool_b.description or ''}".lower()
|
|
124
|
+
|
|
125
|
+
absent_exact_vs_search = (
|
|
126
|
+
{verb_a, verb_b} & _ONE_HINTS
|
|
127
|
+
and {verb_a, verb_b} & _MANY_HINTS
|
|
128
|
+
and "exact" not in combined_description
|
|
129
|
+
)
|
|
130
|
+
one_vs_many_keywords = ("single", "one or more", "list of", "multiple")
|
|
131
|
+
absent_one_vs_many = (
|
|
132
|
+
bool({verb_a, verb_b} & _ONE_HINTS)
|
|
133
|
+
and bool({verb_a, verb_b} & _MANY_HINTS)
|
|
134
|
+
and not any(kw in combined_description for kw in one_vs_many_keywords)
|
|
135
|
+
)
|
|
136
|
+
read_only_a = bool(tool_a.annotations.read_only_hint)
|
|
137
|
+
read_only_b = bool(tool_b.annotations.read_only_hint)
|
|
138
|
+
absent_read_vs_write = read_only_a != read_only_b and "read-only" not in combined_description
|
|
139
|
+
|
|
140
|
+
evidence = AmbiguityEvidence(
|
|
141
|
+
shared_verbs=shared_verbs,
|
|
142
|
+
shared_entities=shared_entities,
|
|
143
|
+
overlapping_parameters=overlapping_parameters,
|
|
144
|
+
absent_exact_vs_search_distinction=bool(absent_exact_vs_search),
|
|
145
|
+
absent_one_vs_many_distinction=bool(absent_one_vs_many),
|
|
146
|
+
absent_read_vs_write_distinction=absent_read_vs_write,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return AmbiguityPairResult(
|
|
150
|
+
tool_a=tool_a.name,
|
|
151
|
+
tool_b=tool_b.name,
|
|
152
|
+
score=round(score, 4),
|
|
153
|
+
name_similarity=round(name_similarity, 4),
|
|
154
|
+
description_similarity=round(description_similarity, 4),
|
|
155
|
+
schema_similarity=round(schema_similarity, 4),
|
|
156
|
+
evidence=evidence,
|
|
157
|
+
)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Rules built on top of the cross-tool ambiguity engine (`ambiguity.py`).
|
|
2
|
+
|
|
3
|
+
Both rules iterate every *other* tool in the snapshot but only emit a
|
|
4
|
+
finding once per unordered pair (when `tool.name < other.name`), since the
|
|
5
|
+
lint engine calls `check()` once per tool and pairs would otherwise be
|
|
6
|
+
reported twice.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from mcplint.core.rules.ambiguity import DEFAULT_AMBIGUITY_THRESHOLD, compute_ambiguity
|
|
12
|
+
from mcplint.core.rules.base import Rule, RuleContext
|
|
13
|
+
from mcplint.models.contracts import SourceLocation, ToolContract
|
|
14
|
+
from mcplint.models.findings import Finding, Severity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AmbiguousToolOverlapRule(Rule):
|
|
18
|
+
id = "ambiguous-tool-overlap"
|
|
19
|
+
title = "Ambiguous tool overlap"
|
|
20
|
+
description = (
|
|
21
|
+
"Flags pairs of tools whose name, description, and parameters overlap "
|
|
22
|
+
"enough that an agent could plausibly pick either one for the same task."
|
|
23
|
+
)
|
|
24
|
+
default_severity = Severity.WARNING
|
|
25
|
+
tags = ("ambiguity",)
|
|
26
|
+
threshold = DEFAULT_AMBIGUITY_THRESHOLD
|
|
27
|
+
|
|
28
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
29
|
+
findings = []
|
|
30
|
+
for other in context.snapshot.tools:
|
|
31
|
+
if other.name <= tool.name:
|
|
32
|
+
continue
|
|
33
|
+
result = compute_ambiguity(tool, other)
|
|
34
|
+
if result.score < self.threshold:
|
|
35
|
+
continue
|
|
36
|
+
evidence_parts = [
|
|
37
|
+
f"ambiguity score {result.score:.2f} (threshold {self.threshold:.2f})"
|
|
38
|
+
]
|
|
39
|
+
if result.evidence.shared_verbs:
|
|
40
|
+
evidence_parts.append(f"shared verbs: {', '.join(result.evidence.shared_verbs)}")
|
|
41
|
+
if result.evidence.shared_entities:
|
|
42
|
+
evidence_parts.append(
|
|
43
|
+
f"shared entities: {', '.join(result.evidence.shared_entities)}"
|
|
44
|
+
)
|
|
45
|
+
if result.evidence.overlapping_parameters:
|
|
46
|
+
evidence_parts.append(
|
|
47
|
+
f"overlapping parameters: {', '.join(result.evidence.overlapping_parameters)}"
|
|
48
|
+
)
|
|
49
|
+
findings.append(
|
|
50
|
+
Finding(
|
|
51
|
+
rule_id=self.id,
|
|
52
|
+
severity=self.default_severity,
|
|
53
|
+
message=f"Tools '{tool.name}' and '{other.name}' are semantically ambiguous.",
|
|
54
|
+
evidence="; ".join(evidence_parts),
|
|
55
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
56
|
+
remediation=(
|
|
57
|
+
f"Clarify how '{tool.name}' differs from '{other.name}': when an "
|
|
58
|
+
"agent should pick one over the other."
|
|
59
|
+
),
|
|
60
|
+
confidence=min(0.9, 0.4 + result.score / 2),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return findings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MissingToolDistinctionRule(Rule):
|
|
67
|
+
id = "missing-tool-distinction"
|
|
68
|
+
title = "Missing tool distinction"
|
|
69
|
+
description = (
|
|
70
|
+
"For ambiguous tool pairs, flags the absence of an explicit "
|
|
71
|
+
"exact-vs-search, one-vs-many, or read-vs-write distinction."
|
|
72
|
+
)
|
|
73
|
+
default_severity = Severity.INFO
|
|
74
|
+
tags = ("ambiguity",)
|
|
75
|
+
threshold = DEFAULT_AMBIGUITY_THRESHOLD
|
|
76
|
+
|
|
77
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
78
|
+
findings = []
|
|
79
|
+
for other in context.snapshot.tools:
|
|
80
|
+
if other.name <= tool.name:
|
|
81
|
+
continue
|
|
82
|
+
result = compute_ambiguity(tool, other)
|
|
83
|
+
if result.score < self.threshold or not result.has_missing_distinction():
|
|
84
|
+
continue
|
|
85
|
+
missing = []
|
|
86
|
+
if result.evidence.absent_exact_vs_search_distinction:
|
|
87
|
+
missing.append("exact-vs-search")
|
|
88
|
+
if result.evidence.absent_one_vs_many_distinction:
|
|
89
|
+
missing.append("one-vs-many")
|
|
90
|
+
if result.evidence.absent_read_vs_write_distinction:
|
|
91
|
+
missing.append("read-vs-write")
|
|
92
|
+
findings.append(
|
|
93
|
+
Finding(
|
|
94
|
+
rule_id=self.id,
|
|
95
|
+
severity=self.default_severity,
|
|
96
|
+
message=(
|
|
97
|
+
f"Tools '{tool.name}' and '{other.name}' don't state a "
|
|
98
|
+
f"{'/'.join(missing)} distinction."
|
|
99
|
+
),
|
|
100
|
+
evidence=f"missing distinction(s): {', '.join(missing)}",
|
|
101
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
102
|
+
remediation=(
|
|
103
|
+
f'Add a sentence like "Use {tool.name} when ... Use {other.name} '
|
|
104
|
+
f'when ..." to both descriptions.'
|
|
105
|
+
),
|
|
106
|
+
confidence=0.5,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return findings
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The Rule contract every deterministic and plugin rule implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
from mcplint.models.contracts import ToolContract
|
|
11
|
+
from mcplint.models.findings import Finding, RuleMetadata, Severity
|
|
12
|
+
from mcplint.models.snapshot import MCPServerSnapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RuleContext(BaseModel):
|
|
16
|
+
model_config = ConfigDict(frozen=True)
|
|
17
|
+
|
|
18
|
+
snapshot: MCPServerSnapshot
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Rule(ABC):
|
|
22
|
+
id: ClassVar[str]
|
|
23
|
+
title: ClassVar[str]
|
|
24
|
+
description: ClassVar[str]
|
|
25
|
+
default_severity: ClassVar[Severity]
|
|
26
|
+
tags: ClassVar[tuple[str, ...]] = ()
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]: ...
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def metadata(cls) -> RuleMetadata:
|
|
33
|
+
return RuleMetadata(
|
|
34
|
+
id=cls.id,
|
|
35
|
+
title=cls.title,
|
|
36
|
+
description=cls.description,
|
|
37
|
+
default_severity=cls.default_severity,
|
|
38
|
+
tags=list(cls.tags),
|
|
39
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Registry of all built-in rule classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcplint.core.rules.ambiguity_rules import AmbiguousToolOverlapRule, MissingToolDistinctionRule
|
|
6
|
+
from mcplint.core.rules.base import Rule
|
|
7
|
+
from mcplint.core.rules.completeness_rules import (
|
|
8
|
+
ExcessiveDescriptionLengthRule,
|
|
9
|
+
MissingReturnSemanticsRule,
|
|
10
|
+
UndefinedDomainTermRule,
|
|
11
|
+
UndocumentedErrorBehaviourRule,
|
|
12
|
+
UndocumentedRequiredConstraintRule,
|
|
13
|
+
)
|
|
14
|
+
from mcplint.core.rules.description_rules import (
|
|
15
|
+
DescriptionRepeatsNameRule,
|
|
16
|
+
MissingToolDescriptionRule,
|
|
17
|
+
VagueToolDescriptionRule,
|
|
18
|
+
)
|
|
19
|
+
from mcplint.core.rules.safety_rules import (
|
|
20
|
+
DestructiveToolWithoutWarningRule,
|
|
21
|
+
StateChangingToolMarkedReadOnlyRule,
|
|
22
|
+
ToolNameActionConflictRule,
|
|
23
|
+
)
|
|
24
|
+
from mcplint.core.rules.schema_rules import (
|
|
25
|
+
MissingParameterDescriptionRule,
|
|
26
|
+
SchemaDescriptionTypeConflictRule,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
BUILTIN_RULES: list[type[Rule]] = [
|
|
30
|
+
MissingToolDescriptionRule,
|
|
31
|
+
DescriptionRepeatsNameRule,
|
|
32
|
+
VagueToolDescriptionRule,
|
|
33
|
+
MissingParameterDescriptionRule,
|
|
34
|
+
MissingReturnSemanticsRule,
|
|
35
|
+
UndocumentedErrorBehaviourRule,
|
|
36
|
+
UndocumentedRequiredConstraintRule,
|
|
37
|
+
SchemaDescriptionTypeConflictRule,
|
|
38
|
+
ToolNameActionConflictRule,
|
|
39
|
+
DestructiveToolWithoutWarningRule,
|
|
40
|
+
StateChangingToolMarkedReadOnlyRule,
|
|
41
|
+
AmbiguousToolOverlapRule,
|
|
42
|
+
MissingToolDistinctionRule,
|
|
43
|
+
ExcessiveDescriptionLengthRule,
|
|
44
|
+
UndefinedDomainTermRule,
|
|
45
|
+
]
|