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.
Files changed (48) hide show
  1. agent_capmap-0.1.0.dist-info/METADATA +295 -0
  2. agent_capmap-0.1.0.dist-info/RECORD +48 -0
  3. agent_capmap-0.1.0.dist-info/WHEEL +4 -0
  4. agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
  5. agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. agentcapmap/__init__.py +3 -0
  7. agentcapmap/__main__.py +5 -0
  8. agentcapmap/baseline.py +125 -0
  9. agentcapmap/classify/__init__.py +221 -0
  10. agentcapmap/classify/cache.py +74 -0
  11. agentcapmap/classify/prompt.py +160 -0
  12. agentcapmap/classify/provider.py +122 -0
  13. agentcapmap/classify/reference.py +115 -0
  14. agentcapmap/classify/reference_labels.json +6135 -0
  15. agentcapmap/cli.py +392 -0
  16. agentcapmap/diff.py +103 -0
  17. agentcapmap/discovery/__init__.py +209 -0
  18. agentcapmap/discovery/_common.py +297 -0
  19. agentcapmap/discovery/claude_code.py +258 -0
  20. agentcapmap/discovery/claude_desktop.py +28 -0
  21. agentcapmap/discovery/cursor.py +33 -0
  22. agentcapmap/discovery/mcp_json.py +29 -0
  23. agentcapmap/discovery/vscode.py +65 -0
  24. agentcapmap/enumerate/__init__.py +278 -0
  25. agentcapmap/enumerate/hashing.py +37 -0
  26. agentcapmap/enumerate/http.py +313 -0
  27. agentcapmap/enumerate/netguard.py +60 -0
  28. agentcapmap/enumerate/stdio.py +273 -0
  29. agentcapmap/enumerate/transport.py +86 -0
  30. agentcapmap/evaluation.py +498 -0
  31. agentcapmap/findings.py +129 -0
  32. agentcapmap/graph/__init__.py +264 -0
  33. agentcapmap/graph/server_attrs.py +164 -0
  34. agentcapmap/limits.py +35 -0
  35. agentcapmap/models.py +186 -0
  36. agentcapmap/policies/default.yaml +113 -0
  37. agentcapmap/policy/__init__.py +24 -0
  38. agentcapmap/policy/engine.py +229 -0
  39. agentcapmap/policy/loader.py +223 -0
  40. agentcapmap/py.typed +0 -0
  41. agentcapmap/redact.py +268 -0
  42. agentcapmap/report/__init__.py +36 -0
  43. agentcapmap/report/_text.py +62 -0
  44. agentcapmap/report/markdown.py +152 -0
  45. agentcapmap/report/sarif.py +209 -0
  46. agentcapmap/schemas/policy.schema.json +172 -0
  47. agentcapmap/schemas/tool-tags.schema.json +57 -0
  48. 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))