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/__init__.py +27 -0
- agentcheckup/__main__.py +6 -0
- agentcheckup/cli.py +242 -0
- agentcheckup/conflicts.py +253 -0
- agentcheckup/discovery.py +206 -0
- agentcheckup/engine.py +146 -0
- agentcheckup/loader.py +214 -0
- agentcheckup/models.py +166 -0
- agentcheckup/report.py +274 -0
- agentcheckup/rules/__init__.py +12 -0
- agentcheckup/rules/base.py +91 -0
- agentcheckup/rules/config_rules.py +180 -0
- agentcheckup/rules/docs.py +124 -0
- agentcheckup/rules/injection.py +127 -0
- agentcheckup/rules/naming.py +100 -0
- agentcheckup/rules/safety.py +131 -0
- agentcheckup/rules/schema.py +143 -0
- agentcheckup/tokens.py +69 -0
- agentcheckup-0.4.0.dist-info/METADATA +261 -0
- agentcheckup-0.4.0.dist-info/RECORD +23 -0
- agentcheckup-0.4.0.dist-info/WHEEL +4 -0
- agentcheckup-0.4.0.dist-info/entry_points.txt +2 -0
- agentcheckup-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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)]
|