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.
@@ -0,0 +1,27 @@
1
+ """agentcheckup - diagnose your agent setup before your agent does."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .conflicts import Conflict, ToolRef, detect_conflicts, lint_conflicts
6
+ from .discovery import discover, load_servers
7
+ from .engine import Report, lint_audit, lint_servers, lint_tools
8
+ from .models import Finding, ServerConfig, Severity, Tool
9
+ from .report import __version__
10
+
11
+ __all__ = [
12
+ "Conflict",
13
+ "Finding",
14
+ "Report",
15
+ "ServerConfig",
16
+ "Severity",
17
+ "Tool",
18
+ "ToolRef",
19
+ "__version__",
20
+ "detect_conflicts",
21
+ "discover",
22
+ "lint_audit",
23
+ "lint_conflicts",
24
+ "lint_servers",
25
+ "lint_tools",
26
+ "load_servers",
27
+ ]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
agentcheckup/cli.py ADDED
@@ -0,0 +1,242 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .discovery import discover, load_servers
10
+ from .engine import Report, lint_audit, lint_servers, lint_tools
11
+ from .loader import LoadError, StdioSession, load_from_file, servers_from_config
12
+ from .models import ServerConfig, Severity
13
+ from .report import __version__, render_json, render_rule_list, render_sarif, render_text
14
+
15
+ EXIT_OK = 0
16
+ EXIT_FINDINGS = 1
17
+ EXIT_USAGE = 2
18
+
19
+ THRESHOLDS = {
20
+ "error": Severity.ERROR,
21
+ "warning": Severity.WARNING,
22
+ "info": Severity.INFO,
23
+ }
24
+
25
+
26
+ def _add_output_flags(parser: argparse.ArgumentParser) -> None:
27
+ parser.add_argument("--format", choices=("text", "json", "sarif"), default="text")
28
+ parser.add_argument(
29
+ "--fail-on",
30
+ choices=("error", "warning", "info", "never"),
31
+ default="error",
32
+ help="Exit non-zero at or above this severity (default: error).",
33
+ )
34
+ parser.add_argument("--min-score", type=int, default=None, help="Also fail below this score.")
35
+ parser.add_argument("--verbose", action="store_true", help="Show extra detail per finding.")
36
+ parser.add_argument(
37
+ "--json-out",
38
+ metavar="PATH",
39
+ default=None,
40
+ help="Also write the JSON report here, whatever --format says.",
41
+ )
42
+ parser.add_argument(
43
+ "--sarif-out",
44
+ metavar="PATH",
45
+ default=None,
46
+ help="Also write a SARIF report here. One run, three outputs.",
47
+ )
48
+
49
+
50
+ def _build_parser() -> argparse.ArgumentParser:
51
+ parser = argparse.ArgumentParser(
52
+ prog="agentcheckup",
53
+ description="Diagnose your agent setup: tool conflicts, risky config, context budget.",
54
+ )
55
+ parser.add_argument("--version", action="version", version=f"agentcheckup {__version__}")
56
+ sub = parser.add_subparsers(dest="command")
57
+
58
+ audit = sub.add_parser(
59
+ "audit",
60
+ help="Audit every MCP client config found on this machine (default command).",
61
+ )
62
+ audit.add_argument(
63
+ "--config",
64
+ action="append",
65
+ default=[],
66
+ metavar="PATH",
67
+ help="Extra config file to include (repeatable).",
68
+ )
69
+ audit.add_argument(
70
+ "--timeout",
71
+ type=float,
72
+ default=25.0,
73
+ help="Seconds to wait for each server's tools/list (default: 25).",
74
+ )
75
+ audit.add_argument(
76
+ "--no-spawn",
77
+ action="store_true",
78
+ help="Do not start servers; check configs only. Fast, but no conflict detection.",
79
+ )
80
+ audit.add_argument(
81
+ "--no-discover",
82
+ action="store_true",
83
+ help="Do not auto-find configs; audit only the ones passed with --config.",
84
+ )
85
+ _add_output_flags(audit)
86
+
87
+ scan = sub.add_parser("scan", help="Lint one tools/list manifest (file or live server).")
88
+ scan.add_argument("path", nargs="?", help="JSON file containing a tools array.")
89
+ scan.add_argument("--cmd", dest="cmd", help="Spawn this stdio server and lint its live tools/list.")
90
+ _add_output_flags(scan)
91
+
92
+ cfg = sub.add_parser("config", help="Audit a single client config file.")
93
+ cfg.add_argument("path", help="e.g. claude_desktop_config.json or .vscode/mcp.json")
94
+ _add_output_flags(cfg)
95
+
96
+ sub.add_parser("rules", help="List every rule with its id and severity.")
97
+ return parser
98
+
99
+
100
+ def _emit(report: Report, args: argparse.Namespace) -> None:
101
+ # Side outputs first: CI reads them even when the human view is what printed.
102
+ if args.json_out:
103
+ Path(args.json_out).write_text(render_json(report), encoding="utf-8")
104
+ if args.sarif_out:
105
+ Path(args.sarif_out).write_text(render_sarif(report), encoding="utf-8")
106
+
107
+ if args.format == "json":
108
+ print(render_json(report))
109
+ elif args.format == "sarif":
110
+ print(render_sarif(report))
111
+ else:
112
+ print(render_text(report, verbose=args.verbose))
113
+
114
+
115
+ def _exit_code(report: Report, fail_on: str, min_score: int | None) -> int:
116
+ if min_score is not None and report.score < min_score:
117
+ return EXIT_FINDINGS
118
+ if fail_on == "never":
119
+ return EXIT_OK
120
+ return EXIT_FINDINGS if report.exceeding(THRESHOLDS[fail_on]) else EXIT_OK
121
+
122
+
123
+ def _dedupe(servers: list[ServerConfig]) -> list[ServerConfig]:
124
+ """The same server is often declared in several configs.
125
+
126
+ Spawning it twice would double its tools and invent conflicts that do not
127
+ exist, so identity is the launch spec, not the name.
128
+ """
129
+ seen: set[tuple] = set()
130
+ out: list[ServerConfig] = []
131
+ for server in servers:
132
+ key = (
133
+ (server.command or "").lower(),
134
+ tuple(a.lower() for a in server.args),
135
+ (server.url or "").lower(),
136
+ )
137
+ if key in seen:
138
+ continue
139
+ seen.add(key)
140
+ out.append(server)
141
+ return out
142
+
143
+
144
+ def _run_audit(args: argparse.Namespace) -> int:
145
+ configs = [] if args.no_discover else discover()
146
+ for extra in args.config:
147
+ path = Path(extra)
148
+ if not path.is_file():
149
+ print(f"agentcheckup: no such config file: {path}", file=sys.stderr)
150
+ return EXIT_USAGE
151
+ configs.append(("explicit", path))
152
+
153
+ if not configs:
154
+ print(
155
+ "agentcheckup: no MCP client config found.\n"
156
+ "Looked for Claude Desktop, Claude Code, Cursor, Windsurf, Cline and\n"
157
+ "project-local .mcp.json / .cursor/mcp.json / .vscode/mcp.json.\n"
158
+ "Point it at one explicitly: agentcheckup audit --config path/to/config.json",
159
+ file=sys.stderr,
160
+ )
161
+ return EXIT_USAGE
162
+
163
+ servers: list[ServerConfig] = []
164
+ for label, path in configs:
165
+ servers.extend(load_servers(path, label))
166
+ servers = _dedupe(servers)
167
+
168
+ if not servers:
169
+ print("agentcheckup: configs found, but none declare an MCP server.", file=sys.stderr)
170
+ return EXIT_USAGE
171
+
172
+ tools_by_server: dict[str, list] = {}
173
+ failures: dict[str, str] = {}
174
+
175
+ if args.no_spawn:
176
+ # Config-only mode: no tools, so no tool or conflict findings.
177
+ for server in servers:
178
+ failures[server.name] = "not started (--no-spawn)"
179
+ else:
180
+ for server in servers:
181
+ if not server.command:
182
+ failures[server.name] = "remote server (url); cannot be introspected over stdio"
183
+ continue
184
+ argv = [server.command, *server.args]
185
+ try:
186
+ with StdioSession(argv, timeout=args.timeout, env=server.env) as session:
187
+ tools_by_server[server.name] = session.list_tools()
188
+ except LoadError as exc:
189
+ failures[server.name] = str(exc).splitlines()[0][:200]
190
+
191
+ report = lint_audit(
192
+ servers,
193
+ tools_by_server,
194
+ failures={} if args.no_spawn else failures,
195
+ source=f"{len(configs)} config(s)",
196
+ )
197
+ _emit(report, args)
198
+ return _exit_code(report, args.fail_on, args.min_score)
199
+
200
+
201
+ def main(argv: list[str] | None = None) -> int:
202
+ if argv is None:
203
+ argv = sys.argv[1:]
204
+
205
+ # `agentcheckup` with no arguments is the whole point: one command, full audit.
206
+ if not argv or (argv[0].startswith("-") and argv[0] not in ("-h", "--help", "--version")):
207
+ argv = ["audit", *argv]
208
+
209
+ parser = _build_parser()
210
+ args = parser.parse_args(argv)
211
+
212
+ if args.command == "rules":
213
+ print(render_rule_list())
214
+ return EXIT_OK
215
+
216
+ if args.command == "audit":
217
+ return _run_audit(args)
218
+
219
+ try:
220
+ if args.command == "scan":
221
+ if bool(args.path) == bool(args.cmd):
222
+ parser.error("scan needs exactly one of: a JSON path, or --cmd")
223
+ if args.cmd:
224
+ with StdioSession(args.cmd) as session:
225
+ tools = session.list_tools()
226
+ report = lint_tools(tools, source=f"cmd: {args.cmd}")
227
+ else:
228
+ path = Path(args.path)
229
+ report = lint_tools(load_from_file(path), source=str(path))
230
+ else:
231
+ path = Path(args.path)
232
+ report = lint_servers(servers_from_config(path), source=str(path))
233
+ except LoadError as exc:
234
+ print(f"agentcheckup: {exc}", file=sys.stderr)
235
+ return EXIT_USAGE
236
+
237
+ _emit(report, args)
238
+ return _exit_code(report, args.fail_on, args.min_score)
239
+
240
+
241
+ if __name__ == "__main__":
242
+ raise SystemExit(main())
@@ -0,0 +1,253 @@
1
+ """Cross-server tool conflict detection.
2
+
3
+ This is the reason the tool exists. Every other scanner looks at one server at a
4
+ time; nobody asks what happens when five servers are loaded at once and three of
5
+ them expose something called ``search``.
6
+
7
+ Two servers cannot see each other. The model can, and it has no way to know that
8
+ ``github.search`` and ``docs.search`` are different things - it gets a flat list
9
+ of 47 tools and picks by description similarity. When two descriptions overlap,
10
+ selection becomes a coin flip that looks like model flakiness.
11
+
12
+ No dependencies: similarity is token-set Jaccard over normalised text. An
13
+ embedding would be better, but it would also mean a model download, a network
14
+ call, or a 200MB wheel - and then the linter stops working on a plane.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+ from itertools import combinations
22
+
23
+ from .models import Finding, Severity, Tool
24
+
25
+ #: Words that carry no discriminating signal in a tool description.
26
+ STOPWORDS = frozenset(
27
+ {
28
+ "a", "an", "the", "this", "that", "these", "those",
29
+ "is", "are", "was", "were", "be", "been", "being",
30
+ "and", "or", "but", "if", "then", "than", "so", "as",
31
+ "at", "by", "for", "from", "in", "into", "of", "on", "to", "with",
32
+ "it", "its", "you", "your", "we", "our", "they", "them", "their",
33
+ "he", "she", "his", "her",
34
+ "can", "could", "may", "might", "must", "shall", "should", "will", "would",
35
+ "do", "does", "did", "done",
36
+ "make", "makes", "made", "use", "uses", "used", "using",
37
+ "tool", "tools", "function", "functions", "call", "calls", "called",
38
+ "return", "returns", "returned", "value", "values", "result", "results",
39
+ "given", "specify", "specified", "provide", "provides",
40
+ }
41
+ )
42
+
43
+ WORD = re.compile(r"[a-z0-9]+")
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ToolRef:
48
+ """A tool plus the server it came from."""
49
+
50
+ server: str
51
+ tool: Tool
52
+
53
+ @property
54
+ def qualified(self) -> str:
55
+ return f"{self.server}/{self.tool.name}"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Conflict:
60
+ rule: str
61
+ severity: Severity
62
+ a: ToolRef
63
+ b: ToolRef
64
+ similarity: float
65
+ message: str
66
+ hint: str
67
+
68
+ def as_finding(self) -> Finding:
69
+ return Finding(
70
+ rule=self.rule,
71
+ severity=self.severity,
72
+ message=self.message,
73
+ tool=f"{self.a.qualified} <-> {self.b.qualified}",
74
+ hint=self.hint,
75
+ where=f"similarity={self.similarity:.2f}",
76
+ )
77
+
78
+
79
+ def _stem(word: str) -> str:
80
+ """Crude English plural stripping.
81
+
82
+ Deliberately not a real stemmer: `read_files` and `read_file` must collapse
83
+ to the same token, and that is the only job. A full Porter stemmer would be
84
+ a dependency for no benefit.
85
+ """
86
+ if len(word) <= 3:
87
+ return word
88
+ if word.endswith("ies") and len(word) > 4:
89
+ return word[:-3] + "y"
90
+ if word.endswith(("ses", "xes", "zes", "ches", "shes")):
91
+ return word[:-2]
92
+ if word.endswith("s") and not word.endswith("ss"):
93
+ return word[:-1]
94
+ return word
95
+
96
+
97
+ def tokens(text: str) -> set[str]:
98
+ """Lowercase word set, stopwords removed, crude plural stemming."""
99
+ return {
100
+ _stem(w)
101
+ for w in WORD.findall(text.lower())
102
+ if w not in STOPWORDS and len(w) > 1
103
+ }
104
+
105
+
106
+ def jaccard(a: set[str], b: set[str]) -> float:
107
+ if not a or not b:
108
+ return 0.0
109
+ union = a | b
110
+ return len(a & b) / len(union)
111
+
112
+
113
+ def name_tokens(name: str) -> set[str]:
114
+ return {_stem(w) for w in WORD.findall(name.lower()) if len(w) > 1}
115
+
116
+
117
+ def similarity(a: ToolRef, b: ToolRef) -> float:
118
+ """Blend name and description overlap.
119
+
120
+ Name overlap matters more when it is exact, because that is a hard
121
+ collision rather than a soft one.
122
+ """
123
+ name_sim = jaccard(name_tokens(a.tool.name), name_tokens(b.tool.name))
124
+ desc_sim = jaccard(tokens(a.tool.description), tokens(b.tool.description))
125
+ return round(0.45 * name_sim + 0.55 * desc_sim, 3)
126
+
127
+
128
+ def is_ambiguous(a: ToolRef, b: ToolRef, threshold: float = 0.55) -> tuple[bool, float]:
129
+ """Decide whether two tools are genuinely confusable.
130
+
131
+ Two signals, either is sufficient:
132
+
133
+ - the descriptions overlap heavily (>= 0.65) and the names share a head
134
+ noun: the model reads the description, so this is the primary signal;
135
+ - the blended score clears ``threshold``.
136
+
137
+ Requiring a shared noun is what keeps ``search_repositories`` and
138
+ ``search_documents`` apart. Without it the false-positive rate made the
139
+ whole report untrustworthy.
140
+ """
141
+ name_sim = jaccard(name_tokens(a.tool.name), name_tokens(b.tool.name))
142
+ desc_sim = jaccard(tokens(a.tool.description), tokens(b.tool.description))
143
+ blended = round(0.45 * name_sim + 0.55 * desc_sim, 3)
144
+
145
+ if not _same_object(a, b):
146
+ return False, blended
147
+ return (desc_sim >= 0.65 or blended >= threshold), blended
148
+
149
+
150
+ def _same_object(a: ToolRef, b: ToolRef) -> bool:
151
+ """Two names sharing a head noun are more likely to collide.
152
+
153
+ ``search_docs`` and ``search_code`` both end in a different noun, so they are
154
+ probably fine. ``search`` and ``search_docs`` share one, so they are not.
155
+ """
156
+ ta, tb = name_tokens(a.tool.name), name_tokens(b.tool.name)
157
+ return bool(ta & tb)
158
+
159
+
160
+ def detect_conflicts(
161
+ refs: list[ToolRef],
162
+ *,
163
+ threshold: float = 0.55,
164
+ max_pairs: int = 60_000,
165
+ ) -> list[Conflict]:
166
+ """Find tool pairs across servers that an agent cannot reliably tell apart."""
167
+ conflicts: list[Conflict] = []
168
+ seen: set[tuple[str, str]] = set()
169
+
170
+ pairs = list(combinations(refs, 2))
171
+ if len(pairs) > max_pairs: # pathological manifests: compare only plausible pairs
172
+ pairs = [
173
+ (a, b)
174
+ for a, b in pairs
175
+ if a.server != b.server and _same_object(a, b)
176
+ ]
177
+
178
+ for a, b in pairs:
179
+ if a.server == b.server:
180
+ continue # same server: a naming problem, not a conflict problem
181
+
182
+ key = tuple(sorted([a.qualified, b.qualified]))
183
+ if key in seen:
184
+ continue
185
+ seen.add(key)
186
+
187
+ name_a = a.tool.name.lower()
188
+ name_b = b.tool.name.lower()
189
+
190
+ # Hard collision: identical names, different servers. One is unreachable.
191
+ if name_a == name_b:
192
+ conflicts.append(
193
+ Conflict(
194
+ rule="AGT201",
195
+ severity=Severity.ERROR,
196
+ a=a,
197
+ b=b,
198
+ similarity=1.0,
199
+ message=(
200
+ f"Tool name '{a.tool.name}' is exposed by both "
201
+ f"'{a.server}' and '{b.server}'."
202
+ ),
203
+ hint=(
204
+ "Hard collision: the client keeps one and the other is "
205
+ "unreachable. Rename one, or use a gateway that prefixes."
206
+ ),
207
+ )
208
+ )
209
+ continue
210
+
211
+ # Same name modulo punctuation/plural: `read_file` vs `readFiles`.
212
+ if name_tokens(a.tool.name) == name_tokens(b.tool.name):
213
+ conflicts.append(
214
+ Conflict(
215
+ rule="AGT202",
216
+ severity=Severity.WARNING,
217
+ a=a,
218
+ b=b,
219
+ similarity=1.0,
220
+ message=(
221
+ f"Tools '{a.qualified}' and '{b.qualified}' have the same "
222
+ "name once normalised."
223
+ ),
224
+ hint="Rename one so the model can tell them apart by name alone.",
225
+ )
226
+ )
227
+ continue
228
+
229
+ flagged, score = is_ambiguous(a, b, threshold=threshold)
230
+ if flagged:
231
+ conflicts.append(
232
+ Conflict(
233
+ rule="AGT203",
234
+ severity=Severity.WARNING,
235
+ a=a,
236
+ b=b,
237
+ similarity=score,
238
+ message=(
239
+ f"'{a.qualified}' and '{b.qualified}' overlap "
240
+ f"({score:.0%}); the model picks between them at random."
241
+ ),
242
+ hint=(
243
+ "Add a disambiguation clause to both descriptions: "
244
+ '"Use this only for X; use <other tool> for Y."'
245
+ ),
246
+ )
247
+ )
248
+
249
+ return sorted(conflicts, key=lambda c: (-c.severity.rank, -c.similarity))
250
+
251
+
252
+ def lint_conflicts(refs: list[ToolRef], threshold: float = 0.55) -> list[Finding]:
253
+ return [c.as_finding() for c in detect_conflicts(refs, threshold=threshold)]