Skip to content

Commit dd7035c

Browse files
2233adminclaude
andcommitted
feat(doctor): add MCP routing drift audit subcommand
Adds `cli2skill doctor` that audits the three-layer Claude Code MCP routing chain and reports drift: 1. ~/.claude.json mcpServers (global + per-project) 2. ~/.claude/settings.{json,local.json} enabledMcpjsonServers whitelist 3. ~/.claude/plugins/cache/**/.mcp.json plugin-bundled definitions Three drift classes detected: - Orphan whitelist: enabledMcpjsonServers references no plugin .mcp.json - Conflict state: same MCP in both enabled and disabled lists - Bundled inactive: plugin defines an MCP nobody whitelisted Solves the silent drift problem where the deferred tools list shown to the LLM at session start != MCPs actually loaded. Pairs with mcp2skill: doctor finds conversion candidates, mcp2skill transforms them. Zero new dependencies. Subcommand aliases: doctor / dr / audit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3b25286 commit dd7035c

2 files changed

Lines changed: 189 additions & 0 deletions

File tree

cli2skill/doctor.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""cli2skill doctor — audit Claude Code MCP routing chain for drift.
2+
3+
Reads the three-layer Claude Code config:
4+
1. ~/.claude.json -- mcpServers (global + per-project) + disabledMcpServers
5+
2. ~/.claude/settings.{json,local.json} -- enabledMcpjsonServers whitelist
6+
3. ~/.claude/plugins/cache/**/.mcp.json -- plugin-bundled MCP definitions
7+
8+
Reports three drift classes:
9+
- Orphan whitelist: enabledMcpjsonServers references no plugin .mcp.json
10+
- Conflict state: same MCP in both enabledMcpjsonServers and disabledMcpServers
11+
- Bundled but inactive: plugin defines an MCP nobody whitelisted
12+
13+
Why: deferred tools list shown to the LLM at session start != MCPs actually
14+
loaded. Plugin updates and stale user whitelists drift apart silently.
15+
"""
16+
from __future__ import annotations
17+
18+
import argparse
19+
import json
20+
import sys
21+
from pathlib import Path
22+
23+
24+
def _load_json(path: Path) -> dict | None:
25+
try:
26+
with path.open(encoding="utf-8") as f:
27+
return json.load(f)
28+
except (FileNotFoundError, json.JSONDecodeError, PermissionError, OSError):
29+
return None
30+
31+
32+
def _claude_home() -> Path:
33+
return Path.home() / ".claude"
34+
35+
36+
def discover_global_mcp(claude_json: dict | None) -> dict[str, dict]:
37+
if not claude_json:
38+
return {}
39+
return claude_json.get("mcpServers") or {}
40+
41+
42+
def discover_project_mcp(claude_json: dict | None) -> dict[str, dict]:
43+
if not claude_json:
44+
return {}
45+
projects = claude_json.get("projects") or {}
46+
result: dict[str, dict] = {}
47+
for proj, conf in projects.items():
48+
for name, defn in (conf.get("mcpServers") or {}).items():
49+
result[name] = {**defn, "_project": proj}
50+
return result
51+
52+
53+
def discover_disabled(claude_json: dict | None) -> set[str]:
54+
if not claude_json:
55+
return set()
56+
disabled: set[str] = set()
57+
for proj_conf in (claude_json.get("projects") or {}).values():
58+
for name in proj_conf.get("disabledMcpServers") or []:
59+
disabled.add(name)
60+
return disabled
61+
62+
63+
def discover_whitelist(settings_files: list[Path]) -> set[str]:
64+
whitelist: set[str] = set()
65+
for f in settings_files:
66+
data = _load_json(f)
67+
if not data:
68+
continue
69+
for name in data.get("enabledMcpjsonServers") or []:
70+
whitelist.add(name)
71+
return whitelist
72+
73+
74+
def discover_plugin_mcps(plugin_root: Path) -> dict[str, list[Path]]:
75+
"""Return {mcp_name: [path, ...]} mapping each MCP to plugins that define it."""
76+
found: dict[str, list[Path]] = {}
77+
if not plugin_root.exists():
78+
return found
79+
for mcp_json in plugin_root.rglob(".mcp.json"):
80+
data = _load_json(mcp_json)
81+
if not data:
82+
continue
83+
for name in (data.get("mcpServers") or {}):
84+
found.setdefault(name, []).append(mcp_json)
85+
return found
86+
87+
88+
def audit(home: Path | None = None) -> dict:
89+
"""Run the audit and return a structured report."""
90+
home = home or _claude_home()
91+
claude_json = _load_json(Path.home() / ".claude.json")
92+
settings_files = [home / "settings.json", home / "settings.local.json"]
93+
plugin_root = home / "plugins" / "cache"
94+
95+
global_mcp = discover_global_mcp(claude_json)
96+
project_mcp = discover_project_mcp(claude_json)
97+
disabled = discover_disabled(claude_json)
98+
whitelist = discover_whitelist(settings_files)
99+
plugin_mcps = discover_plugin_mcps(plugin_root)
100+
bundled = set(plugin_mcps)
101+
102+
return {
103+
"summary": {
104+
"global_mcp": sorted(global_mcp),
105+
"project_mcp": sorted(project_mcp),
106+
"disabled": sorted(disabled),
107+
"whitelist": sorted(whitelist),
108+
"bundled_in_plugins": sorted(bundled),
109+
},
110+
"drift": {
111+
"orphan_whitelist": sorted(whitelist - bundled),
112+
"conflict_state": sorted(whitelist & disabled),
113+
"bundled_inactive": sorted(bundled - whitelist - set(global_mcp) - set(project_mcp)),
114+
},
115+
"plugin_origins": {
116+
name: [str(p) for p in paths] for name, paths in plugin_mcps.items()
117+
},
118+
}
119+
120+
121+
def _print_human(report: dict, home: Path) -> bool:
122+
"""Print human-readable report. Return True if drift was found."""
123+
s = report["summary"]
124+
d = report["drift"]
125+
126+
print("=== cli2skill doctor -- MCP routing audit ===")
127+
print(f"Claude home: {home}")
128+
print()
129+
print(f"## Active MCPs")
130+
print(f" Global mcpServers ({len(s['global_mcp'])}): {', '.join(s['global_mcp']) or '(none)'}")
131+
print(f" Project mcpServers ({len(s['project_mcp'])}): {', '.join(s['project_mcp']) or '(none)'}")
132+
active_plugins = sorted(set(s['whitelist']) & set(s['bundled_in_plugins']))
133+
print(f" Whitelisted plugin MCPs ({len(active_plugins)}): {', '.join(active_plugins) or '(none)'}")
134+
print()
135+
136+
has_drift = bool(d["orphan_whitelist"] or d["conflict_state"] or d["bundled_inactive"])
137+
138+
if not has_drift:
139+
print("OK: no drift detected. Routing chain is clean.")
140+
return False
141+
142+
print("## Drift detected")
143+
if d["orphan_whitelist"]:
144+
print()
145+
print("[WARN] Orphan whitelist entries (enabledMcpjsonServers references no plugin):")
146+
for name in d["orphan_whitelist"]:
147+
print(f" - {name}")
148+
print(f" Fix: remove from enabledMcpjsonServers in ~/.claude/settings.local.json")
149+
150+
if d["conflict_state"]:
151+
print()
152+
print("[FAIL] Conflict state (MCP in both enabled and disabled lists):")
153+
for name in d["conflict_state"]:
154+
print(f" - {name}")
155+
print(f" Fix: pick one. Remove from enabledMcpjsonServers OR from disabledMcpServers.")
156+
157+
if d["bundled_inactive"]:
158+
print()
159+
print(f"[INFO] Plugin-bundled but not whitelisted ({len(d['bundled_inactive'])}):")
160+
for name in d["bundled_inactive"]:
161+
paths = report["plugin_origins"].get(name, [])
162+
origin = Path(paths[0]).name if paths else "?"
163+
print(f" - {name} (defined in {origin})")
164+
print(f" Note: these appear in deferred tools list but are NOT loaded.")
165+
print(f" Add to enabledMcpjsonServers to activate, or ignore.")
166+
167+
print()
168+
print("Tip: stateless MCPs (simple HTTP wrappers) are good candidates for")
169+
print(" conversion via `cli2skill mcp <command>` -- replaces the persistent")
170+
print(" process with an on-demand skill.")
171+
return True
172+
173+
174+
def cmd_doctor(args: argparse.Namespace) -> None:
175+
home = _claude_home()
176+
report = audit(home)
177+
178+
if args.json:
179+
print(json.dumps(report, indent=2, ensure_ascii=False))
180+
sys.exit(1 if any(report["drift"].values()) else 0)
181+
182+
has_drift = _print_human(report, home)
183+
sys.exit(1 if has_drift else 0)

cli2skill/main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .parser import run_help, parse_help_text, parse_subcommand_help
77
from .generator import generate_skill
88
from .mcp2skill import connect_and_extract, extract_from_config, generate_mcp_skill
9+
from .doctor import cmd_doctor
910

1011

1112
def cmd_generate(args: argparse.Namespace) -> None:
@@ -146,6 +147,11 @@ def app() -> None:
146147
m.add_argument("--env", action="append", default=[], help="Extra env vars (KEY=VALUE), repeatable")
147148
m.set_defaults(func=cmd_mcp)
148149

150+
# doctor
151+
d = sub.add_parser("doctor", aliases=["dr", "audit"], help="Audit MCP routing chain for drift")
152+
d.add_argument("--json", action="store_true", help="Output JSON instead of text")
153+
d.set_defaults(func=cmd_doctor)
154+
149155
args = p.parse_args()
150156
if not args.cmd:
151157
p.print_help()

0 commit comments

Comments
 (0)