shadow-mcp 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.
shadow_mcp/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """shadow-mcp: discover and risk-grade the MCP servers present on this machine.
2
+
3
+ Discovery is strictly read-only: collectors parse configs and list processes,
4
+ and never mutate anything they find. Risk-grading delegates to the existing
5
+ engines (MCPAudit for a 0-10 capability composite, mcp-trust for an A-F danger
6
+ grade) rather than reimplementing them.
7
+ """
8
+
9
+ __version__ = "0.1.0"
shadow_mcp/cli.py ADDED
@@ -0,0 +1,268 @@
1
+ """shadow-mcp command line: discover -> inventory -> grade -> report.
2
+
3
+ shadow-mcp scan full pipeline, rich terminal report
4
+ shadow-mcp scan --json out.json write the machine-readable inventory
5
+ shadow-mcp scan --format markdown
6
+ shadow-mcp discover inventory only, skip grading
7
+ shadow-mcp sources what each collector found, no grading
8
+
9
+ Discovery is read-only throughout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import socket
17
+ import sys
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ from .collectors import discover_all
22
+ from .config import DiscoveryPaths, GradingPaths
23
+ from .grading import grade_inventory
24
+ from .inventory import build_inventory
25
+ from .models import GradedServer, RiskAssessment
26
+ from .report import build_report, render_markdown, render_terminal
27
+
28
+
29
+ def _now_iso() -> str:
30
+ return datetime.now(UTC).isoformat(timespec="seconds")
31
+
32
+
33
+ def _discovery_paths(args: argparse.Namespace) -> DiscoveryPaths:
34
+ home = Path(args.home).expanduser() if args.home else None
35
+ return DiscoveryPaths.default(home)
36
+
37
+
38
+ def _grading_paths(args: argparse.Namespace) -> GradingPaths:
39
+ gp = GradingPaths.default()
40
+ if args.registry_db:
41
+ gp.mcptrust_registry_db = Path(args.registry_db).expanduser()
42
+ return gp
43
+
44
+
45
+ def _run_pipeline(args: argparse.Namespace, *, grade: bool, only: list[str] | None = None):
46
+ paths = _discovery_paths(args)
47
+ result = discover_all(
48
+ paths,
49
+ include_processes=not args.no_processes,
50
+ include_cli=not args.no_cli,
51
+ )
52
+ inventory = build_inventory(result.servers)
53
+ if only:
54
+ wanted = {n.lower() for n in only}
55
+ inventory = [
56
+ e
57
+ for e in inventory
58
+ if e.canonical_name.lower() in wanted or any(a.lower() in wanted for a in e.aliases)
59
+ ]
60
+ if grade:
61
+ connect = getattr(args, "connect", False)
62
+ if connect:
63
+ print(
64
+ "warning: --connect spawns each stdio MCP server to enumerate its "
65
+ "tools. Servers needing real secrets will fail to start and fall "
66
+ "back to their static grade.",
67
+ file=sys.stderr,
68
+ )
69
+ graded = grade_inventory(
70
+ inventory,
71
+ grading_paths=_grading_paths(args),
72
+ run_mcpaudit=not getattr(args, "no_mcpaudit", False),
73
+ compute_missing=not getattr(args, "no_compute", False),
74
+ connect=connect,
75
+ connect_timeout=getattr(args, "connect_timeout", 8),
76
+ )
77
+ else:
78
+ graded = [
79
+ GradedServer(
80
+ entry=e,
81
+ risk=RiskAssessment(band="unknown", headline="ungraded"),
82
+ )
83
+ for e in inventory
84
+ ]
85
+ report = build_report(
86
+ graded,
87
+ host=socket.gethostname(),
88
+ generated_at=_now_iso(),
89
+ source_counts=result.source_counts,
90
+ errors=result.errors,
91
+ )
92
+ return report
93
+
94
+
95
+ def _emit(report, args: argparse.Namespace) -> None:
96
+ if args.json:
97
+ Path(args.json).write_text(
98
+ json.dumps(report.model_dump(mode="json"), indent=2), encoding="utf-8"
99
+ )
100
+ print(f"wrote {args.json}", file=sys.stderr)
101
+ fmt = args.format
102
+ if fmt == "json" and not args.json:
103
+ print(json.dumps(report.model_dump(mode="json"), indent=2))
104
+ elif fmt == "markdown":
105
+ print(render_markdown(report))
106
+ elif fmt == "terminal":
107
+ render_terminal(report)
108
+
109
+
110
+ def cmd_scan(args: argparse.Namespace) -> int:
111
+ report = _run_pipeline(args, grade=True)
112
+ _emit(report, args)
113
+ return 0
114
+
115
+
116
+ def cmd_discover(args: argparse.Namespace) -> int:
117
+ report = _run_pipeline(args, grade=False)
118
+ _emit(report, args)
119
+ return 0
120
+
121
+
122
+ def cmd_deep_scan(args: argparse.Namespace) -> int:
123
+ """Connect to (spawn) the named servers (or all), enumerate tools, grade."""
124
+ args.connect = True
125
+ report = _run_pipeline(args, grade=True, only=args.names or None)
126
+ _emit(report, args)
127
+ return 0
128
+
129
+
130
+ def cmd_grade_missing(args: argparse.Namespace) -> int:
131
+ """Show servers the mcp-trust registry had no grade for, with a computed letter."""
132
+ report = _run_pipeline(args, grade=True)
133
+ computed = [g for g in report.servers if g.risk.mcptrust and g.risk.mcptrust.computed]
134
+ in_registry = [
135
+ g
136
+ for g in report.servers
137
+ if g.risk.mcptrust
138
+ and g.risk.mcptrust.grade not in ("unknown",)
139
+ and not g.risk.mcptrust.computed
140
+ ]
141
+ if args.format == "json":
142
+ payload = {
143
+ "computed": [g.model_dump(mode="json") for g in computed],
144
+ "in_registry": [g.entry.canonical_name for g in in_registry],
145
+ }
146
+ print(json.dumps(payload, indent=2))
147
+ return 0
148
+ print(
149
+ f"{len(in_registry)} server(s) graded by the mcp-trust registry; "
150
+ f"{len(computed)} computed from MCPAudit dimensions via mcp-trust grade():\n"
151
+ )
152
+ for g in sorted(computed, key=lambda g: g.risk.mcptrust.grade):
153
+ e = g.entry
154
+ print(f" {g.risk.mcptrust.grade} {e.canonical_name:28} ({','.join(e.sources)})")
155
+ return 0
156
+
157
+
158
+ def cmd_mcp_serve(args: argparse.Namespace) -> int:
159
+ """Run shadow-mcp as an MCP server over stdio."""
160
+ from . import mcp_server
161
+
162
+ mcp_server.run()
163
+ return 0
164
+
165
+
166
+ def cmd_sources(args: argparse.Namespace) -> int:
167
+ paths = _discovery_paths(args)
168
+ result = discover_all(
169
+ paths,
170
+ include_processes=not args.no_processes,
171
+ include_cli=not args.no_cli,
172
+ )
173
+ for source, count in sorted(result.source_counts.items()):
174
+ print(f"{source:18} {count}")
175
+ if result.errors:
176
+ print("\nerrors:")
177
+ for e in result.errors:
178
+ print(f" {e}")
179
+ return 0
180
+
181
+
182
+ def _add_common(p: argparse.ArgumentParser) -> None:
183
+ p.add_argument("--home", help="override $HOME for discovery (testing)")
184
+ p.add_argument("--no-processes", action="store_true", help="skip live process scan")
185
+ p.add_argument("--no-cli", action="store_true", help="skip `claude mcp list`")
186
+ p.add_argument("--json", metavar="PATH", help="write machine-readable inventory JSON")
187
+ p.add_argument(
188
+ "--format",
189
+ choices=["terminal", "json", "markdown"],
190
+ default="terminal",
191
+ help="output format (default: terminal)",
192
+ )
193
+
194
+
195
+ def build_parser() -> argparse.ArgumentParser:
196
+ parser = argparse.ArgumentParser(
197
+ prog="shadow-mcp",
198
+ description="Discover and risk-grade the MCP servers present on this machine.",
199
+ )
200
+ sub = parser.add_subparsers(dest="command")
201
+
202
+ p_scan = sub.add_parser("scan", help="discover, grade, and report (default)")
203
+ _add_common(p_scan)
204
+ p_scan.add_argument("--no-mcpaudit", action="store_true", help="skip MCPAudit grading")
205
+ p_scan.add_argument("--registry-db", help="path to mcp-trust registry.db")
206
+ p_scan.add_argument(
207
+ "--no-compute",
208
+ action="store_true",
209
+ help="don't compute a grade for servers the registry hasn't scanned",
210
+ )
211
+ p_scan.add_argument(
212
+ "--connect",
213
+ action="store_true",
214
+ help="spawn each stdio server to grade its real tools (opt-in; executes servers)",
215
+ )
216
+ p_scan.add_argument(
217
+ "--connect-timeout", type=int, default=8, help="per-server connect timeout (s)"
218
+ )
219
+ p_scan.set_defaults(func=cmd_scan)
220
+
221
+ p_disc = sub.add_parser("discover", help="inventory only, no grading")
222
+ _add_common(p_disc)
223
+ p_disc.set_defaults(func=cmd_discover)
224
+
225
+ p_deep = sub.add_parser(
226
+ "deep-scan",
227
+ help="connect to (spawn) named servers (or all), enumerate tools, grade",
228
+ )
229
+ _add_common(p_deep)
230
+ p_deep.add_argument("names", nargs="*", help="server names to connect to (default: all stdio)")
231
+ p_deep.add_argument("--no-mcpaudit", action="store_true", help="skip MCPAudit grading")
232
+ p_deep.add_argument("--no-compute", action="store_true", help="skip computed grade fill")
233
+ p_deep.add_argument("--registry-db", help="path to mcp-trust registry.db")
234
+ p_deep.add_argument(
235
+ "--connect-timeout", type=int, default=10, help="per-server connect timeout (s)"
236
+ )
237
+ p_deep.set_defaults(func=cmd_deep_scan)
238
+
239
+ p_grade = sub.add_parser(
240
+ "grade-missing",
241
+ help="grade servers the mcp-trust registry has no scan for, via mcp-trust grade()",
242
+ )
243
+ _add_common(p_grade)
244
+ p_grade.add_argument("--no-mcpaudit", action="store_true", help="skip MCPAudit grading")
245
+ p_grade.add_argument("--registry-db", help="path to mcp-trust registry.db")
246
+ p_grade.set_defaults(func=cmd_grade_missing)
247
+
248
+ p_src = sub.add_parser("sources", help="per-collector counts")
249
+ _add_common(p_src)
250
+ p_src.set_defaults(func=cmd_sources)
251
+
252
+ p_mcp = sub.add_parser("mcp-serve", help="run as an MCP server over stdio")
253
+ p_mcp.set_defaults(func=cmd_mcp_serve)
254
+
255
+ return parser
256
+
257
+
258
+ def main(argv: list[str] | None = None) -> int:
259
+ parser = build_parser()
260
+ args = parser.parse_args(argv)
261
+ if not getattr(args, "command", None):
262
+ # default to scan with terminal output
263
+ args = parser.parse_args(["scan", *(argv or [])])
264
+ return args.func(args)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """Read-only collectors, one per place an MCP server can be declared or run."""
2
+
3
+ from .base import DiscoveryResult, discover_all
4
+
5
+ __all__ = ["DiscoveryResult", "discover_all"]
@@ -0,0 +1,120 @@
1
+ """Shared parsing for the common ``mcpServers`` config shape.
2
+
3
+ Used by every collector whose source stores servers as a name -> spec map
4
+ (Claude Code, project .mcp.json, Claude Desktop). Codex (TOML), the CLI, the
5
+ DXT manifests, and process discovery have their own shapes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..models import DiscoveredServer, Provenance, ServerSpec, SourceKind, Transport
16
+
17
+
18
+ def _resolve_rel(value: str, base_dir: str | None) -> str:
19
+ """Canonicalize an explicitly-relative path (``./x``, ``../x``) against the
20
+ config's directory.
21
+
22
+ A config that declares ``node ./mcp-server.js`` and the same server seen as an
23
+ absolute path in the process table would otherwise produce different identity
24
+ signatures and false-split into a config entry plus a phantom running server.
25
+ Only ``./`` / ``../`` forms are touched, so package names and flags are left
26
+ alone.
27
+ """
28
+ if base_dir and (value.startswith("./") or value.startswith("../")):
29
+ return os.path.normpath(os.path.join(base_dir, value))
30
+ return value
31
+
32
+
33
+ def _config_base_dir(location: str) -> str | None:
34
+ """The filesystem directory a config's relative paths resolve against."""
35
+ # Claude Code encodes the project dir after '#projects.' (project scope).
36
+ if "#projects." in location:
37
+ return location.split("#projects.", 1)[1] or None
38
+ if "#" in location: # other synthetic locations have no filesystem base
39
+ return None
40
+ return os.path.dirname(location) or None
41
+
42
+
43
+ def load_json(path: Path) -> dict[str, Any] | None:
44
+ """Read + parse a JSON file, returning None on any read/parse failure.
45
+
46
+ Read-only and fail-soft: a missing or malformed config must never break
47
+ discovery.
48
+ """
49
+ try:
50
+ with path.open("r", encoding="utf-8") as fh:
51
+ data = json.load(fh)
52
+ except (OSError, ValueError):
53
+ return None
54
+ return data if isinstance(data, dict) else None
55
+
56
+
57
+ def infer_spec(raw: dict[str, Any], base_dir: str | None = None) -> ServerSpec:
58
+ """Normalize one raw server dict into a redacted ServerSpec.
59
+
60
+ ``base_dir`` (the config's directory) canonicalizes relative command/script
61
+ paths so they match the absolute form seen in the process table.
62
+ """
63
+ command = raw.get("command")
64
+ command = _resolve_rel(str(command), base_dir) if command else None
65
+ args_raw = raw.get("args") or []
66
+ args = [_resolve_rel(str(a), base_dir) for a in args_raw] if isinstance(args_raw, list) else []
67
+ url = raw.get("url") or raw.get("serverUrl") or raw.get("endpoint")
68
+ env = raw.get("env")
69
+ env_keys = sorted(env.keys()) if isinstance(env, dict) else []
70
+
71
+ declared = (raw.get("type") or raw.get("transport") or "").lower()
72
+ transport: Transport
73
+ if declared in ("stdio", "http", "sse"):
74
+ transport = declared # type: ignore[assignment]
75
+ elif url and not command:
76
+ transport = "sse" if "sse" in str(url).lower() else "http"
77
+ elif command:
78
+ transport = "stdio"
79
+ else:
80
+ transport = "unknown"
81
+
82
+ return ServerSpec(
83
+ transport=transport,
84
+ command=str(command) if command else None,
85
+ args=args,
86
+ url=str(url) if url else None,
87
+ env_keys=env_keys,
88
+ )
89
+
90
+
91
+ def parse_mcp_servers_block(
92
+ block: Any,
93
+ *,
94
+ source: SourceKind,
95
+ location: str,
96
+ scope: str,
97
+ ) -> list[DiscoveredServer]:
98
+ """Turn a ``mcpServers`` map into DiscoveredServer records."""
99
+ if not isinstance(block, dict):
100
+ return []
101
+ base_dir = _config_base_dir(location)
102
+ out: list[DiscoveredServer] = []
103
+ for name, raw in block.items():
104
+ if not isinstance(raw, dict):
105
+ continue
106
+ enabled = raw.get("enabled")
107
+ out.append(
108
+ DiscoveredServer(
109
+ name=str(name),
110
+ spec=infer_spec(raw, base_dir),
111
+ provenance=Provenance(
112
+ source=source,
113
+ location=location,
114
+ scope=scope,
115
+ declared_name=str(name),
116
+ enabled=enabled if isinstance(enabled, bool) else None,
117
+ ),
118
+ )
119
+ )
120
+ return out
@@ -0,0 +1,72 @@
1
+ """Discovery orchestration: run every collector, fail-soft, and tally sources.
2
+
3
+ A single collector raising must never break discovery, so each is wrapped; its
4
+ error is recorded as a note and the sweep continues.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from ..config import DiscoveryPaths
12
+ from ..models import DiscoveredServer
13
+ from .claude_cli import ClaudeCliCollector
14
+ from .claude_code import ClaudeCodeCollector
15
+ from .claude_desktop import ClaudeDesktopCollector
16
+ from .codex import CodexCollector
17
+ from .dxt import DxtCollector
18
+ from .processes import ProcessCollector
19
+ from .project_mcp import ProjectMcpJsonCollector
20
+
21
+
22
+ @dataclass
23
+ class DiscoveryResult:
24
+ servers: list[DiscoveredServer] = field(default_factory=list)
25
+ source_counts: dict[str, int] = field(default_factory=dict)
26
+ errors: list[str] = field(default_factory=list)
27
+
28
+
29
+ def default_collectors(
30
+ paths: DiscoveryPaths,
31
+ *,
32
+ include_processes: bool = True,
33
+ include_cli: bool = True,
34
+ ) -> list[object]:
35
+ collectors: list[object] = [
36
+ ClaudeCodeCollector(paths),
37
+ ClaudeDesktopCollector(paths),
38
+ CodexCollector(paths),
39
+ DxtCollector(paths),
40
+ ProjectMcpJsonCollector(paths),
41
+ ]
42
+ if include_cli:
43
+ collectors.append(ClaudeCliCollector())
44
+ if include_processes:
45
+ collectors.append(ProcessCollector())
46
+ return collectors
47
+
48
+
49
+ def discover_all(
50
+ paths: DiscoveryPaths | None = None,
51
+ *,
52
+ include_processes: bool = True,
53
+ include_cli: bool = True,
54
+ collectors: list[object] | None = None,
55
+ ) -> DiscoveryResult:
56
+ paths = paths or DiscoveryPaths.default()
57
+ cols = (
58
+ collectors
59
+ if collectors is not None
60
+ else default_collectors(paths, include_processes=include_processes, include_cli=include_cli)
61
+ )
62
+ result = DiscoveryResult()
63
+ for col in cols:
64
+ source = getattr(col, "source", col.__class__.__name__)
65
+ try:
66
+ found = col.collect() # type: ignore[attr-defined]
67
+ except Exception as exc: # a collector must never break the sweep
68
+ result.errors.append(f"{source}: {type(exc).__name__}: {exc}")
69
+ continue
70
+ result.servers.extend(found)
71
+ result.source_counts[source] = result.source_counts.get(source, 0) + len(found)
72
+ return result
@@ -0,0 +1,100 @@
1
+ """Collector: ``claude mcp list``.
2
+
3
+ The CLI roster is the ground truth for Claude Code: it resolves servers that no
4
+ JSON file contains (remote ``claude.ai`` HTTP servers and plugin servers). We
5
+ shell out read-only and parse the line-oriented output tolerantly across CLI
6
+ versions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ from collections.abc import Callable
15
+
16
+ from ..models import DiscoveredServer, Provenance, ServerSpec, Transport
17
+
18
+ SOURCE = "claude_cli"
19
+
20
+ Runner = Callable[[], "tuple[int, str]"]
21
+
22
+ _URL_RE = re.compile(r"https?://\S+")
23
+ # Trailing status decoration: " - ✓ Connected", " (HTTP)", " - Failed to connect"
24
+ _STATUS_RE = re.compile(r"\s+-\s+.*$")
25
+
26
+
27
+ def _default_runner() -> tuple[int, str]:
28
+ exe = shutil.which("claude")
29
+ if not exe:
30
+ return (127, "")
31
+ try:
32
+ proc = subprocess.run(
33
+ [exe, "mcp", "list"],
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=20,
37
+ )
38
+ except (OSError, subprocess.SubprocessError):
39
+ return (1, "")
40
+ return (proc.returncode, proc.stdout or "")
41
+
42
+
43
+ def parse_claude_mcp_list(text: str) -> list[DiscoveredServer]:
44
+ """Parse ``claude mcp list`` output into DiscoveredServer records."""
45
+ out: list[DiscoveredServer] = []
46
+ for line in text.splitlines():
47
+ line = line.strip()
48
+ if not line or ":" not in line:
49
+ continue
50
+ name, _, rest = line.partition(":")
51
+ name = name.strip()
52
+ rest = rest.strip()
53
+ if not name or not rest:
54
+ continue
55
+ # drop a "- Connected" / "- Failed" status tail and any "(HTTP)" hint
56
+ had_http_hint = "(http" in rest.lower()
57
+ rest = _STATUS_RE.sub("", rest).strip()
58
+ rest = re.sub(r"\((?:HTTP|SSE|STDIO)\)", "", rest, flags=re.IGNORECASE).strip()
59
+
60
+ url_match = _URL_RE.search(rest)
61
+ if url_match:
62
+ url = url_match.group(0)
63
+ transport: Transport = "sse" if "sse" in url.lower() else "http"
64
+ spec = ServerSpec(transport=transport, url=url)
65
+ else:
66
+ tokens = rest.split()
67
+ if not tokens:
68
+ continue
69
+ transport = "http" if had_http_hint else "stdio"
70
+ spec = ServerSpec(
71
+ transport=transport,
72
+ command=tokens[0],
73
+ args=tokens[1:],
74
+ )
75
+ out.append(
76
+ DiscoveredServer(
77
+ name=name,
78
+ spec=spec,
79
+ provenance=Provenance(
80
+ source=SOURCE,
81
+ location="claude mcp list",
82
+ scope="user",
83
+ declared_name=name,
84
+ ),
85
+ )
86
+ )
87
+ return out
88
+
89
+
90
+ class ClaudeCliCollector:
91
+ source = SOURCE
92
+
93
+ def __init__(self, runner: Runner | None = None) -> None:
94
+ self.runner = runner or _default_runner
95
+
96
+ def collect(self) -> list[DiscoveredServer]:
97
+ code, text = self.runner()
98
+ if code != 0 or not text.strip():
99
+ return []
100
+ return parse_claude_mcp_list(text)
@@ -0,0 +1,43 @@
1
+ """Collector: Claude Code ``~/.claude.json``.
2
+
3
+ Two scopes live in this file: top-level ``mcpServers`` (user scope) and
4
+ per-project ``projects.<path>.mcpServers`` (project scope).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..config import DiscoveryPaths
10
+ from ..models import DiscoveredServer
11
+ from ._common import load_json, parse_mcp_servers_block
12
+
13
+ SOURCE = "claude_code"
14
+
15
+
16
+ class ClaudeCodeCollector:
17
+ source = SOURCE
18
+
19
+ def __init__(self, paths: DiscoveryPaths) -> None:
20
+ self.paths = paths
21
+
22
+ def collect(self) -> list[DiscoveredServer]:
23
+ data = load_json(self.paths.claude_json)
24
+ if data is None:
25
+ return []
26
+ loc = str(self.paths.claude_json)
27
+ out = parse_mcp_servers_block(
28
+ data.get("mcpServers"), source=SOURCE, location=loc, scope="user"
29
+ )
30
+ projects = data.get("projects")
31
+ if isinstance(projects, dict):
32
+ for proj_path, proj in projects.items():
33
+ if not isinstance(proj, dict):
34
+ continue
35
+ out.extend(
36
+ parse_mcp_servers_block(
37
+ proj.get("mcpServers"),
38
+ source=SOURCE,
39
+ location=f"{loc}#projects.{proj_path}",
40
+ scope="project",
41
+ )
42
+ )
43
+ return out
@@ -0,0 +1,27 @@
1
+ """Collector: Claude Desktop ``claude_desktop_config.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..config import DiscoveryPaths
6
+ from ..models import DiscoveredServer
7
+ from ._common import load_json, parse_mcp_servers_block
8
+
9
+ SOURCE = "claude_desktop"
10
+
11
+
12
+ class ClaudeDesktopCollector:
13
+ source = SOURCE
14
+
15
+ def __init__(self, paths: DiscoveryPaths) -> None:
16
+ self.paths = paths
17
+
18
+ def collect(self) -> list[DiscoveredServer]:
19
+ data = load_json(self.paths.claude_desktop_config)
20
+ if data is None:
21
+ return []
22
+ return parse_mcp_servers_block(
23
+ data.get("mcpServers"),
24
+ source=SOURCE,
25
+ location=str(self.paths.claude_desktop_config),
26
+ scope="desktop",
27
+ )