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,206 @@
1
+ """Find MCP client configs without asking the user where they are.
2
+
3
+ Every client invented its own filename, its own nesting and its own idea of where
4
+ "user config" lives. The point of ``agentcheckup`` is that you type one word and it
5
+ finds them, so this module is the difference between "a linter" and "a doctor".
6
+
7
+ Coverage is the whole product here. A client we cannot find is a client we cannot
8
+ diagnose, so the path table below is deliberately explicit rather than clever.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .models import ServerConfig
19
+
20
+ HOME = Path.home()
21
+
22
+ #: Keys that hold a server map at the top level of a config file.
23
+ SERVER_BLOCKS = ("mcpServers", "servers", "mcp_servers", "context_servers")
24
+
25
+ #: VS Code extension ids that store MCP servers in globalStorage.
26
+ VSCODE_EXTENSIONS = (
27
+ ("Cline", "saoudrizwan.claude-dev"),
28
+ ("Roo Code", "rooveterinaryinc.roo-cline"),
29
+ ("Kilo Code", "kilocode.kilo-code"),
30
+ )
31
+
32
+
33
+ def _appdata() -> Path:
34
+ return Path(os.environ.get("APPDATA", HOME / "AppData" / "Roaming"))
35
+
36
+
37
+ def _platform_app_dir() -> Path:
38
+ """Where GUI apps keep per-user data on this OS."""
39
+ if sys.platform == "win32":
40
+ return _appdata()
41
+ if sys.platform == "darwin":
42
+ return HOME / "Library" / "Application Support"
43
+ return HOME / ".config"
44
+
45
+
46
+ def _editor_bases() -> list[tuple[str, Path]]:
47
+ """VS Code and its forks, which share the globalStorage layout."""
48
+ root = _platform_app_dir()
49
+ return [
50
+ ("VS Code", root / "Code"),
51
+ ("Cursor", root / "Cursor"),
52
+ ("VSCodium", root / "VSCodium"),
53
+ ("Windsurf", root / "Windsurf"),
54
+ ]
55
+
56
+
57
+ def candidate_paths(cwd: Path | None = None) -> list[tuple[str, Path]]:
58
+ """Every known config location, with a human label. May not exist."""
59
+ cwd = cwd or Path.cwd()
60
+ app = _platform_app_dir()
61
+ out: list[tuple[str, Path]] = []
62
+
63
+ # Desktop clients
64
+ out.append(("Claude Desktop", app / "Claude" / "claude_desktop_config.json"))
65
+ out.append(("Zed", app / "Zed" / "settings.json"))
66
+
67
+ # Editors: per-extension globalStorage
68
+ for editor, base in _editor_bases():
69
+ for label, extension in VSCODE_EXTENSIONS:
70
+ out.append(
71
+ (
72
+ f"{label} ({editor})",
73
+ base / "User" / "globalStorage" / extension / "settings" / "cline_mcp_settings.json",
74
+ )
75
+ )
76
+ # VS Code 1.102+ keeps MCP servers under `mcp.servers` in settings.json.
77
+ out.append((f"{editor} settings", base / "User" / "settings.json"))
78
+
79
+ # Cross-platform, user-level
80
+ out += [
81
+ ("Claude Code", HOME / ".claude.json"),
82
+ ("Cursor", HOME / ".cursor" / "mcp.json"),
83
+ ("Zed (config dir)", HOME / ".config" / "zed" / "settings.json"),
84
+ ("Codex CLI", HOME / ".codex" / "config.toml"),
85
+ ("Continue", HOME / ".continue" / "config.json"),
86
+ ]
87
+
88
+ # Project-local
89
+ out += [
90
+ ("project .mcp.json", cwd / ".mcp.json"),
91
+ ("project Cursor", cwd / ".cursor" / "mcp.json"),
92
+ ("project VS Code", cwd / ".vscode" / "mcp.json"),
93
+ ]
94
+
95
+ return out
96
+
97
+
98
+ def discover(cwd: Path | None = None) -> list[tuple[str, Path]]:
99
+ """Return only the configs that actually exist, deduplicated."""
100
+ found: list[tuple[str, Path]] = []
101
+ seen: set[str] = set()
102
+ for label, path in candidate_paths(cwd):
103
+ try:
104
+ key = str(path.resolve()).lower()
105
+ except OSError:
106
+ key = str(path).lower()
107
+ if key in seen:
108
+ continue
109
+ seen.add(key)
110
+ try:
111
+ if path.is_file():
112
+ found.append((label, path))
113
+ except OSError:
114
+ continue
115
+ return found
116
+
117
+
118
+ def _servers_from_block(block: object, source: str) -> list[ServerConfig]:
119
+ if not isinstance(block, dict):
120
+ return []
121
+ return [
122
+ ServerConfig.from_raw(name, entry, source=source)
123
+ for name, entry in block.items()
124
+ if isinstance(entry, dict)
125
+ ]
126
+
127
+
128
+ def _servers_from_payload(payload: dict, tag: str) -> list[ServerConfig]:
129
+ """Pull every server map out of one config document.
130
+
131
+ Three shapes exist in the wild and all three are load-bearing:
132
+
133
+ - top level: ``{"mcpServers": {...}}`` - Claude Desktop, Claude Code, Cursor
134
+ - nested under ``mcp``: ``{"mcp": {"servers": {...}}}`` - VS Code settings.json
135
+ - per project: ``{"projects": {"/path": {"mcpServers": {...}}}}`` - Claude Code
136
+ """
137
+ servers: list[ServerConfig] = []
138
+
139
+ for key in SERVER_BLOCKS:
140
+ servers.extend(_servers_from_block(payload.get(key), tag))
141
+
142
+ mcp = payload.get("mcp")
143
+ if isinstance(mcp, dict):
144
+ for key in ("servers", "mcpServers"):
145
+ servers.extend(_servers_from_block(mcp.get(key), f"{tag} [mcp]"))
146
+
147
+ projects = payload.get("projects")
148
+ if isinstance(projects, dict):
149
+ for project_path, project in projects.items():
150
+ if not isinstance(project, dict):
151
+ continue
152
+ nested = _servers_from_block(
153
+ project.get("mcpServers"), f"{tag} :: {project_path}"
154
+ )
155
+ # Prefix so two projects exposing the same server name stay distinct.
156
+ for server in nested:
157
+ server.name = f"{Path(str(project_path)).name}:{server.name}"
158
+ servers.extend(nested)
159
+
160
+ return servers
161
+
162
+
163
+ def _load_json(path: Path, label: str) -> list[ServerConfig]:
164
+ try:
165
+ payload = json.loads(path.read_text(encoding="utf-8"))
166
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
167
+ return []
168
+ if not isinstance(payload, dict):
169
+ return []
170
+ return _servers_from_payload(payload, f"{label} ({path})")
171
+
172
+
173
+ def _load_toml(path: Path, label: str) -> list[ServerConfig]:
174
+ """Codex CLI keeps its servers in TOML, not JSON.
175
+
176
+ ``tomllib`` is stdlib from 3.11. On 3.10 we skip the file rather than add a
177
+ dependency - the zero-dependency promise is worth more than this one client.
178
+ """
179
+ try:
180
+ import tomllib
181
+ except ModuleNotFoundError:
182
+ return []
183
+
184
+ try:
185
+ payload = tomllib.loads(path.read_text(encoding="utf-8"))
186
+ except (OSError, ValueError):
187
+ return []
188
+ if not isinstance(payload, dict):
189
+ return []
190
+
191
+ tag = f"{label} ({path})"
192
+ servers: list[ServerConfig] = []
193
+ block = payload.get("mcp_servers") or payload.get("mcpServers")
194
+ if isinstance(block, dict):
195
+ for name, entry in block.items():
196
+ if isinstance(entry, dict):
197
+ servers.append(ServerConfig.from_raw(name, entry, source=tag))
198
+ return servers
199
+
200
+
201
+ def load_servers(path: Path, label: str = "") -> list[ServerConfig]:
202
+ """Read servers from one config, whatever format it is in."""
203
+ label = label or path.name
204
+ if path.suffix.lower() == ".toml":
205
+ return _load_toml(path, label)
206
+ return _load_json(path, label)
agentcheckup/engine.py ADDED
@@ -0,0 +1,146 @@
1
+ """Run rules over a manifest and produce a report object."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from dataclasses import dataclass, field
7
+
8
+ from . import rules as _rules # noqa: F401 - side effect: registers rules
9
+ from .conflicts import ToolRef, lint_conflicts
10
+ from .models import Finding, ServerConfig, Severity, Tool, grade_for, score_findings
11
+ from .tokens import manifest_tokens
12
+
13
+
14
+ @dataclass
15
+ class Report:
16
+ findings: list[Finding] = field(default_factory=list)
17
+ tool_count: int = 0
18
+ tokens: int = 0
19
+ source: str = ""
20
+ kind: str = "tools"
21
+ server_count: int = 0
22
+
23
+ @property
24
+ def score(self) -> int:
25
+ return score_findings(self.findings, tool_count=self.tool_count)
26
+
27
+ @property
28
+ def grade(self) -> str:
29
+ return grade_for(self.score)
30
+
31
+ def counts(self) -> dict[str, int]:
32
+ counter = Counter(f.severity.value for f in self.findings)
33
+ return {s.value: counter.get(s.value, 0) for s in Severity}
34
+
35
+ def sorted_findings(self) -> list[Finding]:
36
+ return sorted(
37
+ self.findings,
38
+ key=lambda f: (-f.severity.rank, f.rule, f.tool or "", f.message),
39
+ )
40
+
41
+ def conflicts(self) -> list[Finding]:
42
+ """Cross-server findings, reported in their own section."""
43
+ return [f for f in self.sorted_findings() if f.rule.startswith("AGT2")]
44
+
45
+ def risks(self) -> list[Finding]:
46
+ return [f for f in self.sorted_findings() if not f.rule.startswith("AGT2")]
47
+
48
+ def worst(self) -> Severity | None:
49
+ if not self.findings:
50
+ return None
51
+ return max((f.severity for f in self.findings), key=lambda s: s.rank)
52
+
53
+ def exceeding(self, threshold: Severity) -> list[Finding]:
54
+ return [f for f in self.findings if f.severity.rank >= threshold.rank]
55
+
56
+
57
+ def _tool_findings(tools: list[Tool]) -> list[Finding]:
58
+ findings: list[Finding] = []
59
+
60
+ # Suite-level: duplicate names make selection non-deterministic.
61
+ seen: dict[str, int] = {}
62
+ for tool in tools:
63
+ seen[tool.name] = seen.get(tool.name, 0) + 1
64
+ for name, count in seen.items():
65
+ if name and count > 1:
66
+ findings.append(
67
+ Finding(
68
+ rule="AGT004",
69
+ severity=Severity.ERROR,
70
+ message=f"Tool name {name!r} appears {count} times.",
71
+ tool=name,
72
+ hint="Names must be unique; clients keep only one of them.",
73
+ )
74
+ )
75
+
76
+ for tool in tools:
77
+ for rule in _rules.TOOL_RULES:
78
+ findings.extend(rule.check(tool))
79
+
80
+ return findings
81
+
82
+
83
+ def _server_findings(servers: list[ServerConfig]) -> list[Finding]:
84
+ findings: list[Finding] = []
85
+ for server in servers:
86
+ for rule in _rules.CONFIG_RULES:
87
+ findings.extend(rule.check(server))
88
+ return findings
89
+
90
+
91
+ def lint_tools(tools: list[Tool], source: str = "") -> Report:
92
+ return Report(
93
+ findings=_tool_findings(tools),
94
+ tool_count=len(tools),
95
+ tokens=manifest_tokens(tools),
96
+ source=source,
97
+ )
98
+
99
+
100
+ def lint_servers(servers: list[ServerConfig], source: str = "") -> Report:
101
+ return Report(
102
+ findings=_server_findings(servers),
103
+ tool_count=len(servers),
104
+ tokens=0,
105
+ source=source,
106
+ kind="config",
107
+ )
108
+
109
+
110
+ def lint_audit(
111
+ servers: list[ServerConfig],
112
+ tools_by_server: dict[str, list[Tool]],
113
+ failures: dict[str, str] | None = None,
114
+ source: str = "",
115
+ ) -> Report:
116
+ """The full check: config risk + per-server tool quality + cross-server conflicts."""
117
+ failures = failures or {}
118
+ findings = _server_findings(servers)
119
+
120
+ refs: list[ToolRef] = []
121
+ for server_name, tools in tools_by_server.items():
122
+ findings.extend(_tool_findings(tools))
123
+ refs.extend(ToolRef(server=server_name, tool=tool) for tool in tools)
124
+
125
+ findings.extend(lint_conflicts(refs))
126
+
127
+ for server_name, reason in sorted(failures.items()):
128
+ findings.append(
129
+ Finding(
130
+ rule="AGT105",
131
+ severity=Severity.INFO,
132
+ message=f"Server {server_name!r} did not return a tool list: {reason}",
133
+ tool=server_name,
134
+ hint="Check that the command runs standalone; unreachable servers are unaudited.",
135
+ )
136
+ )
137
+
138
+ all_tools = [tool for tools in tools_by_server.values() for tool in tools]
139
+ return Report(
140
+ findings=findings,
141
+ tool_count=len(all_tools),
142
+ tokens=manifest_tokens(all_tools),
143
+ source=source,
144
+ kind="audit",
145
+ server_count=len(servers),
146
+ )
agentcheckup/loader.py ADDED
@@ -0,0 +1,214 @@
1
+ """Load tool manifests from a file or from a live stdio server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .models import ServerConfig, Tool
14
+
15
+ PROTOCOL_VERSION = "2025-06-18"
16
+
17
+
18
+ class LoadError(RuntimeError):
19
+ """Raised when a manifest cannot be read or parsed."""
20
+
21
+
22
+ def tools_from_payload(payload: Any) -> list[Tool]:
23
+ """Accept every shape people actually have lying around.
24
+
25
+ Supported: ``{"tools": [...]}``, a bare list, a JSON-RPC envelope
26
+ ``{"result": {"tools": [...]}}``, or a full session log with ``messages``.
27
+ """
28
+ if isinstance(payload, list):
29
+ return [Tool.from_raw(item) for item in payload if isinstance(item, dict)]
30
+
31
+ if isinstance(payload, dict):
32
+ for key in ("tools",):
33
+ value = payload.get(key)
34
+ if isinstance(value, list):
35
+ return [Tool.from_raw(item) for item in value if isinstance(item, dict)]
36
+
37
+ result = payload.get("result")
38
+ if isinstance(result, dict) and isinstance(result.get("tools"), list):
39
+ return [Tool.from_raw(item) for item in result["tools"] if isinstance(item, dict)]
40
+
41
+ messages = payload.get("messages")
42
+ if isinstance(messages, list):
43
+ collected: list[Tool] = []
44
+ for msg in messages:
45
+ if isinstance(msg, dict):
46
+ collected.extend(tools_from_payload(msg))
47
+ return collected
48
+
49
+ raise LoadError(
50
+ "Could not find a tools array. Expected {'tools': [...]}, a JSON-RPC "
51
+ "'result.tools', or a bare list."
52
+ )
53
+
54
+
55
+ def load_from_file(path: str | Path) -> list[Tool]:
56
+ file = Path(path)
57
+ if not file.exists():
58
+ raise LoadError(f"No such file: {file}")
59
+ try:
60
+ payload = json.loads(file.read_text(encoding="utf-8"))
61
+ except json.JSONDecodeError as exc:
62
+ raise LoadError(f"{file} is not valid JSON: {exc}") from exc
63
+ return tools_from_payload(payload)
64
+
65
+
66
+ def servers_from_config(path: str | Path) -> list[ServerConfig]:
67
+ """Read a client config (Claude Desktop, Cursor, VS Code, Cline)."""
68
+ file = Path(path)
69
+ if not file.exists():
70
+ raise LoadError(f"No such file: {file}")
71
+ try:
72
+ payload = json.loads(file.read_text(encoding="utf-8"))
73
+ except json.JSONDecodeError as exc:
74
+ raise LoadError(f"{file} is not valid JSON: {exc}") from exc
75
+
76
+ if not isinstance(payload, dict):
77
+ raise LoadError(f"{file} does not contain a JSON object.")
78
+
79
+ blocks: list[tuple[str, dict]] = []
80
+ for key in ("mcpServers", "servers", "mcp_servers"):
81
+ value = payload.get(key)
82
+ if isinstance(value, dict):
83
+ blocks.append((key, value))
84
+
85
+ if not blocks:
86
+ raise LoadError(
87
+ f"{file} has no server block. Expected one of: mcpServers, servers, mcp_servers."
88
+ )
89
+
90
+ servers: list[ServerConfig] = []
91
+ for key, block in blocks:
92
+ for name, entry in block.items():
93
+ if isinstance(entry, dict):
94
+ servers.append(ServerConfig.from_raw(name, entry, source=f"{file}:{key}"))
95
+ return servers
96
+
97
+
98
+ class StdioSession:
99
+ """Minimal MCP stdio client: initialize -> tools/list -> close.
100
+
101
+ Newline-delimited JSON-RPC, per the MCP stdio transport. No SDK dependency,
102
+ so this keeps working when the SDK's API moves.
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ command: str | list[str],
108
+ timeout: float = 30.0,
109
+ env: dict[str, str] | None = None,
110
+ ) -> None:
111
+ """``command`` may be a shell string or an argv list.
112
+
113
+ Prefer the list form: it sidesteps quoting entirely, which matters on
114
+ Windows where ``shell=True`` re-parses every argument through cmd.exe.
115
+ """
116
+ self.command = command
117
+ self.timeout = timeout
118
+ self.env = env
119
+ self._label = command if isinstance(command, str) else " ".join(command)
120
+
121
+ def __enter__(self) -> StdioSession:
122
+ creationflags = 0
123
+ if sys.platform == "win32": # keep a stray console from flashing up
124
+ creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
125
+
126
+ child_env = None
127
+ if self.env:
128
+ child_env = {**os.environ, **self.env}
129
+
130
+ try:
131
+ self.proc = subprocess.Popen(
132
+ self.command,
133
+ shell=isinstance(self.command, str),
134
+ stdin=subprocess.PIPE,
135
+ stdout=subprocess.PIPE,
136
+ stderr=subprocess.PIPE,
137
+ text=True,
138
+ encoding="utf-8",
139
+ errors="replace",
140
+ bufsize=1,
141
+ creationflags=creationflags,
142
+ env=child_env,
143
+ )
144
+ except OSError as exc:
145
+ raise LoadError(f"Could not start {self._label!r}: {exc}") from exc
146
+ return self
147
+
148
+ def __exit__(self, *exc_info) -> None:
149
+ proc = getattr(self, "proc", None)
150
+ if proc is None:
151
+ return
152
+ with contextlib.suppress(OSError, ValueError):
153
+ if proc.stdin:
154
+ proc.stdin.close()
155
+ try:
156
+ proc.terminate()
157
+ proc.wait(timeout=5)
158
+ except Exception: # noqa: BLE001 - teardown must never mask the real error
159
+ with contextlib.suppress(Exception):
160
+ proc.kill()
161
+
162
+ def _send(self, message: dict[str, Any]) -> None:
163
+ assert self.proc.stdin is not None
164
+ self.proc.stdin.write(json.dumps(message) + "\n")
165
+ self.proc.stdin.flush()
166
+
167
+ def _read_until(self, msg_id: int) -> dict[str, Any]:
168
+ assert self.proc.stdout is not None
169
+ while True:
170
+ line = self.proc.stdout.readline()
171
+ if not line:
172
+ stderr = ""
173
+ if self.proc.stderr:
174
+ try:
175
+ stderr = self.proc.stderr.read()[:800]
176
+ except Exception: # noqa: BLE001
177
+ stderr = ""
178
+ raise LoadError(
179
+ f"Server exited before replying to id={msg_id}. stderr: {stderr.strip() or '(empty)'}"
180
+ )
181
+ line = line.strip()
182
+ if not line:
183
+ continue
184
+ try:
185
+ message = json.loads(line)
186
+ except json.JSONDecodeError:
187
+ continue # servers do log to stdout sometimes
188
+ if isinstance(message, dict) and message.get("id") == msg_id:
189
+ if "error" in message:
190
+ raise LoadError(f"Server returned an error: {message['error']}")
191
+ return message
192
+
193
+ def list_tools(self) -> list[Tool]:
194
+ self._send(
195
+ {
196
+ "jsonrpc": "2.0",
197
+ "id": 1,
198
+ "method": "initialize",
199
+ "params": {
200
+ "protocolVersion": PROTOCOL_VERSION,
201
+ "capabilities": {},
202
+ "clientInfo": {"name": "agentcheckup", "version": "0.1.0"},
203
+ },
204
+ }
205
+ )
206
+ self._read_until(1)
207
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
208
+ self._send({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
209
+ reply = self._read_until(2)
210
+ result = reply.get("result") or {}
211
+ tools = result.get("tools") if isinstance(result, dict) else None
212
+ if not isinstance(tools, list):
213
+ raise LoadError("Server replied to tools/list without a tools array.")
214
+ return [Tool.from_raw(item) for item in tools if isinstance(item, dict)]