Skip to content

Commit 4949a5e

Browse files
committed
Improve MCP config auto discovery
1 parent e850023 commit 4949a5e

5 files changed

Lines changed: 92 additions & 22 deletions

File tree

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ python3 -m venv .venv-intentprobe
4040
.venv-intentprobe/bin/intentprobe scan-config auto --format summary
4141
```
4242

43-
Then scan the MCP tools already configured on your machine:
43+
Then scan the MCP tools already configured on your machine. `scan-config auto`
44+
checks common Claude Desktop, Claude Code, Codex, Cursor, Windsurf, and repo MCP
45+
config locations:
4446

4547
```bash
4648
intentprobe scan-config auto --format summary
@@ -175,7 +177,7 @@ Same test sets. Same split. Same seed. Every number is reproducible from `resear
175177
## Try it
176178

177179
```bash
178-
# Scan Claude/Cursor/Claude Code MCP configs already on this machine
180+
# Scan Claude/Cursor/Codex MCP configs already on this machine
179181
intentprobe scan-config auto --format summary
180182

181183
# Scan a suspicious tool description
@@ -300,8 +302,10 @@ blocked.
300302
301303
scan-config:
302304
├── Claude Desktop claude_desktop_config.json
303-
├── Claude Code ~/.claude/mcp.json
305+
├── Claude Code ~/.claude.json, ~/.claude/mcp.json
306+
├── Codex ~/.codex/config.toml
304307
├── Cursor ~/.cursor/mcp.json
308+
├── Windsurf ~/.codeium/windsurf/mcp_config.json
305309
└── local repo .mcp.json
306310
307311
runtime:

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.2"
3+
__version__ = "0.1.3"

intentprobe/scanner/configs.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
from pathlib import Path
99
from typing import Any
1010

11+
try:
12+
import tomllib
13+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback
14+
import tomli as tomllib
15+
1116
from .core import CORE_VERSION, DECISION_POLICY_NAME
1217
from .hook import ScanSubject, max_decision, object_subject
1318

@@ -20,13 +25,9 @@
2025
}
2126

2227
REVIEW_FLAG_IDS = {
23-
"env-secrets",
24-
"remote-http",
2528
"browser-access",
2629
"filesystem-access",
2730
"code-execution",
28-
"email-or-identity",
29-
"repo-or-ticketing-access",
3031
}
3132

3233

