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,209 @@
|
|
|
1
|
+
"""Find MCP client configs and normalize them into `Agent -> [Server]`.
|
|
2
|
+
|
|
3
|
+
Each config file is one agent: the set of servers one client instance can reach.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Iterable, Iterator
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from agentcapmap.discovery import claude_code, claude_desktop, cursor, mcp_json, vscode
|
|
14
|
+
from agentcapmap.discovery._common import ConfigError, ConfigFormat, load_jsonc
|
|
15
|
+
from agentcapmap.limits import MAX_CONFIG_BYTES
|
|
16
|
+
from agentcapmap.models import Agent, DiscoveryError, Inventory
|
|
17
|
+
from agentcapmap.redact import get_logger
|
|
18
|
+
|
|
19
|
+
log = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
# Order matters: the first format whose `matches` accepts a path wins.
|
|
22
|
+
FORMATS: list[ConfigFormat] = [
|
|
23
|
+
*claude_code.FORMATS,
|
|
24
|
+
*claude_desktop.FORMATS,
|
|
25
|
+
*cursor.FORMATS,
|
|
26
|
+
*vscode.FORMATS,
|
|
27
|
+
*mcp_json.FORMATS,
|
|
28
|
+
]
|
|
29
|
+
_BY_NAME = {f.name: f for f in FORMATS}
|
|
30
|
+
|
|
31
|
+
CANDIDATE_NAMES = frozenset(
|
|
32
|
+
{"claude_desktop_config.json", "mcp.json", ".mcp.json", "settings.json"}
|
|
33
|
+
)
|
|
34
|
+
SKIP_DIRS = frozenset(
|
|
35
|
+
{
|
|
36
|
+
".git",
|
|
37
|
+
".hg",
|
|
38
|
+
".svn",
|
|
39
|
+
"node_modules",
|
|
40
|
+
".venv",
|
|
41
|
+
"venv",
|
|
42
|
+
"__pycache__",
|
|
43
|
+
".mypy_cache",
|
|
44
|
+
".pytest_cache",
|
|
45
|
+
".ruff_cache",
|
|
46
|
+
".tox",
|
|
47
|
+
"dist",
|
|
48
|
+
"build",
|
|
49
|
+
"site-packages",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = ["FORMATS", "ConfigError", "detect_format", "discover", "user_config_paths"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def detect_format(path: Path, text: str | None = None) -> ConfigFormat | None:
|
|
57
|
+
"""Pick a format from the file location, falling back to sniffing the content."""
|
|
58
|
+
for fmt in FORMATS:
|
|
59
|
+
if fmt.matches(path):
|
|
60
|
+
return fmt
|
|
61
|
+
if text is None:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
data = load_jsonc(text)
|
|
65
|
+
except ConfigError:
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(data, dict):
|
|
68
|
+
return None
|
|
69
|
+
if "mcpServers" in data:
|
|
70
|
+
return _BY_NAME["mcp_json"]
|
|
71
|
+
if isinstance(data.get("servers"), dict):
|
|
72
|
+
return _BY_NAME["vscode"]
|
|
73
|
+
mcp = data.get("mcp")
|
|
74
|
+
if isinstance(mcp, dict) and isinstance(mcp.get("servers"), dict):
|
|
75
|
+
return _BY_NAME["vscode_settings"]
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _display_path(path: Path, root: Path | None) -> str:
|
|
80
|
+
if root is not None:
|
|
81
|
+
try:
|
|
82
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
83
|
+
except ValueError:
|
|
84
|
+
pass
|
|
85
|
+
return path.as_posix()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_config_agents(
|
|
89
|
+
path: Path, *, root: Path | None = None, explicit: bool = False
|
|
90
|
+
) -> tuple[list[Agent], DiscoveryError | None]:
|
|
91
|
+
"""Parse one config file into its agents (most formats describe exactly one).
|
|
92
|
+
|
|
93
|
+
Discovered-by-walk files that turn out not to be MCP configs return ([], None);
|
|
94
|
+
`explicit` files the user named directly report an error instead.
|
|
95
|
+
"""
|
|
96
|
+
display = _display_path(path, root)
|
|
97
|
+
by_name = detect_format(path)
|
|
98
|
+
limit = (by_name.max_bytes if by_name else None) or MAX_CONFIG_BYTES
|
|
99
|
+
try:
|
|
100
|
+
if path.stat().st_size > limit:
|
|
101
|
+
return [], DiscoveryError(
|
|
102
|
+
path=display, message=f"file larger than {limit} bytes; skipped"
|
|
103
|
+
)
|
|
104
|
+
text = path.read_text(encoding="utf-8-sig")
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
return [], DiscoveryError(path=display, message=f"cannot read file: {exc.strerror}")
|
|
107
|
+
except UnicodeDecodeError:
|
|
108
|
+
return [], DiscoveryError(path=display, message="cannot read file: not UTF-8 text")
|
|
109
|
+
|
|
110
|
+
fmt = by_name or detect_format(path, text)
|
|
111
|
+
if fmt is None:
|
|
112
|
+
if explicit:
|
|
113
|
+
return [], DiscoveryError(path=display, message="not a recognized MCP config")
|
|
114
|
+
return [], None
|
|
115
|
+
try:
|
|
116
|
+
if fmt.parse_agents is not None:
|
|
117
|
+
groups = fmt.parse_agents(text, path, display)
|
|
118
|
+
else:
|
|
119
|
+
groups = [("", fmt.parse(text, path, display))]
|
|
120
|
+
except ConfigError as exc:
|
|
121
|
+
return [], DiscoveryError(path=display, message=str(exc))
|
|
122
|
+
|
|
123
|
+
agents = []
|
|
124
|
+
for suffix, servers in groups:
|
|
125
|
+
if not servers and not explicit and fmt.parse_agents is None:
|
|
126
|
+
continue # e.g. an unrelated settings.json
|
|
127
|
+
agent_id = f"{fmt.client}:{display}" + (f"#{suffix}" if suffix else "")
|
|
128
|
+
agents.append(Agent(id=agent_id, client=fmt.client, config_path=display, servers=servers))
|
|
129
|
+
log.debug("parsed %s as %s: %d agent(s)", display, fmt.name, len(agents))
|
|
130
|
+
return agents, None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_config_file(
|
|
134
|
+
path: Path, *, root: Path | None = None, explicit: bool = False
|
|
135
|
+
) -> tuple[Agent | None, DiscoveryError | None]:
|
|
136
|
+
"""Single-agent convenience wrapper around `parse_config_agents`."""
|
|
137
|
+
agents, error = parse_config_agents(path, root=root, explicit=explicit)
|
|
138
|
+
return (agents[0] if agents else None), error
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def iter_candidate_files(root: Path) -> Iterator[Path]:
|
|
142
|
+
"""Walk a directory tree yielding files whose names match a known config format."""
|
|
143
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
144
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
|
|
145
|
+
for name in sorted(filenames):
|
|
146
|
+
if name.lower() in CANDIDATE_NAMES:
|
|
147
|
+
path = Path(dirpath) / name
|
|
148
|
+
if detect_format(path) is not None:
|
|
149
|
+
yield path
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def user_config_paths() -> list[Path]:
|
|
153
|
+
"""Known per-user config locations for the supported clients on this OS."""
|
|
154
|
+
home = Path.home()
|
|
155
|
+
if sys.platform == "win32":
|
|
156
|
+
appdata = Path(os.environ.get("APPDATA", home / "AppData" / "Roaming"))
|
|
157
|
+
claude_dir, code_user = appdata / "Claude", appdata / "Code" / "User"
|
|
158
|
+
elif sys.platform == "darwin":
|
|
159
|
+
support = home / "Library" / "Application Support"
|
|
160
|
+
claude_dir, code_user = support / "Claude", support / "Code" / "User"
|
|
161
|
+
else:
|
|
162
|
+
config = Path(os.environ.get("XDG_CONFIG_HOME", home / ".config"))
|
|
163
|
+
claude_dir, code_user = config / "Claude", config / "Code" / "User"
|
|
164
|
+
claude_code_home = Path(os.environ.get("CLAUDE_CONFIG_DIR") or home)
|
|
165
|
+
return [
|
|
166
|
+
claude_code_home / ".claude.json",
|
|
167
|
+
claude_dir / "claude_desktop_config.json",
|
|
168
|
+
home / ".cursor" / "mcp.json",
|
|
169
|
+
code_user / "mcp.json",
|
|
170
|
+
code_user / "settings.json",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def discover(paths: Iterable[Path], *, user: bool = False, root: Path | None = None) -> Inventory:
|
|
175
|
+
"""Build an inventory from files and/or directories, plus user-level configs if asked.
|
|
176
|
+
|
|
177
|
+
Config paths in the output are relative to `root` when given (the CLI passes the
|
|
178
|
+
working directory, so SARIF paths are repository-relative), otherwise to each
|
|
179
|
+
scanned directory.
|
|
180
|
+
"""
|
|
181
|
+
inventory = Inventory()
|
|
182
|
+
seen: set[Path] = set()
|
|
183
|
+
|
|
184
|
+
def add(path: Path, root: Path | None, explicit: bool) -> None:
|
|
185
|
+
resolved = path.resolve()
|
|
186
|
+
if resolved in seen:
|
|
187
|
+
return
|
|
188
|
+
seen.add(resolved)
|
|
189
|
+
agents, error = parse_config_agents(path, root=root, explicit=explicit)
|
|
190
|
+
inventory.agents.extend(agents)
|
|
191
|
+
if error is not None:
|
|
192
|
+
log.warning("%s: %s", error.path, error.message)
|
|
193
|
+
inventory.errors.append(error)
|
|
194
|
+
|
|
195
|
+
for target in paths:
|
|
196
|
+
if target.is_dir():
|
|
197
|
+
for candidate in iter_candidate_files(target):
|
|
198
|
+
add(candidate, root or target, explicit=False)
|
|
199
|
+
elif target.is_file():
|
|
200
|
+
add(target, root or Path.cwd(), explicit=True)
|
|
201
|
+
else:
|
|
202
|
+
inventory.errors.append(DiscoveryError(path=str(target), message="path does not exist"))
|
|
203
|
+
|
|
204
|
+
if user:
|
|
205
|
+
for candidate in user_config_paths():
|
|
206
|
+
if candidate.is_file():
|
|
207
|
+
add(candidate, None, explicit=False)
|
|
208
|
+
|
|
209
|
+
return inventory
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Helpers shared by the per-format config parsers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from agentcapmap.models import (
|
|
13
|
+
ApprovalConfig,
|
|
14
|
+
AuthHint,
|
|
15
|
+
AuthMethod,
|
|
16
|
+
LaunchSpec,
|
|
17
|
+
Server,
|
|
18
|
+
SourceLocation,
|
|
19
|
+
Transport,
|
|
20
|
+
)
|
|
21
|
+
from agentcapmap.redact import (
|
|
22
|
+
is_sensitive_name,
|
|
23
|
+
known_secrets,
|
|
24
|
+
redact_args,
|
|
25
|
+
redact_text,
|
|
26
|
+
redact_url,
|
|
27
|
+
redact_value,
|
|
28
|
+
sensitive_arg_names,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(ValueError):
|
|
33
|
+
"""A config file exists but cannot be parsed into servers."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ConfigFormat:
|
|
38
|
+
"""One client config format. Parsers are isolated per format so they can evolve alone."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
client: str
|
|
42
|
+
matches: Callable[[Path], bool]
|
|
43
|
+
# (file text, real path, path to show in output) -> servers
|
|
44
|
+
parse: Callable[[str, Path, str], list[Server]]
|
|
45
|
+
# Formats describing several agents in one file return (id suffix, servers) pairs.
|
|
46
|
+
parse_agents: Callable[[str, Path, str], list[tuple[str, list[Server]]]] | None = None
|
|
47
|
+
# Size cap for this format; None means the default MAX_CONFIG_BYTES.
|
|
48
|
+
max_bytes: int | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_jsonc(text: str) -> Any:
|
|
52
|
+
"""Parse JSON that may contain // and /* */ comments and trailing commas (VS Code style)."""
|
|
53
|
+
out: list[str] = []
|
|
54
|
+
i, n = 0, len(text)
|
|
55
|
+
in_string = False
|
|
56
|
+
while i < n:
|
|
57
|
+
c = text[i]
|
|
58
|
+
if in_string:
|
|
59
|
+
out.append(c)
|
|
60
|
+
if c == "\\" and i + 1 < n:
|
|
61
|
+
out.append(text[i + 1])
|
|
62
|
+
i += 2
|
|
63
|
+
continue
|
|
64
|
+
if c == '"':
|
|
65
|
+
in_string = False
|
|
66
|
+
i += 1
|
|
67
|
+
continue
|
|
68
|
+
if c == '"':
|
|
69
|
+
in_string = True
|
|
70
|
+
out.append(c)
|
|
71
|
+
i += 1
|
|
72
|
+
elif text.startswith("//", i):
|
|
73
|
+
while i < n and text[i] not in "\r\n":
|
|
74
|
+
i += 1
|
|
75
|
+
elif text.startswith("/*", i):
|
|
76
|
+
end = text.find("*/", i + 2)
|
|
77
|
+
if end == -1:
|
|
78
|
+
raise ConfigError("unterminated block comment")
|
|
79
|
+
out.append("\n" * text.count("\n", i, end)) # keep line numbers stable
|
|
80
|
+
i = end + 2
|
|
81
|
+
elif c == ",":
|
|
82
|
+
j = i + 1
|
|
83
|
+
while j < n and text[j] in " \t\r\n":
|
|
84
|
+
j += 1
|
|
85
|
+
if j < n and text[j] in "]}":
|
|
86
|
+
i += 1 # drop trailing comma
|
|
87
|
+
else:
|
|
88
|
+
out.append(c)
|
|
89
|
+
i += 1
|
|
90
|
+
else:
|
|
91
|
+
out.append(c)
|
|
92
|
+
i += 1
|
|
93
|
+
try:
|
|
94
|
+
return json.loads("".join(out))
|
|
95
|
+
except json.JSONDecodeError as exc:
|
|
96
|
+
raise ConfigError(f"invalid JSON: {exc.msg} at line {exc.lineno}") from None
|
|
97
|
+
except RecursionError:
|
|
98
|
+
raise ConfigError("invalid JSON: nested too deeply") from None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_key_line(text: str, key: str, start: int = 0) -> int | None:
|
|
102
|
+
"""1-based line of the first `"key":` at or after offset `start`."""
|
|
103
|
+
m = re.compile(r'"' + re.escape(json.dumps(key)[1:-1]) + r'"\s*:').search(text, start)
|
|
104
|
+
if m is None:
|
|
105
|
+
return None
|
|
106
|
+
return text.count("\n", 0, m.start()) + 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def section_offset(text: str, key: str, after: int = 0) -> int:
|
|
110
|
+
m = re.compile(r'"' + re.escape(key) + r'"\s*:').search(text, after)
|
|
111
|
+
return m.end() if m else after
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_TRANSPORT_ALIASES = {
|
|
115
|
+
"stdio": Transport.STDIO,
|
|
116
|
+
"http": Transport.HTTP,
|
|
117
|
+
"streamable-http": Transport.HTTP,
|
|
118
|
+
"streamablehttp": Transport.HTTP,
|
|
119
|
+
"streamable_http": Transport.HTTP,
|
|
120
|
+
"sse": Transport.SSE,
|
|
121
|
+
"ws": Transport.WS,
|
|
122
|
+
"websocket": Transport.WS,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def infer_transport(entry: dict[str, Any], url: str | None) -> Transport:
|
|
127
|
+
declared = entry.get("type") or entry.get("transport")
|
|
128
|
+
if declared is not None and not isinstance(declared, str):
|
|
129
|
+
raise ConfigError(f"transport type must be a string, got {declared!r}")
|
|
130
|
+
if declared:
|
|
131
|
+
transport = _TRANSPORT_ALIASES.get(declared.strip().lower())
|
|
132
|
+
if transport is None:
|
|
133
|
+
raise ConfigError(f"unknown transport type {declared!r}")
|
|
134
|
+
return transport
|
|
135
|
+
if url:
|
|
136
|
+
path = url.split("?", 1)[0].rstrip("/")
|
|
137
|
+
return Transport.SSE if path.endswith("/sse") else Transport.HTTP
|
|
138
|
+
return Transport.STDIO
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _str_dict(value: Any, field: str) -> dict[str, str]:
|
|
142
|
+
if value is None:
|
|
143
|
+
return {}
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
raise ConfigError(f"{field!r} must be an object")
|
|
146
|
+
return {str(k): "" if v is None else str(v) for k, v in value.items()}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _str_list(value: Any, field: str) -> list[str]:
|
|
150
|
+
if value is None:
|
|
151
|
+
return []
|
|
152
|
+
if not isinstance(value, list):
|
|
153
|
+
raise ConfigError(f"{field!r} must be an array")
|
|
154
|
+
return [str(v) for v in value]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def infer_auth(
|
|
158
|
+
transport: Transport,
|
|
159
|
+
env: dict[str, str],
|
|
160
|
+
headers: dict[str, str],
|
|
161
|
+
args: list[str],
|
|
162
|
+
url: str | None,
|
|
163
|
+
env_file: str | None,
|
|
164
|
+
) -> AuthHint:
|
|
165
|
+
"""Best-effort auth classification from config alone. Names only, never values."""
|
|
166
|
+
evidence: list[str] = []
|
|
167
|
+
evidence += [f"header:{k}" for k in headers if is_sensitive_name(k)]
|
|
168
|
+
evidence += [f"env:{k}" for k in env if is_sensitive_name(k)]
|
|
169
|
+
evidence += [f"arg:{name}" for name in sensitive_arg_names(args)]
|
|
170
|
+
if url and redact_url(url) != url:
|
|
171
|
+
evidence.append("url:embedded-credential")
|
|
172
|
+
if evidence:
|
|
173
|
+
return AuthHint(method=AuthMethod.STATIC, evidence=evidence)
|
|
174
|
+
if env_file:
|
|
175
|
+
return AuthHint(method=AuthMethod.UNKNOWN, evidence=["envFile"])
|
|
176
|
+
if transport is Transport.STDIO:
|
|
177
|
+
# Local process: no credential in config, but it inherits the user's ambient identity.
|
|
178
|
+
return AuthHint(method=AuthMethod.NONE)
|
|
179
|
+
# Remote with no credential in config: either open, or OAuth negotiated at connect time.
|
|
180
|
+
return AuthHint(method=AuthMethod.UNKNOWN)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# Keys clients use to skip per-call confirmation: Cline/Roo `alwaysAllow`, `autoApprove`
|
|
184
|
+
# (list of tool names), and Gemini CLI `trust: true` (all tools).
|
|
185
|
+
_AUTO_APPROVE_LIST_KEYS = ("alwaysAllow", "autoApprove")
|
|
186
|
+
_MAX_APPROVAL_ENTRIES = 1000
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_approval(name: str, entry: dict[str, Any]) -> ApprovalConfig:
|
|
190
|
+
tools: list[str] = []
|
|
191
|
+
sources: list[str] = []
|
|
192
|
+
for key in _AUTO_APPROVE_LIST_KEYS:
|
|
193
|
+
value = entry.get(key)
|
|
194
|
+
if value is None:
|
|
195
|
+
continue
|
|
196
|
+
if not isinstance(value, list) or not all(isinstance(t, str) for t in value):
|
|
197
|
+
raise ConfigError(f"server {name!r}: {key!r} must be an array of tool names")
|
|
198
|
+
if len(value) > _MAX_APPROVAL_ENTRIES:
|
|
199
|
+
raise ConfigError(f"server {name!r}: {key!r} has too many entries")
|
|
200
|
+
tools.extend(value)
|
|
201
|
+
sources.append(key)
|
|
202
|
+
trust = entry.get("trust")
|
|
203
|
+
if trust is not None and not isinstance(trust, bool):
|
|
204
|
+
raise ConfigError(f"server {name!r}: 'trust' must be true or false")
|
|
205
|
+
if trust:
|
|
206
|
+
sources.append("trust")
|
|
207
|
+
return ApprovalConfig(
|
|
208
|
+
auto_approve_all=bool(trust), auto_approved_tools=sorted(set(tools)), sources=sources
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_server(
|
|
213
|
+
name: str,
|
|
214
|
+
entry: Any,
|
|
215
|
+
*,
|
|
216
|
+
config_path: str,
|
|
217
|
+
text: str,
|
|
218
|
+
section_start: int,
|
|
219
|
+
base_dir: Path,
|
|
220
|
+
) -> Server:
|
|
221
|
+
"""Normalize one server entry. Raw values go to LaunchSpec; everything else is redacted."""
|
|
222
|
+
if not isinstance(entry, dict):
|
|
223
|
+
raise ConfigError(f"server {name!r}: entry must be an object")
|
|
224
|
+
|
|
225
|
+
url_value = entry.get("url") or entry.get("serverUrl")
|
|
226
|
+
url = str(url_value) if url_value else None
|
|
227
|
+
command = entry.get("command")
|
|
228
|
+
if command is not None and not isinstance(command, str):
|
|
229
|
+
raise ConfigError(f"server {name!r}: 'command' must be a string")
|
|
230
|
+
if not command and not url:
|
|
231
|
+
raise ConfigError(f"server {name!r}: needs either 'command' or 'url'")
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
transport = infer_transport(entry, url)
|
|
235
|
+
args = _str_list(entry.get("args"), "args")
|
|
236
|
+
env = _str_dict(entry.get("env"), "env")
|
|
237
|
+
headers = _str_dict(entry.get("headers"), "headers")
|
|
238
|
+
except ConfigError as exc:
|
|
239
|
+
raise ConfigError(f"server {name!r}: {exc}") from None
|
|
240
|
+
env_file = entry.get("envFile")
|
|
241
|
+
cwd = entry.get("cwd")
|
|
242
|
+
|
|
243
|
+
for value in [*env.values(), *headers.values()]:
|
|
244
|
+
known_secrets.add(value)
|
|
245
|
+
|
|
246
|
+
return Server(
|
|
247
|
+
name=name,
|
|
248
|
+
transport=transport,
|
|
249
|
+
command=redact_text(command) if command else None,
|
|
250
|
+
args=redact_args(args),
|
|
251
|
+
url=redact_url(url) if url else None,
|
|
252
|
+
env={k: redact_value(v) for k, v in env.items()},
|
|
253
|
+
headers={k: redact_value(v) for k, v in headers.items()},
|
|
254
|
+
auth=infer_auth(transport, env, headers, args, url, env_file if env_file else None),
|
|
255
|
+
location=SourceLocation(path=config_path, line=find_key_line(text, name, section_start)),
|
|
256
|
+
disabled=bool(entry.get("disabled", False)),
|
|
257
|
+
approval=parse_approval(name, entry),
|
|
258
|
+
launch=LaunchSpec(
|
|
259
|
+
command=command,
|
|
260
|
+
args=args,
|
|
261
|
+
env=env,
|
|
262
|
+
env_file=str(env_file) if env_file else None,
|
|
263
|
+
cwd=str(cwd) if cwd else None,
|
|
264
|
+
url=url,
|
|
265
|
+
headers=headers,
|
|
266
|
+
# Absolute, so ${workspaceFolder} and the server cwd do not depend on how the
|
|
267
|
+
# scan path was written (never serialized, so it does not leak into output).
|
|
268
|
+
base_dir=str(base_dir.resolve()),
|
|
269
|
+
),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_server_map(
|
|
274
|
+
servers: Any,
|
|
275
|
+
*,
|
|
276
|
+
config_path: str,
|
|
277
|
+
text: str,
|
|
278
|
+
section_key: str,
|
|
279
|
+
base_dir: Path,
|
|
280
|
+
search_from: int = 0,
|
|
281
|
+
) -> list[Server]:
|
|
282
|
+
if servers is None:
|
|
283
|
+
return []
|
|
284
|
+
if not isinstance(servers, dict):
|
|
285
|
+
raise ConfigError(f"{section_key!r} must be an object mapping names to servers")
|
|
286
|
+
start = section_offset(text, section_key, search_from)
|
|
287
|
+
return [
|
|
288
|
+
build_server(
|
|
289
|
+
str(name),
|
|
290
|
+
entry,
|
|
291
|
+
config_path=config_path,
|
|
292
|
+
text=text,
|
|
293
|
+
section_start=start,
|
|
294
|
+
base_dir=base_dir,
|
|
295
|
+
)
|
|
296
|
+
for name, entry in servers.items()
|
|
297
|
+
]
|