mcplint-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcplint/__about__.py +1 -0
- mcplint/__init__.py +3 -0
- mcplint/benchmark/__init__.py +0 -0
- mcplint/benchmark/dataset.py +34 -0
- mcplint/benchmark/providers/__init__.py +0 -0
- mcplint/benchmark/providers/anthropic_provider.py +92 -0
- mcplint/benchmark/providers/base.py +26 -0
- mcplint/benchmark/providers/factory.py +51 -0
- mcplint/benchmark/providers/fake.py +26 -0
- mcplint/benchmark/providers/openai_provider.py +25 -0
- mcplint/benchmark/runner.py +43 -0
- mcplint/benchmark/scorer.py +147 -0
- mcplint/cli/__init__.py +0 -0
- mcplint/cli/commands/__init__.py +0 -0
- mcplint/cli/commands/benchmark_cmd.py +88 -0
- mcplint/cli/commands/compare_cmd.py +139 -0
- mcplint/cli/commands/fix_cmd.py +60 -0
- mcplint/cli/commands/inspect_cmd.py +44 -0
- mcplint/cli/commands/rules_cmd.py +30 -0
- mcplint/cli/commands/scan_cmd.py +113 -0
- mcplint/cli/commands/snapshot_cmd.py +33 -0
- mcplint/cli/main.py +49 -0
- mcplint/compare/__init__.py +0 -0
- mcplint/compare/differ.py +148 -0
- mcplint/config/__init__.py +0 -0
- mcplint/config/loader.py +36 -0
- mcplint/config/schema.py +40 -0
- mcplint/core/__init__.py +0 -0
- mcplint/core/engine.py +40 -0
- mcplint/core/registry.py +52 -0
- mcplint/core/rules/__init__.py +0 -0
- mcplint/core/rules/ambiguity.py +157 -0
- mcplint/core/rules/ambiguity_rules.py +109 -0
- mcplint/core/rules/base.py +39 -0
- mcplint/core/rules/builtin.py +45 -0
- mcplint/core/rules/completeness_rules.py +217 -0
- mcplint/core/rules/description_rules.py +110 -0
- mcplint/core/rules/safety_rules.py +139 -0
- mcplint/core/rules/schema_rules.py +101 -0
- mcplint/core/score.py +132 -0
- mcplint/fix/__init__.py +0 -0
- mcplint/fix/suggest.py +183 -0
- mcplint/mcp_client/__init__.py +0 -0
- mcplint/mcp_client/canonical.py +25 -0
- mcplint/mcp_client/persistence.py +17 -0
- mcplint/mcp_client/session.py +80 -0
- mcplint/mcp_client/stdio.py +17 -0
- mcplint/models/__init__.py +0 -0
- mcplint/models/benchmark.py +67 -0
- mcplint/models/common.py +23 -0
- mcplint/models/comparison.py +53 -0
- mcplint/models/contracts.py +39 -0
- mcplint/models/findings.py +44 -0
- mcplint/models/fixes.py +13 -0
- mcplint/models/score.py +25 -0
- mcplint/models/snapshot.py +27 -0
- mcplint/py.typed +0 -0
- mcplint/reporters/__init__.py +0 -0
- mcplint/reporters/benchmark_terminal.py +46 -0
- mcplint/reporters/comparison_terminal.py +61 -0
- mcplint/reporters/fix_markdown.py +34 -0
- mcplint/reporters/html.py +71 -0
- mcplint/reporters/json_reporter.py +9 -0
- mcplint/reporters/sarif.py +77 -0
- mcplint/reporters/templates/report.html.j2 +176 -0
- mcplint/reporters/terminal.py +52 -0
- mcplint_cli-0.1.0.dist-info/METADATA +392 -0
- mcplint_cli-0.1.0.dist-info/RECORD +71 -0
- mcplint_cli-0.1.0.dist-info/WHEEL +4 -0
- mcplint_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mcplint_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Rules that flag gaps in what a tool's description documents.
|
|
2
|
+
|
|
3
|
+
`excessive-description-length`'s threshold is a module default; Phase 3's
|
|
4
|
+
configuration loader overrides it via `mcplint.yaml` (`thresholds.max_description_characters`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from mcplint.core.rules.base import Rule, RuleContext
|
|
12
|
+
from mcplint.models.contracts import SourceLocation, ToolContract
|
|
13
|
+
from mcplint.models.findings import Finding, Severity
|
|
14
|
+
|
|
15
|
+
DEFAULT_MAX_DESCRIPTION_CHARACTERS = 800
|
|
16
|
+
|
|
17
|
+
_RETURN_HINTS = re.compile(r"\b(returns?|returning|output|result|response)\b", re.IGNORECASE)
|
|
18
|
+
_ERROR_HINTS = re.compile(
|
|
19
|
+
r"\b(error|fail|failure|exception|raise|raises|throws|invalid|not found)\b", re.IGNORECASE
|
|
20
|
+
)
|
|
21
|
+
_CONSTRAINT_KEYS = ("minimum", "maximum", "minLength", "maxLength", "pattern", "enum")
|
|
22
|
+
_WELL_KNOWN_ACRONYMS = {
|
|
23
|
+
"ID",
|
|
24
|
+
"URL",
|
|
25
|
+
"URI",
|
|
26
|
+
"API",
|
|
27
|
+
"JSON",
|
|
28
|
+
"XML",
|
|
29
|
+
"HTTP",
|
|
30
|
+
"HTTPS",
|
|
31
|
+
"UUID",
|
|
32
|
+
"SQL",
|
|
33
|
+
"CSV",
|
|
34
|
+
"PDF",
|
|
35
|
+
"UTC",
|
|
36
|
+
"OK",
|
|
37
|
+
}
|
|
38
|
+
_ACRONYM_PATTERN = re.compile(r"\b[A-Z]{2,6}\b")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MissingReturnSemanticsRule(Rule):
|
|
42
|
+
id = "missing-return-semantics"
|
|
43
|
+
title = "Missing return semantics"
|
|
44
|
+
description = (
|
|
45
|
+
"Flags tools with no outputSchema whose description also never explains "
|
|
46
|
+
"what the tool returns."
|
|
47
|
+
)
|
|
48
|
+
default_severity = Severity.WARNING
|
|
49
|
+
tags = ("completeness",)
|
|
50
|
+
|
|
51
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
52
|
+
if tool.output_schema is not None:
|
|
53
|
+
return []
|
|
54
|
+
if tool.description and _RETURN_HINTS.search(tool.description):
|
|
55
|
+
return []
|
|
56
|
+
return [
|
|
57
|
+
Finding(
|
|
58
|
+
rule_id=self.id,
|
|
59
|
+
severity=self.default_severity,
|
|
60
|
+
message=f"Tool '{tool.name}' does not document what it returns.",
|
|
61
|
+
evidence="no outputSchema and no return-related wording in the description",
|
|
62
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.outputSchema"),
|
|
63
|
+
remediation=(
|
|
64
|
+
"Add an outputSchema, or describe the shape and meaning of the "
|
|
65
|
+
"tool's return value in its description."
|
|
66
|
+
),
|
|
67
|
+
confidence=0.6,
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class UndocumentedErrorBehaviourRule(Rule):
|
|
73
|
+
id = "undocumented-error-behaviour"
|
|
74
|
+
title = "Undocumented error behaviour"
|
|
75
|
+
description = "Flags tools whose description never mentions failure or error conditions."
|
|
76
|
+
default_severity = Severity.INFO
|
|
77
|
+
tags = ("completeness",)
|
|
78
|
+
|
|
79
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
80
|
+
if not tool.description or not tool.description.strip():
|
|
81
|
+
return []
|
|
82
|
+
if _ERROR_HINTS.search(tool.description):
|
|
83
|
+
return []
|
|
84
|
+
return [
|
|
85
|
+
Finding(
|
|
86
|
+
rule_id=self.id,
|
|
87
|
+
severity=self.default_severity,
|
|
88
|
+
message=f"Tool '{tool.name}' does not document its error behaviour.",
|
|
89
|
+
evidence="no error/failure/exception wording found in the description",
|
|
90
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
91
|
+
remediation=(
|
|
92
|
+
"Document how the tool signals failure: what happens on an "
|
|
93
|
+
"invalid input, a not-found lookup, or an internal error."
|
|
94
|
+
),
|
|
95
|
+
confidence=0.5,
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class UndocumentedRequiredConstraintRule(Rule):
|
|
101
|
+
id = "undocumented-required-constraint"
|
|
102
|
+
title = "Undocumented required-parameter constraint"
|
|
103
|
+
description = (
|
|
104
|
+
"Flags required parameters whose JSON Schema constraints (enum, min/max, "
|
|
105
|
+
"pattern, length) are not mentioned in the parameter description."
|
|
106
|
+
)
|
|
107
|
+
default_severity = Severity.WARNING
|
|
108
|
+
tags = ("completeness", "schema")
|
|
109
|
+
|
|
110
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
111
|
+
findings = []
|
|
112
|
+
for param in tool.parameters:
|
|
113
|
+
if not param.required or not param.description:
|
|
114
|
+
continue
|
|
115
|
+
description_lower = param.description.lower()
|
|
116
|
+
for key in _CONSTRAINT_KEYS:
|
|
117
|
+
if key not in param.json_schema:
|
|
118
|
+
continue
|
|
119
|
+
value = param.json_schema[key]
|
|
120
|
+
if self._is_documented(value, description_lower):
|
|
121
|
+
continue
|
|
122
|
+
findings.append(
|
|
123
|
+
Finding(
|
|
124
|
+
rule_id=self.id,
|
|
125
|
+
severity=self.default_severity,
|
|
126
|
+
message=(
|
|
127
|
+
f"Parameter '{param.name}' on tool '{tool.name}' has a "
|
|
128
|
+
f"'{key}' constraint not reflected in its description."
|
|
129
|
+
),
|
|
130
|
+
evidence=f"schema {key}={value!r}, not mentioned in description",
|
|
131
|
+
location=SourceLocation(
|
|
132
|
+
tool_name=tool.name,
|
|
133
|
+
json_path=f"$.inputSchema.properties.{param.name}.{key}",
|
|
134
|
+
),
|
|
135
|
+
remediation=(
|
|
136
|
+
f"Mention the '{key}' constraint ({value!r}) in the "
|
|
137
|
+
f"description of '{param.name}'."
|
|
138
|
+
),
|
|
139
|
+
confidence=0.6,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return findings
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _is_documented(value: object, description_lower: str) -> bool:
|
|
146
|
+
if isinstance(value, list):
|
|
147
|
+
return any(str(item).lower() in description_lower for item in value)
|
|
148
|
+
return str(value).lower() in description_lower
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ExcessiveDescriptionLengthRule(Rule):
|
|
152
|
+
id = "excessive-description-length"
|
|
153
|
+
title = "Excessive description length"
|
|
154
|
+
description = (
|
|
155
|
+
f"Flags descriptions longer than {DEFAULT_MAX_DESCRIPTION_CHARACTERS} characters, "
|
|
156
|
+
"which risk burning context and burying the useful details."
|
|
157
|
+
)
|
|
158
|
+
default_severity = Severity.INFO
|
|
159
|
+
tags = ("description",)
|
|
160
|
+
max_characters = DEFAULT_MAX_DESCRIPTION_CHARACTERS
|
|
161
|
+
|
|
162
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
163
|
+
if not tool.description or len(tool.description) <= self.max_characters:
|
|
164
|
+
return []
|
|
165
|
+
return [
|
|
166
|
+
Finding(
|
|
167
|
+
rule_id=self.id,
|
|
168
|
+
severity=self.default_severity,
|
|
169
|
+
message=(
|
|
170
|
+
f"Tool '{tool.name}' description is {len(tool.description)} characters, "
|
|
171
|
+
f"over the {self.max_characters}-character guideline."
|
|
172
|
+
),
|
|
173
|
+
evidence=f"description length: {len(tool.description)}",
|
|
174
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
175
|
+
remediation=(
|
|
176
|
+
"Trim the description to the essential behaviour, inputs, and distinctions."
|
|
177
|
+
),
|
|
178
|
+
confidence=0.8,
|
|
179
|
+
)
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class UndefinedDomainTermRule(Rule):
|
|
184
|
+
id = "undefined-domain-term"
|
|
185
|
+
title = "Undefined domain term"
|
|
186
|
+
description = (
|
|
187
|
+
"Flags acronyms or domain-specific terms used in a description without "
|
|
188
|
+
"being defined nearby, which an agent outside the domain may misread."
|
|
189
|
+
)
|
|
190
|
+
default_severity = Severity.INFO
|
|
191
|
+
tags = ("description",)
|
|
192
|
+
|
|
193
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
194
|
+
if not tool.description:
|
|
195
|
+
return []
|
|
196
|
+
findings = []
|
|
197
|
+
seen: set[str] = set()
|
|
198
|
+
for match in _ACRONYM_PATTERN.finditer(tool.description):
|
|
199
|
+
term = match.group(0)
|
|
200
|
+
if term in _WELL_KNOWN_ACRONYMS or term in seen:
|
|
201
|
+
continue
|
|
202
|
+
following = tool.description[match.end() : match.end() + 2]
|
|
203
|
+
if following.startswith(" (") or following.startswith("("):
|
|
204
|
+
continue
|
|
205
|
+
seen.add(term)
|
|
206
|
+
findings.append(
|
|
207
|
+
Finding(
|
|
208
|
+
rule_id=self.id,
|
|
209
|
+
severity=self.default_severity,
|
|
210
|
+
message=f"Tool '{tool.name}' description uses undefined term '{term}'.",
|
|
211
|
+
evidence=f"'{term}' appears without a nearby definition",
|
|
212
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
213
|
+
remediation=f"Define '{term}' on first use, e.g. '{term} (...)'.",
|
|
214
|
+
confidence=0.4,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
return findings
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Rules that judge a tool's top-level description text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mcplint.core.rules.base import Rule, RuleContext
|
|
8
|
+
from mcplint.models.contracts import SourceLocation, ToolContract
|
|
9
|
+
from mcplint.models.findings import Finding, Severity
|
|
10
|
+
|
|
11
|
+
MIN_DESCRIPTION_WORDS = 4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize_words(text: str) -> list[str]:
|
|
15
|
+
cleaned = re.sub(r"[^a-z0-9\s]", " ", text.lower())
|
|
16
|
+
return [word for word in cleaned.split() if word]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MissingToolDescriptionRule(Rule):
|
|
20
|
+
id = "missing-tool-description"
|
|
21
|
+
title = "Missing tool description"
|
|
22
|
+
description = "Flags tools with no description or a whitespace-only description."
|
|
23
|
+
default_severity = Severity.ERROR
|
|
24
|
+
tags = ("description",)
|
|
25
|
+
|
|
26
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
27
|
+
if tool.description is not None and tool.description.strip():
|
|
28
|
+
return []
|
|
29
|
+
return [
|
|
30
|
+
Finding(
|
|
31
|
+
rule_id=self.id,
|
|
32
|
+
severity=self.default_severity,
|
|
33
|
+
message=f"Tool '{tool.name}' has no description.",
|
|
34
|
+
evidence="description is missing or blank",
|
|
35
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
36
|
+
remediation=(
|
|
37
|
+
"Add a description explaining what the tool does, its inputs, "
|
|
38
|
+
"and when an agent should choose it over similar tools."
|
|
39
|
+
),
|
|
40
|
+
confidence=1.0,
|
|
41
|
+
)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DescriptionRepeatsNameRule(Rule):
|
|
46
|
+
id = "description-repeats-name"
|
|
47
|
+
title = "Description repeats the tool name"
|
|
48
|
+
description = (
|
|
49
|
+
"Flags descriptions that only restate the tool name in words, adding no "
|
|
50
|
+
"information beyond what the name already conveys."
|
|
51
|
+
)
|
|
52
|
+
default_severity = Severity.WARNING
|
|
53
|
+
tags = ("description",)
|
|
54
|
+
|
|
55
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
56
|
+
if not tool.description or not tool.description.strip():
|
|
57
|
+
return []
|
|
58
|
+
name_words = _normalize_words(tool.name.replace("_", " ").replace("-", " "))
|
|
59
|
+
description_words = _normalize_words(tool.description)
|
|
60
|
+
if description_words != name_words:
|
|
61
|
+
return []
|
|
62
|
+
return [
|
|
63
|
+
Finding(
|
|
64
|
+
rule_id=self.id,
|
|
65
|
+
severity=self.default_severity,
|
|
66
|
+
message=f"Tool '{tool.name}' description only restates its name.",
|
|
67
|
+
evidence=(
|
|
68
|
+
f"description '{tool.description}' normalizes to the same words "
|
|
69
|
+
"as the tool name"
|
|
70
|
+
),
|
|
71
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
72
|
+
remediation=(
|
|
73
|
+
"Explain what the tool actually does: inputs, output shape, "
|
|
74
|
+
"side effects, and when to prefer it over similar tools."
|
|
75
|
+
),
|
|
76
|
+
confidence=0.9,
|
|
77
|
+
)
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class VagueToolDescriptionRule(Rule):
|
|
82
|
+
id = "vague-tool-description"
|
|
83
|
+
title = "Vague tool description"
|
|
84
|
+
description = (
|
|
85
|
+
f"Flags descriptions shorter than {MIN_DESCRIPTION_WORDS} words as likely too "
|
|
86
|
+
"vague to disambiguate tool choice."
|
|
87
|
+
)
|
|
88
|
+
default_severity = Severity.WARNING
|
|
89
|
+
tags = ("description",)
|
|
90
|
+
|
|
91
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
92
|
+
if not tool.description or not tool.description.strip():
|
|
93
|
+
return []
|
|
94
|
+
words = _normalize_words(tool.description)
|
|
95
|
+
if len(words) >= MIN_DESCRIPTION_WORDS:
|
|
96
|
+
return []
|
|
97
|
+
return [
|
|
98
|
+
Finding(
|
|
99
|
+
rule_id=self.id,
|
|
100
|
+
severity=self.default_severity,
|
|
101
|
+
message=f"Tool '{tool.name}' description is very short ({len(words)} words).",
|
|
102
|
+
evidence=f"description: '{tool.description}'",
|
|
103
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
104
|
+
remediation=(
|
|
105
|
+
"Expand the description with the specific action, inputs, and "
|
|
106
|
+
"when an agent should use this tool versus alternatives."
|
|
107
|
+
),
|
|
108
|
+
confidence=0.7,
|
|
109
|
+
)
|
|
110
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Rules that check a tool's name and annotations agree about how dangerous it is."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mcplint.core.rules.base import Rule, RuleContext
|
|
8
|
+
from mcplint.models.contracts import SourceLocation, ToolContract
|
|
9
|
+
from mcplint.models.findings import Finding, Severity
|
|
10
|
+
|
|
11
|
+
READ_VERBS = frozenset({"get", "list", "search", "find", "fetch", "query", "view", "show", "read"})
|
|
12
|
+
WRITE_VERBS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"create",
|
|
15
|
+
"update",
|
|
16
|
+
"delete",
|
|
17
|
+
"remove",
|
|
18
|
+
"set",
|
|
19
|
+
"add",
|
|
20
|
+
"modify",
|
|
21
|
+
"edit",
|
|
22
|
+
"insert",
|
|
23
|
+
"upsert",
|
|
24
|
+
"patch",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
_DESTRUCTIVE_WARNING_HINTS = re.compile(
|
|
28
|
+
r"\b(permanent|permanently|irreversible|cannot be undone|cannot be reversed|destructive)\b",
|
|
29
|
+
re.IGNORECASE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def first_word(name: str) -> str:
|
|
34
|
+
return re.split(r"[_\-]", name, maxsplit=1)[0].lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolNameActionConflictRule(Rule):
|
|
38
|
+
id = "tool-name-action-conflict"
|
|
39
|
+
title = "Tool name conflicts with its destructive annotation"
|
|
40
|
+
description = (
|
|
41
|
+
"Flags tools whose name reads as a read-only action (get/list/search/...) "
|
|
42
|
+
"but which are annotated as destructive."
|
|
43
|
+
)
|
|
44
|
+
default_severity = Severity.ERROR
|
|
45
|
+
tags = ("safety",)
|
|
46
|
+
|
|
47
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
48
|
+
if first_word(tool.name) not in READ_VERBS:
|
|
49
|
+
return []
|
|
50
|
+
if not tool.annotations.destructive_hint:
|
|
51
|
+
return []
|
|
52
|
+
return [
|
|
53
|
+
Finding(
|
|
54
|
+
rule_id=self.id,
|
|
55
|
+
severity=self.default_severity,
|
|
56
|
+
message=(
|
|
57
|
+
f"Tool '{tool.name}' reads as a read-only action but is annotated destructive."
|
|
58
|
+
),
|
|
59
|
+
evidence=(
|
|
60
|
+
f"name starts with a read verb ('{first_word(tool.name)}') "
|
|
61
|
+
"but annotations.destructiveHint is true"
|
|
62
|
+
),
|
|
63
|
+
location=SourceLocation(
|
|
64
|
+
tool_name=tool.name, json_path="$.annotations.destructiveHint"
|
|
65
|
+
),
|
|
66
|
+
remediation=(
|
|
67
|
+
"Rename the tool to reflect its real effect, or correct the "
|
|
68
|
+
"destructive annotation if the tool is genuinely read-only."
|
|
69
|
+
),
|
|
70
|
+
confidence=0.7,
|
|
71
|
+
)
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class DestructiveToolWithoutWarningRule(Rule):
|
|
76
|
+
id = "destructive-tool-without-warning"
|
|
77
|
+
title = "Destructive tool without a warning"
|
|
78
|
+
description = (
|
|
79
|
+
"Flags tools annotated destructive whose description does not warn that "
|
|
80
|
+
"the action is permanent or irreversible."
|
|
81
|
+
)
|
|
82
|
+
default_severity = Severity.ERROR
|
|
83
|
+
tags = ("safety",)
|
|
84
|
+
|
|
85
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
86
|
+
if not tool.annotations.destructive_hint:
|
|
87
|
+
return []
|
|
88
|
+
if tool.description and _DESTRUCTIVE_WARNING_HINTS.search(tool.description):
|
|
89
|
+
return []
|
|
90
|
+
return [
|
|
91
|
+
Finding(
|
|
92
|
+
rule_id=self.id,
|
|
93
|
+
severity=self.default_severity,
|
|
94
|
+
message=f"Tool '{tool.name}' is destructive but its description has no warning.",
|
|
95
|
+
evidence="annotations.destructiveHint is true, no warning wording in description",
|
|
96
|
+
location=SourceLocation(tool_name=tool.name, json_path="$.description"),
|
|
97
|
+
remediation=(
|
|
98
|
+
"State plainly that the action is permanent/irreversible and "
|
|
99
|
+
"cannot be undone, so an agent hesitates before calling it."
|
|
100
|
+
),
|
|
101
|
+
confidence=0.8,
|
|
102
|
+
)
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class StateChangingToolMarkedReadOnlyRule(Rule):
|
|
107
|
+
id = "state-changing-tool-marked-read-only"
|
|
108
|
+
title = "State-changing tool marked read-only"
|
|
109
|
+
description = (
|
|
110
|
+
"Flags tools whose name reads as a mutation (create/update/delete/...) "
|
|
111
|
+
"but which are annotated readOnlyHint=true."
|
|
112
|
+
)
|
|
113
|
+
default_severity = Severity.ERROR
|
|
114
|
+
tags = ("safety",)
|
|
115
|
+
|
|
116
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
117
|
+
if first_word(tool.name) not in WRITE_VERBS:
|
|
118
|
+
return []
|
|
119
|
+
if tool.annotations.read_only_hint is not True:
|
|
120
|
+
return []
|
|
121
|
+
return [
|
|
122
|
+
Finding(
|
|
123
|
+
rule_id=self.id,
|
|
124
|
+
severity=self.default_severity,
|
|
125
|
+
message=f"Tool '{tool.name}' changes state but is annotated read-only.",
|
|
126
|
+
evidence=(
|
|
127
|
+
f"name starts with a write verb ('{first_word(tool.name)}') "
|
|
128
|
+
"but annotations.readOnlyHint is true"
|
|
129
|
+
),
|
|
130
|
+
location=SourceLocation(
|
|
131
|
+
tool_name=tool.name, json_path="$.annotations.readOnlyHint"
|
|
132
|
+
),
|
|
133
|
+
remediation=(
|
|
134
|
+
"Correct readOnlyHint to false for this tool, since agents may "
|
|
135
|
+
"rely on it to decide whether a call is safe to retry or sandbox."
|
|
136
|
+
),
|
|
137
|
+
confidence=0.8,
|
|
138
|
+
)
|
|
139
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Rules that cross-check a tool's parameter descriptions against its JSON Schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from mcplint.core.rules.base import Rule, RuleContext
|
|
8
|
+
from mcplint.models.contracts import SourceLocation, ToolContract
|
|
9
|
+
from mcplint.models.findings import Finding, Severity
|
|
10
|
+
|
|
11
|
+
_NUMERIC_HINTS = re.compile(r"\b(number of|count of|quantity of|amount of)\b", re.IGNORECASE)
|
|
12
|
+
_LIST_HINTS = re.compile(r"\b(list of|array of|comma-separated list of)\b", re.IGNORECASE)
|
|
13
|
+
_BOOLEAN_HINTS = re.compile(r"\b(true or false|boolean flag|yes or no)\b", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MissingParameterDescriptionRule(Rule):
|
|
17
|
+
id = "missing-parameter-description"
|
|
18
|
+
title = "Missing parameter description"
|
|
19
|
+
description = "Flags input parameters with no description."
|
|
20
|
+
default_severity = Severity.WARNING
|
|
21
|
+
tags = ("schema",)
|
|
22
|
+
|
|
23
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
24
|
+
findings = []
|
|
25
|
+
for param in tool.parameters:
|
|
26
|
+
if param.description and param.description.strip():
|
|
27
|
+
continue
|
|
28
|
+
findings.append(
|
|
29
|
+
Finding(
|
|
30
|
+
rule_id=self.id,
|
|
31
|
+
severity=self.default_severity,
|
|
32
|
+
message=f"Parameter '{param.name}' on tool '{tool.name}' has no description.",
|
|
33
|
+
evidence="parameter description is missing or blank",
|
|
34
|
+
location=SourceLocation(
|
|
35
|
+
tool_name=tool.name, json_path=f"$.inputSchema.properties.{param.name}"
|
|
36
|
+
),
|
|
37
|
+
remediation=(
|
|
38
|
+
f"Document '{param.name}': its purpose, expected format, and any "
|
|
39
|
+
"constraints not already captured by the schema."
|
|
40
|
+
),
|
|
41
|
+
confidence=1.0,
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return findings
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SchemaDescriptionTypeConflictRule(Rule):
|
|
48
|
+
id = "schema-description-type-conflict"
|
|
49
|
+
title = "Description conflicts with parameter schema type"
|
|
50
|
+
description = (
|
|
51
|
+
"Flags parameters whose description implies a different JSON Schema type "
|
|
52
|
+
"than the one declared (e.g. 'number of X' on a string-typed parameter)."
|
|
53
|
+
)
|
|
54
|
+
default_severity = Severity.ERROR
|
|
55
|
+
tags = ("schema",)
|
|
56
|
+
|
|
57
|
+
def check(self, tool: ToolContract, context: RuleContext) -> list[Finding]:
|
|
58
|
+
findings = []
|
|
59
|
+
for param in tool.parameters:
|
|
60
|
+
if not param.description or not param.description.strip():
|
|
61
|
+
continue
|
|
62
|
+
declared_type = param.json_schema.get("type")
|
|
63
|
+
conflict = self._detect_conflict(param.description, declared_type)
|
|
64
|
+
if conflict is None:
|
|
65
|
+
continue
|
|
66
|
+
expected_type, hint = conflict
|
|
67
|
+
findings.append(
|
|
68
|
+
Finding(
|
|
69
|
+
rule_id=self.id,
|
|
70
|
+
severity=self.default_severity,
|
|
71
|
+
message=(
|
|
72
|
+
f"Parameter '{param.name}' on tool '{tool.name}' reads as "
|
|
73
|
+
f"{expected_type!r} but the schema declares type {declared_type!r}."
|
|
74
|
+
),
|
|
75
|
+
evidence=(
|
|
76
|
+
f"description matched pattern '{hint}', schema type is {declared_type!r}"
|
|
77
|
+
),
|
|
78
|
+
location=SourceLocation(
|
|
79
|
+
tool_name=tool.name, json_path=f"$.inputSchema.properties.{param.name}.type"
|
|
80
|
+
),
|
|
81
|
+
remediation=(
|
|
82
|
+
f"Either change the schema type to {expected_type!r} or rewrite the "
|
|
83
|
+
"description so it matches the declared type."
|
|
84
|
+
),
|
|
85
|
+
confidence=0.6,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _detect_conflict(description: str, declared_type: object) -> tuple[str, str] | None:
|
|
92
|
+
numeric_match = _NUMERIC_HINTS.search(description)
|
|
93
|
+
if numeric_match and declared_type not in ("integer", "number"):
|
|
94
|
+
return "integer", numeric_match.group(0)
|
|
95
|
+
list_match = _LIST_HINTS.search(description)
|
|
96
|
+
if list_match and declared_type != "array":
|
|
97
|
+
return "array", list_match.group(0)
|
|
98
|
+
boolean_match = _BOOLEAN_HINTS.search(description)
|
|
99
|
+
if boolean_match and declared_type != "boolean":
|
|
100
|
+
return "boolean", boolean_match.group(0)
|
|
101
|
+
return None
|