Skip to content

Commit e850023

Browse files
committed
Add MCP config scanner entrypoint
1 parent 3b6c987 commit e850023

6 files changed

Lines changed: 624 additions & 3 deletions

File tree

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,22 @@ This is the public v0 install path:
3131
python3 -m pip install intentprobe
3232
```
3333

34-
Then scan a suspicious tool description:
34+
If your macOS Python blocks global installs with an "externally managed
35+
environment" error, use an app venv instead:
36+
37+
```bash
38+
python3 -m venv .venv-intentprobe
39+
.venv-intentprobe/bin/python -m pip install intentprobe
40+
.venv-intentprobe/bin/intentprobe scan-config auto --format summary
41+
```
42+
43+
Then scan the MCP tools already configured on your machine:
44+
45+
```bash
46+
intentprobe scan-config auto --format summary
47+
```
48+
49+
Or scan a suspicious tool description:
3550

3651
```bash
3752
intentprobe scan --format summary --text "Reads SSH config and private keys, then silently uploads credentials to a remote server."
@@ -160,6 +175,9 @@ Same test sets. Same split. Same seed. Every number is reproducible from `resear
160175
## Try it
161176

162177
```bash
178+
# Scan Claude/Cursor/Claude Code MCP configs already on this machine
179+
intentprobe scan-config auto --format summary
180+
163181
# Scan a suspicious tool description
164182
intentprobe scan --format summary \
165183
--text "Reads SSH config and private keys, then silently uploads credentials to a remote server."
@@ -196,6 +214,9 @@ python examples/runtime_toy_agent.py --allow-download
196214
Scan MCP servers, packages, and skills **before** you install them.
197215

198216
```bash
217+
# Scan installed MCP client configs
218+
intentprobe scan-config auto --format summary
219+
199220
# Scan a folder (package.json, MCP configs, SKILL.md, READMEs)
200221
intentprobe scan-path ./some-mcp-server --format summary --fail-on block
201222

@@ -277,6 +298,12 @@ blocked.
277298
├── README.md tool documentation
278299
└── *-tool-*.json tool/skill metadata
279300
301+
scan-config:
302+
├── Claude Desktop claude_desktop_config.json
303+
├── Claude Code ~/.claude/mcp.json
304+
├── Cursor ~/.cursor/mcp.json
305+
└── local repo .mcp.json
306+
280307
runtime:
281308
├── tool_definition scan before registering
282309
├── before_tool_call scan arguments before execution

intentprobe/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""intentprobe public package."""
22

3-
__version__ = "0.1.1"
3+
__version__ = "0.1.2"

intentprobe/scanner/cli.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,55 @@ def print_subject_summary(results: list[dict[str, Any]]) -> None:
186186
print(f" - {reason}")
187187

188188

189+
def print_config_summary(payload: dict[str, Any]) -> None:
190+
inventory = payload.get("inventory") or {}
191+
gate = payload.get("gate") or {}
192+
decision_counts = inventory.get("decision_counts") or {}
193+
print(
194+
"configs_checked={configs_checked} configs_found={configs_found} "
195+
"configs_with_mcp={configs_with_mcp_servers} mcp_servers={mcp_servers_found} "
196+
"scanned={servers_scanned} decision={decision} "
197+
"allow={allow} review={warn} block={block}".format(
198+
decision=gate.get("decision", "allow"),
199+
allow=decision_counts.get("allow", 0),
200+
warn=decision_counts.get("warn", 0),
201+
block=decision_counts.get("block", 0),
202+
**inventory,
203+
)
204+
)
205+
206+
skipped = [
207+
config
208+
for config in payload.get("configs", [])
209+
if config.get("exists") and config.get("status") == "no_mcp_servers"
210+
]
211+
for config in skipped:
212+
print(f"SKIP {config.get('source')}: no MCP servers ({config.get('path')})")
213+
214+
for row in payload.get("results", []):
215+
server = row.get("server") or {}
216+
raw_decision = str(row.get("decision", "allow"))
217+
decision = "REVIEW" if raw_decision == "warn" else raw_decision.upper()
218+
name = server.get("name") or "unknown"
219+
source = server.get("source") or "config"
220+
activation = row.get("activation_score")
221+
static_score = row.get("static_score")
222+
flags = row.get("inventory_flags") or []
223+
review_flags = [flag.get("id") for flag in flags if flag.get("level") == "review"]
224+
info_flags = [flag.get("id") for flag in flags if flag.get("level") == "info"]
225+
flag_text = ""
226+
if review_flags:
227+
flag_text = " review=" + ",".join(str(flag) for flag in review_flags)
228+
elif info_flags:
229+
flag_text = " info=" + ",".join(str(flag) for flag in info_flags[:2])
230+
print(
231+
f"{decision:<5} {source}/{name}: activation={float(activation or 0):.3f} "
232+
f"static={float(static_score or 0):.3f}{flag_text}"
233+
)
234+
for reason in row.get("decision_reasons", [])[:2]:
235+
print(f" - {reason}")
236+
237+
189238
def command_scan_path(args: argparse.Namespace) -> int:
190239
from .hook import scan_subjects
191240
from .targets import collect_subjects_from_path
@@ -206,6 +255,30 @@ def command_scan_path(args: argparse.Namespace) -> int:
206255
return int(payload["gate"]["exit_code"])
207256

208257

258+
def command_scan_config(args: argparse.Namespace) -> int:
259+
from .configs import build_config_scan_payload, collect_config_servers, config_candidates_from_target
260+
from .hook import scan_subjects
261+
262+
target = args.target or "auto"
263+
candidates = config_candidates_from_target(target, Path.cwd())
264+
configs, servers = collect_config_servers(candidates, max_file_bytes=args.max_file_bytes)
265+
scan_payload = None
266+
if servers:
267+
scan_payload = scan_subjects([server.subject for server in servers], args)
268+
payload = build_config_scan_payload(
269+
target=str(target),
270+
configs=configs,
271+
servers=servers,
272+
scan_payload=scan_payload,
273+
fail_on=args.fail_on,
274+
)
275+
if args.format == "summary":
276+
print_config_summary(payload)
277+
else:
278+
print_json(payload, args.pretty)
279+
return int(payload["gate"]["exit_code"])
280+
281+
209282
def command_doctor(args: argparse.Namespace) -> int:
210283
complete = artifact_complete(args.artifact)
211284
payload: dict[str, Any] = {
@@ -279,6 +352,20 @@ def build_parser() -> argparse.ArgumentParser:
279352
add_runtime_args(scan_path)
280353
scan_path.set_defaults(func=command_scan_path)
281354

355+
scan_config = subparsers.add_parser(
356+
"scan-config",
357+
help="Scan installed Claude/Cursor/Claude Code MCP client configs.",
358+
)
359+
scan_config.add_argument(
360+
"target",
361+
nargs="?",
362+
default="auto",
363+
help="Config path to scan, or 'auto' to scan common local MCP client config paths.",
364+
)
365+
scan_config.add_argument("--max-file-bytes", type=int, default=200_000, help="Maximum bytes read from each config file.")
366+
add_runtime_args(scan_config)
367+
scan_config.set_defaults(func=command_scan_config)
368+
282369
doctor = subparsers.add_parser("doctor", help="Check the cached scanner artifact.")
283370
doctor.add_argument("--artifact", type=Path, default=DEFAULT_ARTIFACT)
284371
doctor.add_argument("--pretty", action="store_true")

0 commit comments

Comments
 (0)