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 +9 -0
- shadow_mcp/cli.py +268 -0
- shadow_mcp/collectors/__init__.py +5 -0
- shadow_mcp/collectors/_common.py +120 -0
- shadow_mcp/collectors/base.py +72 -0
- shadow_mcp/collectors/claude_cli.py +100 -0
- shadow_mcp/collectors/claude_code.py +43 -0
- shadow_mcp/collectors/claude_desktop.py +27 -0
- shadow_mcp/collectors/codex.py +84 -0
- shadow_mcp/collectors/dxt.py +61 -0
- shadow_mcp/collectors/processes.py +269 -0
- shadow_mcp/collectors/project_mcp.py +68 -0
- shadow_mcp/config.py +59 -0
- shadow_mcp/grading/__init__.py +63 -0
- shadow_mcp/grading/combine.py +106 -0
- shadow_mcp/grading/mcpaudit.py +118 -0
- shadow_mcp/grading/mcpaudit_connect.py +82 -0
- shadow_mcp/grading/mcptrust.py +136 -0
- shadow_mcp/grading/mcptrust_compute.py +50 -0
- shadow_mcp/identity.py +166 -0
- shadow_mcp/inventory.py +118 -0
- shadow_mcp/mcp_server.py +113 -0
- shadow_mcp/models.py +189 -0
- shadow_mcp/redact.py +70 -0
- shadow_mcp/report.py +155 -0
- shadow_mcp/shadow.py +101 -0
- shadow_mcp-0.1.0.dist-info/METADATA +154 -0
- shadow_mcp-0.1.0.dist-info/RECORD +30 -0
- shadow_mcp-0.1.0.dist-info/WHEEL +4 -0
- shadow_mcp-0.1.0.dist-info/entry_points.txt +2 -0
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,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
|
+
)
|