agent-capmap 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.
- agent_capmap-0.1.0.dist-info/METADATA +295 -0
- agent_capmap-0.1.0.dist-info/RECORD +48 -0
- agent_capmap-0.1.0.dist-info/WHEEL +4 -0
- agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
- agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentcapmap/__init__.py +3 -0
- agentcapmap/__main__.py +5 -0
- agentcapmap/baseline.py +125 -0
- agentcapmap/classify/__init__.py +221 -0
- agentcapmap/classify/cache.py +74 -0
- agentcapmap/classify/prompt.py +160 -0
- agentcapmap/classify/provider.py +122 -0
- agentcapmap/classify/reference.py +115 -0
- agentcapmap/classify/reference_labels.json +6135 -0
- agentcapmap/cli.py +392 -0
- agentcapmap/diff.py +103 -0
- agentcapmap/discovery/__init__.py +209 -0
- agentcapmap/discovery/_common.py +297 -0
- agentcapmap/discovery/claude_code.py +258 -0
- agentcapmap/discovery/claude_desktop.py +28 -0
- agentcapmap/discovery/cursor.py +33 -0
- agentcapmap/discovery/mcp_json.py +29 -0
- agentcapmap/discovery/vscode.py +65 -0
- agentcapmap/enumerate/__init__.py +278 -0
- agentcapmap/enumerate/hashing.py +37 -0
- agentcapmap/enumerate/http.py +313 -0
- agentcapmap/enumerate/netguard.py +60 -0
- agentcapmap/enumerate/stdio.py +273 -0
- agentcapmap/enumerate/transport.py +86 -0
- agentcapmap/evaluation.py +498 -0
- agentcapmap/findings.py +129 -0
- agentcapmap/graph/__init__.py +264 -0
- agentcapmap/graph/server_attrs.py +164 -0
- agentcapmap/limits.py +35 -0
- agentcapmap/models.py +186 -0
- agentcapmap/policies/default.yaml +113 -0
- agentcapmap/policy/__init__.py +24 -0
- agentcapmap/policy/engine.py +229 -0
- agentcapmap/policy/loader.py +223 -0
- agentcapmap/py.typed +0 -0
- agentcapmap/redact.py +268 -0
- agentcapmap/report/__init__.py +36 -0
- agentcapmap/report/_text.py +62 -0
- agentcapmap/report/markdown.py +152 -0
- agentcapmap/report/sarif.py +209 -0
- agentcapmap/schemas/policy.schema.json +172 -0
- agentcapmap/schemas/tool-tags.schema.json +57 -0
- agentcapmap/taxonomy.py +56 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Claude Code: project `.mcp.json`, the user's `~/.claude.json`, and permission settings.
|
|
2
|
+
|
|
3
|
+
Where Claude Code keeps MCP servers (https://code.claude.com/docs/en/mcp):
|
|
4
|
+
- user scope: top-level `mcpServers` in `~/.claude.json` (every project)
|
|
5
|
+
- local scope: `projects.<path>.mcpServers` in `~/.claude.json` (one project, private)
|
|
6
|
+
- project scope: `.mcp.json` at the project root (shared through version control)
|
|
7
|
+
The same name in several scopes resolves local > project > user, whole entry.
|
|
8
|
+
|
|
9
|
+
Approval (https://code.claude.com/docs/en/permissions): in the default permission
|
|
10
|
+
mode Claude Code asks before an MCP tool call unless a `permissions.allow` rule
|
|
11
|
+
(`mcp__<server>`, `mcp__<server>__*`, `mcp__<server>__<tool>`) covers it.
|
|
12
|
+
`bypassPermissions` and `auto` modes skip the human prompt; `dontAsk` refuses calls that
|
|
13
|
+
would prompt; `permissions.deny` rules remove tools.
|
|
14
|
+
|
|
15
|
+
`~/.claude.json` also holds account data (and possibly `primaryApiKey`). Only MCP
|
|
16
|
+
server entries and per-project MCP toggles are read from it; nothing else is output.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc, parse_server_map
|
|
30
|
+
from agentcapmap.limits import MAX_CONFIG_BYTES
|
|
31
|
+
from agentcapmap.models import ApprovalConfig, Server
|
|
32
|
+
from agentcapmap.redact import get_logger, known_secrets
|
|
33
|
+
|
|
34
|
+
log = get_logger(__name__)
|
|
35
|
+
|
|
36
|
+
# ~/.claude.json grows with per-project history; allow more than a normal config.
|
|
37
|
+
USER_CONFIG_MAX_BYTES = 64 * 1024 * 1024
|
|
38
|
+
_RULE_NAME = re.compile(r"[^A-Za-z0-9_-]")
|
|
39
|
+
_PROMPTING_MODES = {None, "default", "manual", "acceptEdits", "plan"}
|
|
40
|
+
_NO_PROMPT_MODES = {"bypassPermissions", "auto"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Permissions:
|
|
45
|
+
allow: list[str] = field(default_factory=list)
|
|
46
|
+
deny: list[str] = field(default_factory=list)
|
|
47
|
+
mode: str | None = None
|
|
48
|
+
sources: list[str] = field(default_factory=list)
|
|
49
|
+
disabled_mcpjson: set[str] = field(default_factory=set)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_json(path: Path, limit: int = MAX_CONFIG_BYTES) -> dict[str, Any] | None:
|
|
53
|
+
try:
|
|
54
|
+
if not path.is_file() or path.stat().st_size > limit:
|
|
55
|
+
return None
|
|
56
|
+
data = load_jsonc(path.read_text(encoding="utf-8-sig"))
|
|
57
|
+
except (OSError, UnicodeDecodeError, ConfigError) as exc:
|
|
58
|
+
log.warning("ignoring unreadable Claude Code file %s: %s", path.name, exc)
|
|
59
|
+
return None
|
|
60
|
+
return data if isinstance(data, dict) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strings(value: Any) -> list[str]:
|
|
64
|
+
return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_permissions(settings_files: list[Path]) -> Permissions:
|
|
68
|
+
"""Merge permission settings; later files (higher precedence) win for the mode."""
|
|
69
|
+
perms = Permissions()
|
|
70
|
+
for path in settings_files:
|
|
71
|
+
data = _read_json(path)
|
|
72
|
+
if data is None:
|
|
73
|
+
continue
|
|
74
|
+
raw = data.get("permissions")
|
|
75
|
+
section: dict[str, Any] = raw if isinstance(raw, dict) else {}
|
|
76
|
+
perms.allow += _strings(section.get("allow"))
|
|
77
|
+
perms.deny += _strings(section.get("deny"))
|
|
78
|
+
mode = section.get("defaultMode", data.get("defaultMode"))
|
|
79
|
+
if isinstance(mode, str):
|
|
80
|
+
perms.mode = mode
|
|
81
|
+
perms.disabled_mcpjson |= set(_strings(data.get("disabledMcpjsonServers")))
|
|
82
|
+
perms.sources.append(path.name if path.parent.name != ".claude" else f".claude/{path.name}")
|
|
83
|
+
return perms
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _tool_pattern(rule: str, server_key: str) -> str | None:
|
|
87
|
+
"""The tool-name glob an `mcp__...` rule grants for this server, if any."""
|
|
88
|
+
if "(" in rule: # Claude Code skips parameterized mcp__ rules in settings files
|
|
89
|
+
return None
|
|
90
|
+
if rule == server_key:
|
|
91
|
+
return "*"
|
|
92
|
+
prefix = f"{server_key}__"
|
|
93
|
+
if rule.startswith(prefix):
|
|
94
|
+
return rule[len(prefix) :] or "*"
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def server_approval(
|
|
99
|
+
name: str, perms: Permissions, extra_allow: Sequence[str] = ()
|
|
100
|
+
) -> ApprovalConfig:
|
|
101
|
+
key = "mcp__" + _RULE_NAME.sub("_", name)
|
|
102
|
+
allow = [p for r in [*perms.allow, *extra_allow] if (p := _tool_pattern(r, key))]
|
|
103
|
+
deny = [p for r in perms.deny if (p := _tool_pattern(r, key))]
|
|
104
|
+
if any(r in ("*", "mcp__*") for r in perms.deny):
|
|
105
|
+
deny.append("*")
|
|
106
|
+
mode = perms.mode
|
|
107
|
+
gate: Literal["required", "none", "unknown", "blocked"]
|
|
108
|
+
if mode in _NO_PROMPT_MODES:
|
|
109
|
+
gate, reason = "none", f"Claude Code permission mode {mode!r} skips approval prompts"
|
|
110
|
+
elif mode == "dontAsk":
|
|
111
|
+
gate, reason = "blocked", "Claude Code 'dontAsk' mode refuses calls that need approval"
|
|
112
|
+
elif mode in _PROMPTING_MODES:
|
|
113
|
+
gate, reason = "required", "Claude Code asks before MCP tool calls in this permission mode"
|
|
114
|
+
else:
|
|
115
|
+
gate, reason = "unknown", f"unrecognized Claude Code permission mode {mode!r}"
|
|
116
|
+
return ApprovalConfig(
|
|
117
|
+
auto_approve_all="*" in allow,
|
|
118
|
+
auto_approved_patterns=sorted(set(allow)),
|
|
119
|
+
denied_patterns=sorted(set(deny)),
|
|
120
|
+
default_gate=gate,
|
|
121
|
+
default_reason=reason,
|
|
122
|
+
sources=[f"settings: {', '.join(perms.sources)}"] if perms.sources else [],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def tool_matches(patterns: list[str], tool: str) -> bool:
|
|
127
|
+
return any(fnmatch.fnmatchcase(tool, p) for p in patterns)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _project_settings(project: Path) -> list[Path]:
|
|
131
|
+
return [project / ".claude" / "settings.json", project / ".claude" / "settings.local.json"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply(servers: list[Server], perms: Permissions, extra_allow: Sequence[str] = ()) -> None:
|
|
135
|
+
for server in servers:
|
|
136
|
+
server.approval = server_approval(server.name, perms, extra_allow)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- project .mcp.json ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _parse_mcp_json(text: str, path: Path, display: str) -> list[Server]:
|
|
143
|
+
data = load_jsonc(text)
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
raise ConfigError("top level must be an object")
|
|
146
|
+
return parse_server_map(
|
|
147
|
+
data.get("mcpServers"),
|
|
148
|
+
config_path=display,
|
|
149
|
+
text=text,
|
|
150
|
+
section_key="mcpServers",
|
|
151
|
+
base_dir=path.parent,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_project(text: str, path: Path, display: str) -> list[Server]:
|
|
156
|
+
"""A repository's `.mcp.json`, with approvals from that project's own settings only
|
|
157
|
+
(a repository scan must not depend on the scanning machine's user settings)."""
|
|
158
|
+
servers = _parse_mcp_json(text, path, display)
|
|
159
|
+
perms = read_permissions(_project_settings(path.parent))
|
|
160
|
+
servers = [s for s in servers if s.name not in perms.disabled_mcpjson]
|
|
161
|
+
_apply(servers, perms)
|
|
162
|
+
return servers
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- ~/.claude.json ---------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _merge(*scopes: list[Server]) -> list[Server]:
|
|
169
|
+
"""First scope wins per name (pass highest precedence first)."""
|
|
170
|
+
merged: dict[str, Server] = {}
|
|
171
|
+
for scope in scopes:
|
|
172
|
+
for server in scope:
|
|
173
|
+
merged.setdefault(server.name, server)
|
|
174
|
+
return list(merged.values())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _section_start(text: str, project_key: str) -> int:
|
|
178
|
+
"""Offset just after the project's key, matching the JSON-escaped form in the file."""
|
|
179
|
+
match = re.search(re.escape(json.dumps(project_key)) + r"\s*:", text)
|
|
180
|
+
return match.end() if match else 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_user_agents(text: str, path: Path, display: str) -> list[tuple[str, list[Server]]]:
|
|
184
|
+
data = load_jsonc(text)
|
|
185
|
+
if not isinstance(data, dict):
|
|
186
|
+
raise ConfigError("top level must be an object")
|
|
187
|
+
if isinstance(data.get("primaryApiKey"), str):
|
|
188
|
+
known_secrets.add(data["primaryApiKey"]) # never output, scrubbed if it ever appears
|
|
189
|
+
|
|
190
|
+
user_settings = [path.parent / ".claude" / "settings.json"]
|
|
191
|
+
|
|
192
|
+
def user_scope() -> list[Server]:
|
|
193
|
+
return parse_server_map(
|
|
194
|
+
data.get("mcpServers"),
|
|
195
|
+
config_path=display,
|
|
196
|
+
text=text,
|
|
197
|
+
section_key="mcpServers",
|
|
198
|
+
base_dir=path.parent,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
user_servers = user_scope()
|
|
202
|
+
_apply(user_servers, read_permissions(user_settings))
|
|
203
|
+
agents: list[tuple[str, list[Server]]] = [("user", user_servers)]
|
|
204
|
+
|
|
205
|
+
projects = data.get("projects")
|
|
206
|
+
for project_key, entry in sorted(projects.items() if isinstance(projects, dict) else []):
|
|
207
|
+
if not isinstance(entry, dict):
|
|
208
|
+
continue
|
|
209
|
+
project = Path(project_key)
|
|
210
|
+
local = parse_server_map(
|
|
211
|
+
entry.get("mcpServers"),
|
|
212
|
+
config_path=display,
|
|
213
|
+
text=text,
|
|
214
|
+
section_key="mcpServers",
|
|
215
|
+
base_dir=project,
|
|
216
|
+
search_from=_section_start(text, project_key),
|
|
217
|
+
)
|
|
218
|
+
mcp_json = project / ".mcp.json"
|
|
219
|
+
shared: list[Server] = []
|
|
220
|
+
if mcp_json.is_file():
|
|
221
|
+
try:
|
|
222
|
+
shared_text = mcp_json.read_text(encoding="utf-8-sig")
|
|
223
|
+
shared = _parse_mcp_json(shared_text, mcp_json, mcp_json.as_posix())
|
|
224
|
+
except (OSError, UnicodeDecodeError, ConfigError) as exc:
|
|
225
|
+
log.warning("ignoring %s: %s", mcp_json.as_posix(), exc)
|
|
226
|
+
if not local and not shared:
|
|
227
|
+
continue # same reach as the user-scope agent
|
|
228
|
+
|
|
229
|
+
perms = read_permissions([*user_settings, *_project_settings(project)])
|
|
230
|
+
perms.disabled_mcpjson |= set(_strings(entry.get("disabledMcpjsonServers")))
|
|
231
|
+
shared = [s for s in shared if s.name not in perms.disabled_mcpjson]
|
|
232
|
+
servers = _merge(local, shared, user_scope())
|
|
233
|
+
toggled_off = set(_strings(entry.get("disabledMcpServers")))
|
|
234
|
+
for server in servers:
|
|
235
|
+
if server.name in toggled_off:
|
|
236
|
+
server.disabled = True
|
|
237
|
+
_apply(servers, perms, _strings(entry.get("allowedTools")))
|
|
238
|
+
agents.append((f"project:{project.as_posix()}", servers))
|
|
239
|
+
return agents
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_user(text: str, path: Path, display: str) -> list[Server]:
|
|
243
|
+
return parse_user_agents(text, path, display)[0][1]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
FORMATS = [
|
|
247
|
+
ConfigFormat(
|
|
248
|
+
"claude_code_user",
|
|
249
|
+
"claude-code",
|
|
250
|
+
lambda p: p.name.lower() == ".claude.json",
|
|
251
|
+
_parse_user,
|
|
252
|
+
parse_agents=parse_user_agents,
|
|
253
|
+
max_bytes=USER_CONFIG_MAX_BYTES,
|
|
254
|
+
),
|
|
255
|
+
ConfigFormat(
|
|
256
|
+
"claude_code", "claude-code", lambda p: p.name.lower() == ".mcp.json", parse_project
|
|
257
|
+
),
|
|
258
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Claude Desktop: claude_desktop_config.json with a top-level `mcpServers` map."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc, parse_server_map
|
|
8
|
+
from agentcapmap.models import Server
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def matches(path: Path) -> bool:
|
|
12
|
+
return path.name.lower() == "claude_desktop_config.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse(text: str, path: Path, display_path: str) -> list[Server]:
|
|
16
|
+
data = load_jsonc(text)
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
raise ConfigError("top level must be an object")
|
|
19
|
+
return parse_server_map(
|
|
20
|
+
data.get("mcpServers"),
|
|
21
|
+
config_path=display_path,
|
|
22
|
+
text=text,
|
|
23
|
+
section_key="mcpServers",
|
|
24
|
+
base_dir=path.parent,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
FORMATS = [ConfigFormat("claude_desktop", "claude-desktop", matches, parse)]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Cursor: `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global).
|
|
2
|
+
|
|
3
|
+
Same `mcpServers` shape as Claude Desktop, but entries may be remote (`url` + `headers`)
|
|
4
|
+
and values may use `${env:NAME}` interpolation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc, parse_server_map
|
|
12
|
+
from agentcapmap.models import Server
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def matches(path: Path) -> bool:
|
|
16
|
+
return path.name.lower() == "mcp.json" and path.parent.name.lower() == ".cursor"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse(text: str, path: Path, display_path: str) -> list[Server]:
|
|
20
|
+
data = load_jsonc(text)
|
|
21
|
+
if not isinstance(data, dict):
|
|
22
|
+
raise ConfigError("top level must be an object")
|
|
23
|
+
# Workspace root is the directory containing .cursor/.
|
|
24
|
+
return parse_server_map(
|
|
25
|
+
data.get("mcpServers"),
|
|
26
|
+
config_path=display_path,
|
|
27
|
+
text=text,
|
|
28
|
+
section_key="mcpServers",
|
|
29
|
+
base_dir=path.parent.parent,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
FORMATS = [ConfigFormat("cursor", "cursor", matches, parse)]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Generic `mcp.json` files.
|
|
2
|
+
|
|
3
|
+
Uses the `mcpServers` map. A generic `mcp.json` that uses `servers` instead is treated
|
|
4
|
+
as VS Code-shaped. (Claude Code's `.mcp.json` lives in `claude_code.py`.)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc, parse_server_map
|
|
12
|
+
from agentcapmap.models import Server
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse(text: str, path: Path, display_path: str) -> list[Server]:
|
|
16
|
+
data = load_jsonc(text)
|
|
17
|
+
if not isinstance(data, dict):
|
|
18
|
+
raise ConfigError("top level must be an object")
|
|
19
|
+
key = "servers" if "servers" in data and "mcpServers" not in data else "mcpServers"
|
|
20
|
+
return parse_server_map(
|
|
21
|
+
data.get(key),
|
|
22
|
+
config_path=display_path,
|
|
23
|
+
text=text,
|
|
24
|
+
section_key=key,
|
|
25
|
+
base_dir=path.parent,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
FORMATS = [ConfigFormat("mcp_json", "generic", lambda p: p.name.lower() == "mcp.json", parse)]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""VS Code: `.vscode/mcp.json`, user-profile `mcp.json`, and the `mcp` key in settings.json.
|
|
2
|
+
|
|
3
|
+
VS Code files are JSONC. Servers live under `servers` (not `mcpServers`), declare
|
|
4
|
+
`type` explicitly, and may reference prompted secrets via `${input:id}`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc, parse_server_map
|
|
13
|
+
from agentcapmap.models import Server
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _workspace_root(path: Path) -> Path:
|
|
17
|
+
return path.parent.parent if path.parent.name.lower() == ".vscode" else path.parent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def matches_mcp_json(path: Path) -> bool:
|
|
21
|
+
return path.name.lower() == "mcp.json" and path.parent.name.lower() in (".vscode", "user")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_mcp_json(text: str, path: Path, display_path: str) -> list[Server]:
|
|
25
|
+
data = load_jsonc(text)
|
|
26
|
+
if not isinstance(data, dict):
|
|
27
|
+
raise ConfigError("top level must be an object")
|
|
28
|
+
return parse_server_map(
|
|
29
|
+
data.get("servers"),
|
|
30
|
+
config_path=display_path,
|
|
31
|
+
text=text,
|
|
32
|
+
section_key="servers",
|
|
33
|
+
base_dir=_workspace_root(path),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def matches_settings(path: Path) -> bool:
|
|
38
|
+
return path.name.lower() == "settings.json" and path.parent.name.lower() in (".vscode", "user")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_settings(text: str, path: Path, display_path: str) -> list[Server]:
|
|
42
|
+
"""settings.json with an embedded `"mcp": {"servers": {...}}` block."""
|
|
43
|
+
data = load_jsonc(text)
|
|
44
|
+
if not isinstance(data, dict):
|
|
45
|
+
raise ConfigError("top level must be an object")
|
|
46
|
+
mcp = data.get("mcp")
|
|
47
|
+
if mcp is None:
|
|
48
|
+
return []
|
|
49
|
+
if not isinstance(mcp, dict):
|
|
50
|
+
raise ConfigError("'mcp' must be an object")
|
|
51
|
+
m = re.search(r'"mcp"\s*:', text)
|
|
52
|
+
return parse_server_map(
|
|
53
|
+
mcp.get("servers"),
|
|
54
|
+
config_path=display_path,
|
|
55
|
+
text=text,
|
|
56
|
+
section_key="servers",
|
|
57
|
+
base_dir=_workspace_root(path),
|
|
58
|
+
search_from=m.end() if m else 0,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
FORMATS = [
|
|
63
|
+
ConfigFormat("vscode", "vscode", matches_mcp_json, parse_mcp_json),
|
|
64
|
+
ConfigFormat("vscode_settings", "vscode", matches_settings, parse_settings),
|
|
65
|
+
]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Connect to MCP servers and list their tools. Only `initialize` and `tools/list` are sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from agentcapmap import __version__
|
|
15
|
+
from agentcapmap.enumerate.hashing import server_definition_hash, tool_definition_hash
|
|
16
|
+
from agentcapmap.enumerate.http import LegacySseTransport, StreamableHttpTransport
|
|
17
|
+
from agentcapmap.enumerate.netguard import check_remote_url
|
|
18
|
+
from agentcapmap.enumerate.stdio import StdioTransport
|
|
19
|
+
from agentcapmap.enumerate.transport import (
|
|
20
|
+
AuthRequired,
|
|
21
|
+
MCPError,
|
|
22
|
+
RequestTimeout,
|
|
23
|
+
Transport,
|
|
24
|
+
TransportError,
|
|
25
|
+
)
|
|
26
|
+
from agentcapmap.limits import MAX_CONFIG_BYTES, MAX_TOOLS_PER_SERVER
|
|
27
|
+
from agentcapmap.models import (
|
|
28
|
+
AuthHint,
|
|
29
|
+
AuthMethod,
|
|
30
|
+
EnumerationResult,
|
|
31
|
+
EnumerationStatus,
|
|
32
|
+
Inventory,
|
|
33
|
+
LaunchSpec,
|
|
34
|
+
Server,
|
|
35
|
+
ToolDefinition,
|
|
36
|
+
)
|
|
37
|
+
from agentcapmap.models import Transport as TransportKind
|
|
38
|
+
from agentcapmap.redact import get_logger, known_secrets, redact_text
|
|
39
|
+
|
|
40
|
+
log = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
43
|
+
DEFAULT_TIMEOUT = 30.0
|
|
44
|
+
_MAX_PAGES = 100
|
|
45
|
+
_VAR = re.compile(r"\$\{([^}]+)\}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UnresolvedVariable(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_env_file(path: Path) -> dict[str, str]:
|
|
53
|
+
values: dict[str, str] = {}
|
|
54
|
+
if path.stat().st_size > MAX_CONFIG_BYTES:
|
|
55
|
+
raise ValueError("envFile too large")
|
|
56
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
57
|
+
line = line.strip()
|
|
58
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
59
|
+
continue
|
|
60
|
+
key, _, value = line.removeprefix("export ").partition("=")
|
|
61
|
+
value = value.strip()
|
|
62
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
63
|
+
value = value[1:-1]
|
|
64
|
+
values[key.strip()] = value
|
|
65
|
+
return values
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolver(spec: LaunchSpec, env: dict[str, str]) -> Callable[[str], str]:
|
|
69
|
+
base_dir = spec.base_dir or str(Path.cwd())
|
|
70
|
+
|
|
71
|
+
def resolve(text: str) -> str:
|
|
72
|
+
def sub(m: re.Match[str]) -> str:
|
|
73
|
+
name = m.group(1).strip()
|
|
74
|
+
if name in ("workspaceFolder", "workspaceRoot"):
|
|
75
|
+
return base_dir
|
|
76
|
+
if name == "userHome":
|
|
77
|
+
return str(Path.home())
|
|
78
|
+
if name.startswith("input:"):
|
|
79
|
+
raise UnresolvedVariable(f"requires interactive input ${{{name}}}")
|
|
80
|
+
if name.startswith("env:"):
|
|
81
|
+
name = name[4:]
|
|
82
|
+
name, _, default = name.partition(":-")
|
|
83
|
+
if name in env:
|
|
84
|
+
return env[name]
|
|
85
|
+
if default:
|
|
86
|
+
return default
|
|
87
|
+
raise UnresolvedVariable(f"environment variable {name} is not set")
|
|
88
|
+
|
|
89
|
+
return _VAR.sub(sub, text)
|
|
90
|
+
|
|
91
|
+
return resolve
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_launch(spec: LaunchSpec) -> LaunchSpec:
|
|
95
|
+
"""Expand ${...} references the way MCP clients do. Raises UnresolvedVariable."""
|
|
96
|
+
lookup_env = dict(os.environ)
|
|
97
|
+
resolve = _resolver(spec, lookup_env)
|
|
98
|
+
file_env: dict[str, str] = {}
|
|
99
|
+
if spec.env_file:
|
|
100
|
+
env_path = Path(resolve(spec.env_file))
|
|
101
|
+
if not env_path.is_absolute():
|
|
102
|
+
env_path = Path(spec.base_dir or ".") / env_path
|
|
103
|
+
try:
|
|
104
|
+
file_env = _load_env_file(env_path)
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
raise UnresolvedVariable(f"cannot read envFile: {exc.strerror}") from None
|
|
107
|
+
except (UnicodeDecodeError, ValueError):
|
|
108
|
+
raise UnresolvedVariable("cannot read envFile: not a UTF-8 text file") from None
|
|
109
|
+
for value in file_env.values():
|
|
110
|
+
known_secrets.add(value)
|
|
111
|
+
lookup_env.update(file_env)
|
|
112
|
+
env = {**file_env, **{k: resolve(v) for k, v in spec.env.items()}}
|
|
113
|
+
lookup_env.update(env)
|
|
114
|
+
resolve = _resolver(spec, lookup_env)
|
|
115
|
+
resolved = LaunchSpec(
|
|
116
|
+
command=resolve(spec.command) if spec.command else None,
|
|
117
|
+
args=[resolve(a) for a in spec.args],
|
|
118
|
+
env=env,
|
|
119
|
+
cwd=resolve(spec.cwd) if spec.cwd else spec.base_dir,
|
|
120
|
+
url=resolve(spec.url) if spec.url else None,
|
|
121
|
+
headers={k: resolve(v) for k, v in spec.headers.items()},
|
|
122
|
+
base_dir=spec.base_dir,
|
|
123
|
+
)
|
|
124
|
+
for value in [*resolved.env.values(), *resolved.headers.values()]:
|
|
125
|
+
known_secrets.add(value)
|
|
126
|
+
return resolved
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def open_transport(server: Server, spec: LaunchSpec, timeout: float) -> Transport:
|
|
130
|
+
if server.transport is TransportKind.STDIO:
|
|
131
|
+
if not spec.command:
|
|
132
|
+
raise TransportError("stdio server has no command")
|
|
133
|
+
cwd = spec.cwd if spec.cwd and Path(spec.cwd).is_dir() else None
|
|
134
|
+
return StdioTransport(spec.command, spec.args, spec.env, cwd)
|
|
135
|
+
if not spec.url:
|
|
136
|
+
raise TransportError("remote server has no URL")
|
|
137
|
+
if urlsplit(spec.url).scheme.lower() not in ("http", "https"):
|
|
138
|
+
raise TransportError("remote server URL must use http or https")
|
|
139
|
+
check_remote_url(spec.url)
|
|
140
|
+
if server.transport is TransportKind.SSE:
|
|
141
|
+
return LegacySseTransport(spec.url, spec.headers, timeout)
|
|
142
|
+
return StreamableHttpTransport(spec.url, spec.headers, timeout)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse_tools(raw_tools: Any) -> list[ToolDefinition]:
|
|
146
|
+
if not isinstance(raw_tools, list):
|
|
147
|
+
raise TransportError("tools/list result has no 'tools' array")
|
|
148
|
+
tools: list[ToolDefinition] = []
|
|
149
|
+
for raw in raw_tools:
|
|
150
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("name"), str):
|
|
151
|
+
log.debug("skipping malformed tool entry")
|
|
152
|
+
continue
|
|
153
|
+
description = raw.get("description")
|
|
154
|
+
schema = raw.get("inputSchema")
|
|
155
|
+
annotations = raw.get("annotations")
|
|
156
|
+
description = description if isinstance(description, str) else ""
|
|
157
|
+
schema = schema if isinstance(schema, dict) else {}
|
|
158
|
+
tools.append(
|
|
159
|
+
ToolDefinition(
|
|
160
|
+
name=raw["name"],
|
|
161
|
+
description=description,
|
|
162
|
+
input_schema=schema,
|
|
163
|
+
annotations=annotations if isinstance(annotations, dict) else None,
|
|
164
|
+
definition_hash=tool_definition_hash(raw["name"], description, schema),
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return tools
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def list_tools(transport: Transport, timeout: float) -> EnumerationResult:
|
|
171
|
+
"""Run the handshake and page through tools/list within one overall deadline."""
|
|
172
|
+
deadline = time.monotonic() + timeout
|
|
173
|
+
|
|
174
|
+
def remaining() -> float:
|
|
175
|
+
left = deadline - time.monotonic()
|
|
176
|
+
if left <= 0:
|
|
177
|
+
raise RequestTimeout(f"enumeration exceeded {timeout:.0f}s")
|
|
178
|
+
return left
|
|
179
|
+
|
|
180
|
+
init = transport.request(
|
|
181
|
+
"initialize",
|
|
182
|
+
{
|
|
183
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
184
|
+
"capabilities": {},
|
|
185
|
+
"clientInfo": {"name": "agent-capmap", "version": __version__},
|
|
186
|
+
},
|
|
187
|
+
remaining(),
|
|
188
|
+
)
|
|
189
|
+
transport.notify("notifications/initialized")
|
|
190
|
+
server_info = init.get("serverInfo") if isinstance(init.get("serverInfo"), dict) else None
|
|
191
|
+
protocol_version = init.get("protocolVersion")
|
|
192
|
+
capabilities = init.get("capabilities") if isinstance(init.get("capabilities"), dict) else {}
|
|
193
|
+
|
|
194
|
+
tools: list[ToolDefinition] = []
|
|
195
|
+
if capabilities is not None and "tools" in capabilities:
|
|
196
|
+
cursor: str | None = None
|
|
197
|
+
seen_cursors: set[str] = set()
|
|
198
|
+
for _ in range(_MAX_PAGES):
|
|
199
|
+
params: dict[str, Any] = {"cursor": cursor} if cursor else {}
|
|
200
|
+
page = transport.request("tools/list", params, remaining())
|
|
201
|
+
tools.extend(_parse_tools(page.get("tools")))
|
|
202
|
+
if len(tools) > MAX_TOOLS_PER_SERVER:
|
|
203
|
+
raise TransportError(f"server returned more than {MAX_TOOLS_PER_SERVER} tools")
|
|
204
|
+
next_cursor = page.get("nextCursor")
|
|
205
|
+
if not isinstance(next_cursor, str) or not next_cursor or next_cursor in seen_cursors:
|
|
206
|
+
break
|
|
207
|
+
seen_cursors.add(next_cursor)
|
|
208
|
+
cursor = next_cursor
|
|
209
|
+
|
|
210
|
+
return EnumerationResult(
|
|
211
|
+
status=EnumerationStatus.OK,
|
|
212
|
+
protocol_version=protocol_version if isinstance(protocol_version, str) else None,
|
|
213
|
+
server_info=server_info,
|
|
214
|
+
tools=tools,
|
|
215
|
+
definition_hash=server_definition_hash(t.definition_hash for t in tools),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def enumerate_server(server: Server, timeout: float = DEFAULT_TIMEOUT) -> EnumerationResult:
|
|
220
|
+
"""Enumerate one server's tools. Never raises; failures become a non-OK status."""
|
|
221
|
+
if server.disabled:
|
|
222
|
+
return EnumerationResult(status=EnumerationStatus.SKIPPED, error="disabled in config")
|
|
223
|
+
if server.transport is TransportKind.WS:
|
|
224
|
+
return EnumerationResult(
|
|
225
|
+
status=EnumerationStatus.SKIPPED, error="WebSocket servers are not enumerated yet"
|
|
226
|
+
)
|
|
227
|
+
try:
|
|
228
|
+
spec = resolve_launch(server.launch)
|
|
229
|
+
except UnresolvedVariable as exc:
|
|
230
|
+
return EnumerationResult(status=EnumerationStatus.SKIPPED, error=redact_text(str(exc)))
|
|
231
|
+
|
|
232
|
+
transport: Transport | None = None
|
|
233
|
+
try:
|
|
234
|
+
transport = open_transport(server, spec, timeout)
|
|
235
|
+
result = list_tools(transport, timeout)
|
|
236
|
+
except AuthRequired as exc:
|
|
237
|
+
challenge = (exc.www_authenticate or "").lower()
|
|
238
|
+
if server.auth.method is AuthMethod.UNKNOWN and "bearer" in challenge:
|
|
239
|
+
server.auth = AuthHint(method=AuthMethod.OAUTH, evidence=["www-authenticate:Bearer"])
|
|
240
|
+
return EnumerationResult(
|
|
241
|
+
status=EnumerationStatus.AUTH_REQUIRED, error=redact_text(str(exc))
|
|
242
|
+
)
|
|
243
|
+
except RequestTimeout as exc:
|
|
244
|
+
return EnumerationResult(status=EnumerationStatus.TIMEOUT, error=redact_text(str(exc)))
|
|
245
|
+
except (TransportError, MCPError) as exc:
|
|
246
|
+
return EnumerationResult(status=EnumerationStatus.ERROR, error=redact_text(str(exc)))
|
|
247
|
+
finally:
|
|
248
|
+
if transport is not None:
|
|
249
|
+
try:
|
|
250
|
+
transport.close()
|
|
251
|
+
except Exception as exc: # cleanup must not mask the real result
|
|
252
|
+
log.debug("error closing transport for %s: %s", server.name, exc)
|
|
253
|
+
|
|
254
|
+
if server.transport is not TransportKind.STDIO and server.auth.method is AuthMethod.UNKNOWN:
|
|
255
|
+
server.auth = AuthHint(
|
|
256
|
+
method=AuthMethod.NONE, evidence=["tools/list succeeded without credentials"]
|
|
257
|
+
)
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def enumerate_inventory(
|
|
262
|
+
inventory: Inventory, *, timeout: float = DEFAULT_TIMEOUT, concurrency: int = 4
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Enumerate every server in place."""
|
|
265
|
+
servers: Iterable[Server] = [s for agent in inventory.agents for s in agent.servers]
|
|
266
|
+
|
|
267
|
+
def run(server: Server) -> None:
|
|
268
|
+
log.info("enumerating %s (%s)", server.name, server.transport.value)
|
|
269
|
+
server.enumeration = enumerate_server(server, timeout)
|
|
270
|
+
log.info(
|
|
271
|
+
" %s: %s, %d tool(s)",
|
|
272
|
+
server.name,
|
|
273
|
+
server.enumeration.status.value,
|
|
274
|
+
len(server.enumeration.tools),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
with ThreadPoolExecutor(max_workers=max(1, concurrency)) as pool:
|
|
278
|
+
list(pool.map(run, servers))
|