agentcheckup 0.4.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.
agentcheckup/models.py ADDED
@@ -0,0 +1,166 @@
1
+ """Core data model for agentcheckup.
2
+
3
+ Everything here is deliberately dependency-free so the package keeps working
4
+ across MCP spec revisions without chasing an SDK release.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+
14
+ class Severity(str, Enum):
15
+ ERROR = "error"
16
+ WARNING = "warning"
17
+ INFO = "info"
18
+
19
+ @property
20
+ def rank(self) -> int:
21
+ return {"error": 3, "warning": 2, "info": 1}[self.value]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Finding:
26
+ """A single lint result.
27
+
28
+ ``rule`` is a stable id (AGT0xx) so CI configs and suppressions never break
29
+ when wording changes.
30
+ """
31
+
32
+ rule: str
33
+ severity: Severity
34
+ message: str
35
+ tool: str | None = None
36
+ hint: str | None = None
37
+ where: str | None = None
38
+
39
+ def as_dict(self) -> dict[str, Any]:
40
+ out: dict[str, Any] = {
41
+ "rule": self.rule,
42
+ "severity": self.severity.value,
43
+ "message": self.message,
44
+ }
45
+ if self.tool:
46
+ out["tool"] = self.tool
47
+ if self.hint:
48
+ out["hint"] = self.hint
49
+ if self.where:
50
+ out["where"] = self.where
51
+ return out
52
+
53
+
54
+ @dataclass
55
+ class Tool:
56
+ """One entry from ``tools/list``."""
57
+
58
+ name: str
59
+ description: str = ""
60
+ input_schema: dict[str, Any] = field(default_factory=dict)
61
+ annotations: dict[str, Any] = field(default_factory=dict)
62
+ raw: dict[str, Any] = field(default_factory=dict)
63
+
64
+ @classmethod
65
+ def from_raw(cls, data: dict[str, Any]) -> Tool:
66
+ schema = data.get("inputSchema") or data.get("input_schema") or {}
67
+ if not isinstance(schema, dict):
68
+ schema = {}
69
+ annotations = data.get("annotations") or {}
70
+ if not isinstance(annotations, dict):
71
+ annotations = {}
72
+ description = data.get("description") or ""
73
+ if not isinstance(description, str):
74
+ description = str(description)
75
+ return cls(
76
+ name=str(data.get("name", "")),
77
+ description=description,
78
+ input_schema=schema,
79
+ annotations=annotations,
80
+ raw=data,
81
+ )
82
+
83
+ def properties(self) -> dict[str, Any]:
84
+ props = self.input_schema.get("properties")
85
+ return props if isinstance(props, dict) else {}
86
+
87
+ def required(self) -> list[str]:
88
+ req = self.input_schema.get("required")
89
+ if not isinstance(req, list):
90
+ return []
91
+ return [str(x) for x in req]
92
+
93
+
94
+ @dataclass
95
+ class ServerConfig:
96
+ """A server entry lifted from an MCP client config file."""
97
+
98
+ name: str
99
+ command: str | None = None
100
+ args: list[str] = field(default_factory=list)
101
+ env: dict[str, str] = field(default_factory=dict)
102
+ url: str | None = None
103
+ source: str | None = None
104
+
105
+ @classmethod
106
+ def from_raw(cls, name: str, data: dict[str, Any], source: str | None = None) -> ServerConfig:
107
+ args = data.get("args") or []
108
+ if not isinstance(args, list):
109
+ args = [str(args)]
110
+ env = data.get("env") or {}
111
+ if not isinstance(env, dict):
112
+ env = {}
113
+ return cls(
114
+ name=name,
115
+ command=data.get("command"),
116
+ args=[str(a) for a in args],
117
+ env={str(k): str(v) for k, v in env.items()},
118
+ url=data.get("url") or data.get("serverUrl"),
119
+ source=source,
120
+ )
121
+
122
+
123
+ def grade_for(score: int) -> str:
124
+ if score >= 90:
125
+ return "A"
126
+ if score >= 80:
127
+ return "B"
128
+ if score >= 70:
129
+ return "C"
130
+ if score >= 60:
131
+ return "D"
132
+ return "F"
133
+
134
+
135
+ #: Points charged against a manifest's budget.
136
+ #:
137
+ #: ``info`` is capped rather than free: a rule that fires on 18 of 18 tools in
138
+ #: the official MCP servers is expressing a preference, but a report full of
139
+ #: nudges should not still earn an A either.
140
+ SEVERITY_WEIGHT = {Severity.ERROR: 10, Severity.WARNING: 3, Severity.INFO: 1}
141
+
142
+ #: Info findings can cost at most this many points in total.
143
+ INFO_CAP = 10
144
+
145
+ #: Points of budget granted per tool.
146
+ BUDGET_PER_TOOL = 20
147
+
148
+
149
+ def score_findings(findings: list[Finding], tool_count: int = 1) -> int:
150
+ """Score 0-100, normalised per tool so a big manifest is not punished for size.
151
+
152
+ Calibrated against four real cases rather than invented: the three official
153
+ MCP servers (18 tools, 12 warnings, 77 nudges) land at 87; a manifest with a
154
+ hard collision, an unpinned package and a plaintext token lands at 79; the
155
+ deliberately broken fixture lands at 35; a clean manifest at 100.
156
+
157
+ A flat subtraction scored the official servers 0/100, which tells the reader
158
+ nothing and makes the tool look broken.
159
+ """
160
+ penalty = sum(SEVERITY_WEIGHT[f.severity] for f in findings)
161
+ infos = sum(1 for f in findings if f.severity is Severity.INFO)
162
+ if infos:
163
+ # Replace the per-finding info charge with the capped one.
164
+ penalty += min(infos, INFO_CAP) - infos
165
+ budget = BUDGET_PER_TOOL * max(tool_count, 1)
166
+ return round(100 * max(0.0, 1 - penalty / budget))
agentcheckup/report.py ADDED
@@ -0,0 +1,274 @@
1
+ """Render a Report as text, JSON, or SARIF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import unicodedata
7
+ from typing import Any
8
+
9
+ from . import rules as _rules
10
+ from .engine import Report
11
+ from .models import Finding, Severity
12
+
13
+ __version__ = "0.4.0"
14
+
15
+ SARIF_LEVEL = {
16
+ Severity.ERROR: "error",
17
+ Severity.WARNING: "warning",
18
+ Severity.INFO: "note",
19
+ }
20
+
21
+ INVISIBLE = frozenset("\u200b\u200c\u200d\u2060\ufeff")
22
+
23
+
24
+ def display(text: str) -> str:
25
+ """Make invisible characters visible in terminal output.
26
+
27
+ A finding about hidden text is useless if the report itself renders it
28
+ invisibly - the reviewer would see an empty-looking name and move on.
29
+ """
30
+ out: list[str] = []
31
+ for ch in text:
32
+ if ch in INVISIBLE or unicodedata.category(ch) == "Cf":
33
+ out.append(f"\\u{ord(ch):04x}")
34
+ else:
35
+ out.append(ch)
36
+ return "".join(out)
37
+
38
+
39
+ def _rule_metadata() -> list[dict[str, Any]]:
40
+ out: list[dict[str, Any]] = []
41
+ for rule in [*_rules.TOOL_RULES, *_rules.SUITE_RULES, *_rules.CONFIG_RULES]:
42
+ out.append(
43
+ {
44
+ "id": rule.id,
45
+ "name": rule.id,
46
+ "shortDescription": {"text": rule.title},
47
+ "defaultConfiguration": {"level": SARIF_LEVEL[rule.severity]},
48
+ }
49
+ )
50
+ return sorted(out, key=lambda r: r["id"])
51
+
52
+
53
+ def _finding_lines(findings: list[Finding], verbose: bool) -> list[str]:
54
+ out: list[str] = []
55
+ for finding in findings:
56
+ target = f" [{display(finding.tool)}]" if finding.tool else ""
57
+ out.append(f" {finding.rule} {finding.message}{target}")
58
+ if finding.hint:
59
+ out.append(f" fix: {finding.hint}")
60
+ if verbose and finding.where:
61
+ out.append(f" at: {finding.where}")
62
+ return out
63
+
64
+
65
+ def rule_title(rule_id: str) -> str:
66
+ for rule in [*_rules.TOOL_RULES, *_rules.SUITE_RULES, *_rules.CONFIG_RULES]:
67
+ if rule.id == rule_id:
68
+ return rule.title
69
+ return ""
70
+
71
+
72
+ def _nudge_lines(findings: list[Finding]) -> list[str]:
73
+ """Collapse info findings into one line per rule.
74
+
75
+ Measured on the three official MCP servers: 68 info findings across 18 tools,
76
+ most from rules that fire on nearly every tool. Printed individually they
77
+ bury the two findings that matter, and a report nobody reads is a report
78
+ nobody acts on.
79
+ """
80
+ by_rule: dict[str, list[Finding]] = {}
81
+ for finding in findings:
82
+ by_rule.setdefault(finding.rule, []).append(finding)
83
+
84
+ out: list[str] = []
85
+ for rule, group in sorted(by_rule.items(), key=lambda kv: (-len(kv[1]), kv[0])):
86
+ title = rule_title(rule) or group[0].message
87
+ out.append(f" {rule} {title} ({len(group)})")
88
+ names = [display(f.tool) for f in group if f.tool]
89
+ if names:
90
+ preview = ", ".join(names[:3])
91
+ if len(names) > 3:
92
+ preview += f", +{len(names) - 3} more"
93
+ out.append(f" {preview}")
94
+ return out
95
+
96
+
97
+ #: A rule that fires on this many tools is one root cause, not N problems.
98
+ AGGREGATE_AT = 5
99
+
100
+
101
+ def _serious_lines(findings: list[Finding], verbose: bool) -> list[str]:
102
+ """Individual findings, except for rules that repeat: those collapse.
103
+
104
+ The official git server produced 12 identical "parameter has no description"
105
+ warnings, one per tool. Twelve near-identical lines hide everything else,
106
+ while one line naming all twelve tools is both shorter and more actionable.
107
+ """
108
+ by_rule: dict[str, list[Finding]] = {}
109
+ for finding in findings:
110
+ by_rule.setdefault(finding.rule, []).append(finding)
111
+
112
+ ordered = sorted(
113
+ by_rule.items(),
114
+ key=lambda kv: (-kv[1][0].severity.rank, -len(kv[1]), kv[0]),
115
+ )
116
+
117
+ out: list[str] = []
118
+ for rule, group in ordered:
119
+ if len(group) < AGGREGATE_AT:
120
+ out.extend(_finding_lines(group, verbose))
121
+ continue
122
+
123
+ title = rule_title(rule) or group[0].message
124
+ out.append(f" {rule} {title} ({len(group)} tools)")
125
+ names = [display(f.tool) for f in group if f.tool]
126
+ if names:
127
+ preview = ", ".join(names[:4])
128
+ if len(names) > 4:
129
+ preview += f", +{len(names) - 4} more"
130
+ out.append(f" {preview}")
131
+ hint = next((f.hint for f in group if f.hint), "")
132
+ if hint:
133
+ out.append(f" fix: {hint}")
134
+ return out
135
+
136
+
137
+ def render_text(report: Report, verbose: bool = False) -> str:
138
+ lines: list[str] = []
139
+ if report.kind == "audit":
140
+ lines.append(
141
+ f"agentcheckup {__version__} | {report.server_count} server(s) | "
142
+ f"{report.tool_count} tool(s) | ~{report.tokens:,} tokens per request"
143
+ )
144
+ elif report.kind == "config":
145
+ lines.append(f"agentcheckup {__version__} | {report.tool_count} server(s)")
146
+ else:
147
+ lines.append(
148
+ f"agentcheckup {__version__} | {report.tool_count} tool(s) | "
149
+ f"~{report.tokens:,} tokens per request"
150
+ )
151
+ if report.source:
152
+ lines.append(f"source: {report.source}")
153
+
154
+ counts = report.counts()
155
+ summary = " ".join(f"{counts[s.value]} {s.value}" for s in Severity if counts[s.value])
156
+ lines.append("")
157
+ lines.append(
158
+ f" Grade {report.grade:<2} Score {report.score:>3}/100 "
159
+ f"{summary if summary else 'clean'}"
160
+ )
161
+ lines.append("")
162
+
163
+ if not report.findings:
164
+ lines.append(" No findings. Ship it.")
165
+ return "\n".join(lines)
166
+
167
+ ordered = report.sorted_findings()
168
+ serious = [f for f in ordered if f.severity is not Severity.INFO]
169
+ nudges = [f for f in ordered if f.severity is Severity.INFO]
170
+
171
+ if report.kind == "audit":
172
+ # Two sections, because they answer different questions: "will my agent
173
+ # pick the wrong tool?" and "is my setup unsafe?".
174
+ conflicts = [f for f in serious if f.rule.startswith("AGT2")]
175
+ risks = [f for f in serious if not f.rule.startswith("AGT2")]
176
+ if conflicts:
177
+ lines.append(f" CONFLICTS ({len(conflicts)})")
178
+ lines.extend(_finding_lines(conflicts, verbose))
179
+ lines.append("")
180
+ if risks:
181
+ lines.append(f" RISKS ({len(risks)})")
182
+ lines.extend(_serious_lines(risks, verbose))
183
+ else:
184
+ for severity in (Severity.ERROR, Severity.WARNING):
185
+ group = [f for f in serious if f.severity is severity]
186
+ if not group:
187
+ continue
188
+ lines.append(f" {severity.value.upper()}")
189
+ lines.extend(_serious_lines(group, verbose))
190
+
191
+ if nudges:
192
+ rules = len({f.rule for f in nudges})
193
+ lines.append("")
194
+ lines.append(f" NUDGES ({len(nudges)} across {rules} rule(s), no score impact)")
195
+ lines.extend(_nudge_lines(nudges))
196
+
197
+ lines.append("")
198
+ lines.append(" Rule docs: https://github.com/wanjiaqing/agentcheckup#rules")
199
+ return "\n".join(lines)
200
+
201
+
202
+ def render_json(report: Report) -> str:
203
+ payload = {
204
+ "version": __version__,
205
+ "source": report.source,
206
+ "server_count": report.server_count,
207
+ "tool_count": report.tool_count,
208
+ "estimated_tokens": report.tokens,
209
+ "score": report.score,
210
+ "grade": report.grade,
211
+ "counts": report.counts(),
212
+ "findings": [f.as_dict() for f in report.sorted_findings()],
213
+ }
214
+ return json.dumps(payload, indent=2, ensure_ascii=False)
215
+
216
+
217
+ def render_sarif(report: Report) -> str:
218
+ """SARIF 2.1.0 so findings show up in GitHub code scanning for free."""
219
+ uri = report.source or "mcp-tools.json"
220
+ results: list[dict[str, Any]] = []
221
+ for finding in report.sorted_findings():
222
+ result: dict[str, Any] = {
223
+ "ruleId": finding.rule,
224
+ "level": SARIF_LEVEL[finding.severity],
225
+ "message": {"text": finding.message + (f" ({finding.tool})" if finding.tool else "")},
226
+ "locations": [
227
+ {
228
+ "physicalLocation": {
229
+ "artifactLocation": {"uri": uri},
230
+ "region": {"startLine": 1, "startColumn": 1},
231
+ }
232
+ }
233
+ ],
234
+ }
235
+ if finding.hint:
236
+ result["fixes"] = []
237
+ result["properties"] = {"hint": finding.hint}
238
+ results.append(result)
239
+
240
+ payload = {
241
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
242
+ "version": "2.1.0",
243
+ "runs": [
244
+ {
245
+ "tool": {
246
+ "driver": {
247
+ "name": "agentcheckup",
248
+ "version": __version__,
249
+ "informationUri": "https://github.com/wanjiaqing/agentcheckup",
250
+ "rules": _rule_metadata(),
251
+ }
252
+ },
253
+ "results": results,
254
+ }
255
+ ],
256
+ }
257
+ return json.dumps(payload, indent=2, ensure_ascii=False)
258
+
259
+
260
+ def render_rule_list() -> str:
261
+ lines = ["Tool rules", "----------"]
262
+ for rule in sorted(_rules.TOOL_RULES, key=lambda r: r.id):
263
+ lines.append(f" {rule.id} {rule.severity.value:<7} {rule.title}")
264
+ lines.append("")
265
+ lines.append("Suite rules (need the whole manifest)")
266
+ lines.append("------------------------------------")
267
+ for rule in sorted(_rules.SUITE_RULES, key=lambda r: r.id):
268
+ lines.append(f" {rule.id} {rule.severity.value:<7} {rule.title}")
269
+ lines.append("")
270
+ lines.append("Config rules")
271
+ lines.append("------------")
272
+ for rule in sorted(_rules.CONFIG_RULES, key=lambda r: r.id):
273
+ lines.append(f" {rule.id} {rule.severity.value:<7} {rule.title}")
274
+ return "\n".join(lines)
@@ -0,0 +1,12 @@
1
+ """Rule registry bootstrap.
2
+
3
+ Importing this package registers every rule. Order matters only for output
4
+ grouping, not for correctness.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import config_rules, docs, injection, naming, safety, schema # noqa: F401
10
+ from .base import CONFIG_RULES, SUITE_RULES, TOOL_RULES
11
+
12
+ __all__ = ["CONFIG_RULES", "SUITE_RULES", "TOOL_RULES"]
@@ -0,0 +1,91 @@
1
+ """Rule protocol and registry.
2
+
3
+ Adding a rule should be a single small file plus a decorator - that is the
4
+ whole point, because the fastest way to grow an OSS project is to leave obvious
5
+ room for other people's pull requests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import NamedTuple, Protocol
12
+
13
+ from ..models import Finding, Severity
14
+
15
+
16
+ class Rule(Protocol):
17
+ id: str
18
+ severity: Severity
19
+ title: str
20
+
21
+ def check(self, subject): # noqa: ANN001 - Tool or ServerConfig
22
+ ...
23
+
24
+
25
+ TOOL_RULES: list[Rule] = []
26
+ CONFIG_RULES: list[Rule] = []
27
+
28
+
29
+ class SuiteRule(NamedTuple):
30
+ """A rule that needs the whole manifest, so it cannot live in a single file."""
31
+
32
+ id: str
33
+ severity: Severity
34
+ title: str
35
+
36
+
37
+ #: Documented here so ``agentcheckup rules`` and the SARIF catalogue stay complete.
38
+ SUITE_RULES: list[SuiteRule] = [
39
+ SuiteRule("AGT004", Severity.ERROR, "Tool name is duplicated"),
40
+ SuiteRule("AGT105", Severity.INFO, "Server did not return a tool list"),
41
+ SuiteRule("AGT201", Severity.ERROR, "Same tool name exposed by two servers"),
42
+ SuiteRule("AGT202", Severity.WARNING, "Two servers expose the same name once normalised"),
43
+ SuiteRule("AGT203", Severity.WARNING, "Two servers expose overlapping tools"),
44
+ ]
45
+
46
+
47
+ def _make_rule(
48
+ rule_id: str,
49
+ sev: Severity,
50
+ rule_title: str,
51
+ fn: Callable,
52
+ doc: str | None,
53
+ ) -> Rule:
54
+ class _FnRule:
55
+ def check(self, subject): # noqa: ANN001
56
+ result = fn(subject)
57
+ if result is None:
58
+ return []
59
+ if isinstance(result, Finding):
60
+ return [result]
61
+ return list(result)
62
+
63
+ _FnRule.id = rule_id
64
+ _FnRule.severity = sev
65
+ _FnRule.title = rule_title
66
+ _FnRule.__doc__ = doc or fn.__doc__
67
+ return _FnRule()
68
+
69
+
70
+ def tool_rule(rule_id: str, severity: Severity, title: str) -> Callable:
71
+ """Register a tool-level rule.
72
+
73
+ The decorated function takes a :class:`Tool` and returns either a single
74
+ :class:`Finding`, an iterable of them, or ``None``.
75
+ """
76
+
77
+ def decorator(fn: Callable) -> Callable:
78
+ TOOL_RULES.append(_make_rule(rule_id, severity, title, fn, fn.__doc__))
79
+ return fn
80
+
81
+ return decorator
82
+
83
+
84
+ def config_rule(rule_id: str, severity: Severity, title: str) -> Callable:
85
+ """Register a client-config level rule (operates on ServerConfig)."""
86
+
87
+ def decorator(fn: Callable) -> Callable:
88
+ CONFIG_RULES.append(_make_rule(rule_id, severity, title, fn, fn.__doc__))
89
+ return fn
90
+
91
+ return decorator