mcplint-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. mcplint/__about__.py +1 -0
  2. mcplint/__init__.py +3 -0
  3. mcplint/benchmark/__init__.py +0 -0
  4. mcplint/benchmark/dataset.py +34 -0
  5. mcplint/benchmark/providers/__init__.py +0 -0
  6. mcplint/benchmark/providers/anthropic_provider.py +92 -0
  7. mcplint/benchmark/providers/base.py +26 -0
  8. mcplint/benchmark/providers/factory.py +51 -0
  9. mcplint/benchmark/providers/fake.py +26 -0
  10. mcplint/benchmark/providers/openai_provider.py +25 -0
  11. mcplint/benchmark/runner.py +43 -0
  12. mcplint/benchmark/scorer.py +147 -0
  13. mcplint/cli/__init__.py +0 -0
  14. mcplint/cli/commands/__init__.py +0 -0
  15. mcplint/cli/commands/benchmark_cmd.py +88 -0
  16. mcplint/cli/commands/compare_cmd.py +139 -0
  17. mcplint/cli/commands/fix_cmd.py +60 -0
  18. mcplint/cli/commands/inspect_cmd.py +44 -0
  19. mcplint/cli/commands/rules_cmd.py +30 -0
  20. mcplint/cli/commands/scan_cmd.py +113 -0
  21. mcplint/cli/commands/snapshot_cmd.py +33 -0
  22. mcplint/cli/main.py +49 -0
  23. mcplint/compare/__init__.py +0 -0
  24. mcplint/compare/differ.py +148 -0
  25. mcplint/config/__init__.py +0 -0
  26. mcplint/config/loader.py +36 -0
  27. mcplint/config/schema.py +40 -0
  28. mcplint/core/__init__.py +0 -0
  29. mcplint/core/engine.py +40 -0
  30. mcplint/core/registry.py +52 -0
  31. mcplint/core/rules/__init__.py +0 -0
  32. mcplint/core/rules/ambiguity.py +157 -0
  33. mcplint/core/rules/ambiguity_rules.py +109 -0
  34. mcplint/core/rules/base.py +39 -0
  35. mcplint/core/rules/builtin.py +45 -0
  36. mcplint/core/rules/completeness_rules.py +217 -0
  37. mcplint/core/rules/description_rules.py +110 -0
  38. mcplint/core/rules/safety_rules.py +139 -0
  39. mcplint/core/rules/schema_rules.py +101 -0
  40. mcplint/core/score.py +132 -0
  41. mcplint/fix/__init__.py +0 -0
  42. mcplint/fix/suggest.py +183 -0
  43. mcplint/mcp_client/__init__.py +0 -0
  44. mcplint/mcp_client/canonical.py +25 -0
  45. mcplint/mcp_client/persistence.py +17 -0
  46. mcplint/mcp_client/session.py +80 -0
  47. mcplint/mcp_client/stdio.py +17 -0
  48. mcplint/models/__init__.py +0 -0
  49. mcplint/models/benchmark.py +67 -0
  50. mcplint/models/common.py +23 -0
  51. mcplint/models/comparison.py +53 -0
  52. mcplint/models/contracts.py +39 -0
  53. mcplint/models/findings.py +44 -0
  54. mcplint/models/fixes.py +13 -0
  55. mcplint/models/score.py +25 -0
  56. mcplint/models/snapshot.py +27 -0
  57. mcplint/py.typed +0 -0
  58. mcplint/reporters/__init__.py +0 -0
  59. mcplint/reporters/benchmark_terminal.py +46 -0
  60. mcplint/reporters/comparison_terminal.py +61 -0
  61. mcplint/reporters/fix_markdown.py +34 -0
  62. mcplint/reporters/html.py +71 -0
  63. mcplint/reporters/json_reporter.py +9 -0
  64. mcplint/reporters/sarif.py +77 -0
  65. mcplint/reporters/templates/report.html.j2 +176 -0
  66. mcplint/reporters/terminal.py +52 -0
  67. mcplint_cli-0.1.0.dist-info/METADATA +392 -0
  68. mcplint_cli-0.1.0.dist-info/RECORD +71 -0
  69. mcplint_cli-0.1.0.dist-info/WHEEL +4 -0
  70. mcplint_cli-0.1.0.dist-info/entry_points.txt +2 -0
  71. mcplint_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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