@@ -57,7 +58,9 @@ def default_config_candidates(cwd: Path | None = None) -> list[ConfigCandidate]:
5758
working_dir = cwd or Path.cwd()
5859
candidates = [
5960
ConfigCandidate("Claude Desktop", home / "Library/Application Support/Claude/claude_desktop_config.json"),
60-
ConfigCandidate("Claude Code", home / ".claude/mcp.json"),
61+
ConfigCandidate("Claude Code Global", home / ".claude.json"),
62+
ConfigCandidate("Claude Code MCP", home / ".claude/mcp.json"),
63+
ConfigCandidate("Codex", home / ".codex/config.toml"),
6164
ConfigCandidate("Cursor", home / ".cursor/mcp.json"),
6265
ConfigCandidate("Cursor User", home / "Library/Application Support/Cursor/User/mcp.json"),
6366
ConfigCandidate("Windsurf", home / ".codeium/windsurf/mcp_config.json"),
@@ -85,19 +88,29 @@ def config_candidates_from_target(target: str | Path | None, cwd: Path | None =
8588
return [ConfigCandidate("custom", Path(target).expanduser())]
8689

8790

88-
def load_json_config(path: Path, max_file_bytes: int) -> tuple[dict[str, Any] | None, str | None]:
91+
def load_structured_config(path: Path, max_file_bytes: int) -> tuple[dict[str, Any] | None, str | None]:
8992
try:
9093
raw = path.read_bytes()
9194
except OSError as exc:
9295
return None, f"read_error: {exc}"
9396
if len(raw) > max_file_bytes:
9497
return None, f"file_too_large: {len(raw)} bytes > {max_file_bytes}"
9598
try:
96-
payload = json.loads(raw.decode("utf-8"))
99+
text = raw.decode("utf-8")
97100
except UnicodeDecodeError as exc:
98101
return None, f"decode_error: {exc}"
99-
except json.JSONDecodeError as exc:
100-
return None, f"json_error: {exc}"
102+
103+
if path.suffix == ".toml":
104+
try:
105+
payload = tomllib.loads(text)
106+
except tomllib.TOMLDecodeError as exc:
107+
return None, f"toml_error: {exc}"
108+
else:
109+
try:
110+
payload = json.loads(text)
111+
except json.JSONDecodeError as exc:
112+
return None, f"json_error: {exc}"
113+
101114
if not isinstance(payload, dict):
102115
return None, f"unsupported_json_type: {type(payload).__name__}"
103116
return payload, None
@@ -147,7 +160,7 @@ def collect_config_servers(
147160
configs.append(public_config)
148161
continue
149162

150-
payload, error = load_json_config(path, max_file_bytes)
163+
payload, error = load_structured_config(path, max_file_bytes)
151164
if error is not None or payload is None:
152165
public_config.update({"status": "invalid", "error": error})
153166
configs.append(public_config)
@@ -194,7 +207,7 @@ def inventory_flags(server: ConfigServer) -> list[dict[str, str]]:
194207
flags.append(
195208
{
196209
"id": "env-secrets",
197-
"level": "review",
210+
"level": "info",
198211
"reason": "server config includes environment variables; values are redacted",
199212
}
200213
)
@@ -203,7 +216,7 @@ def inventory_flags(server: ConfigServer) -> list[dict[str, str]]:
203216
flags.append(
204217
{
205218
"id": "remote-http",
206-
"level": "review",
219+
"level": "info",
207220
"reason": "server connects to a remote MCP endpoint",
208221
}
209222
)
@@ -248,7 +261,7 @@ def inventory_flags(server: ConfigServer) -> list[dict[str, str]]:
248261
flags.append(
249262
{
250263
"id": "email-or-identity",
251-
"level": "review",
264+
"level": "info",
252265
"reason": "server appears connected to email, calendar, OAuth, or identity data",
253266
}
254267
)
@@ -257,7 +270,7 @@ def inventory_flags(server: ConfigServer) -> list[dict[str, str]]:
257270
flags.append(
258271
{
259272
"id": "repo-or-ticketing-access",
260-
"level": "review",
273+
"level": "info",
261274
"reason": "server appears connected to code repositories or ticketing systems",
262275
}
263276
)
@@ -276,7 +289,7 @@ def product_decision_for_config_scan(risk: dict[str, Any], flags: list[dict[str,
276289

277290
scanner_decision = str(risk.get("decision", "allow"))
278291
static_score = float(risk.get("static_score") or 0.0)
279-
review_flags = [flag for flag in flags if flag.get("id") in REVIEW_FLAG_IDS]
292+
review_flags = [flag for flag in flags if flag.get("level") == "review" and flag.get("id") in REVIEW_FLAG_IDS]
280293
reasons: list[str] = []
281294

282295
if scanner_decision == "block":

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "intentprobe"
7-
version = "0.1.2"
7+
version = "0.1.3"
88
description = "Local activation-probe scanner for AI agent tools, MCP servers, and skills."
99
readme = "README.md"
1010
requires-python = ">=3.10,<3.14"
@@ -17,6 +17,7 @@ dependencies = [
1717
"psutil",
1818
"scikit-learn",
1919
"sentencepiece",
20+
"tomli; python_version < '3.11'",
2021
"torch",
2122
"transformers>=4.40",
2223
]

tests/test_scan_config.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
ConfigServer,
1111
build_config_scan_payload,
1212
collect_config_servers,
13+
default_config_candidates,
1314
inventory_flags,
1415
product_decision_for_config_scan,
1516
subject_for_server,
@@ -31,6 +32,41 @@ def test_config_without_mcp_servers_is_skipped(self) -> None:
3132
self.assertEqual("no_mcp_servers", configs[0]["status"])
3233
self.assertEqual(0, configs[0]["server_count"])
3334

35+
def test_toml_codex_mcp_servers_are_collected(self) -> None:
36+
with tempfile.TemporaryDirectory() as tmp:
37+
path = Path(tmp) / "config.toml"
38+
path.write_text(
39+
"\n".join(
40+
[
41+
"[mcp_servers.github]",
42+
'command = "npx"',
43+
'args = ["-y", "@modelcontextprotocol/server-github"]',
44+
"",
45+
"[mcp_servers.chrome]",
46+
'command = "npx"',
47+
'args = ["-y", "chrome-devtools-mcp"]',
48+
]
49+
)
50+
)
51+
52+
configs, servers = collect_config_servers(
53+
[ConfigCandidate("Codex", path)],
54+
max_file_bytes=200_000,
55+
)
56+
57+
self.assertEqual("scanned", configs[0]["status"])
58+
self.assertEqual("mcp_servers", configs[0]["servers_key"])
59+
self.assertEqual(2, configs[0]["server_count"])
60+
self.assertEqual(["chrome", "github"], [server.name for server in servers])
61+
62+
def test_auto_candidates_include_claude_global_and_codex(self) -> None:
63+
sources = {candidate.source: str(candidate.path) for candidate in default_config_candidates(Path("/repo"))}
64+
65+
self.assertIn("Claude Code Global", sources)
66+
self.assertTrue(sources["Claude Code Global"].endswith("/.claude.json"))
67+
self.assertIn("Codex", sources)
68+
self.assertTrue(sources["Codex"].endswith("/.codex/config.toml"))
69+
3470
def test_activation_only_manifest_signal_downgrades_to_allow(self) -> None:
3571
risk = {
3672
"decision": "warn",
@@ -49,13 +85,29 @@ def test_review_inventory_flag_keeps_config_in_review_tier(self) -> None:
4985
"activation_score": 0.97,
5086
"static_score": 0.0,
5187
}
52-
flags = [{"id": "remote-http", "level": "review", "reason": "server connects remotely"}]
88+
flags = [{"id": "browser-access", "level": "review", "reason": "server controls a browser"}]
5389

5490
decision, reasons = product_decision_for_config_scan(risk, flags)
5591

5692
self.assertEqual("warn", decision)
5793
self.assertIn("review-worthy", reasons[0])
5894

95+
def test_remote_and_env_inventory_do_not_warn_by_default(self) -> None:
96+
risk = {
97+
"decision": "warn",
98+
"activation_score": 0.97,
99+
"static_score": 0.0,
100+
}
101+
flags = [
102+
{"id": "remote-http", "level": "info", "reason": "server connects remotely"},
103+
{"id": "env-secrets", "level": "info", "reason": "server uses environment variables"},
104+
]
105+
106+
decision, reasons = product_decision_for_config_scan(risk, flags)
107+
108+
self.assertEqual("allow", decision)
109+
self.assertIn("activation-only", reasons[0])
110+
59111
def test_env_and_remote_server_inventory_flags_do_not_emit_raw_config(self) -> None:
60112
with tempfile.TemporaryDirectory() as tmp:
61113
path = Path(tmp) / "mcp.json"
@@ -107,7 +159,7 @@ def test_env_and_remote_server_inventory_flags_do_not_emit_raw_config(self) -> N
107159

108160
self.assertIn("env-secrets", {flag["id"] for flag in flags})
109161
self.assertIn("remote-http", {flag["id"] for flag in flags})
110-
self.assertEqual("warn", payload["results"][0]["decision"])
162+
self.assertEqual("allow", payload["results"][0]["decision"])
111163
self.assertNotIn("secret-value", json.dumps(payload, sort_keys=True))
112164
self.assertNotIn("redacted_config", payload["results"][0])
113165

0 commit comments

Comments
 (0)