Skip to content

Commit ca16d89

Browse files
2233adminclaude
andcommitted
fix(doctor): correct routing model -- discover 5 sources, not 3
Initial release missed two MCP definition sources: ~/.mcp.json (home-level, gated by enabledMcpjsonServers) ~/.claude/.mcp.json (claude-level, gated by enabledMcpjsonServers) This caused false-positive "orphan whitelist" entries (any MCP defined in ~/.mcp.json was flagged as orphan since v1 only checked plugin .mcp.json). Also fixed: - Plugin .mcp.json files auto-load with `mcp__plugin_<plugin>_<name>__` prefix and are NOT gated by enabledMcpjsonServers. v1 incorrectly flagged them as "bundled inactive". v2 reports them as informational plugin namespace, grouped by plugin name. - New drift class: duplicate definitions across user-level sources. Same MCP name in 2+ of {global, project, user_home, user_claude} causes routing ambiguity (e.g. llm-wiki defined in both ~/.claude.json and ~/.mcp.json with different commands). Five sources now tracked with explicit labels: global, project, user_home, user_claude, plugin. Whitelist (enabledMcpjsonServers) only gates user_home and user_claude. Disable list applies to all user-level sources. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd7035c commit ca16d89

1 file changed

Lines changed: 171 additions & 102 deletions

File tree

cli2skill/doctor.py

Lines changed: 171 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
1-
"""cli2skill doctor audit Claude Code MCP routing chain for drift.
1+
"""cli2skill doctor -- audit Claude Code MCP routing chain.
22
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
3+
Five sources of MCP definitions:
74
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
5+
G ~/.claude.json mcpServers (global) -> mcp__<name>__
6+
P ~/.claude.json projects[*].mcpServers -> mcp__<name>__ (per-cwd)
7+
Uh ~/.mcp.json -> mcp__<name>__ (gated by W)
8+
Uc ~/.claude/.mcp.json -> mcp__<name>__ (gated by W)
9+
PL ~/.claude/plugins/cache/**/.mcp.json -> mcp__plugin_<plugin>_<name>__
1210
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.
11+
Two gates:
12+
13+
W ~/.claude/settings.{json,local.json} enabledMcpjsonServers (whitelist Uh+Uc)
14+
D ~/.claude.json projects[*].disabledMcpServers (blacklist)
15+
16+
Drift classes:
17+
18+
- Orphan whitelist: W entry not defined in Uh or Uc (whitelist references nothing)
19+
- Conflict state: MCP in both W and D (resolution ambiguous)
20+
- Duplicate names: same MCP name in 2+ user-level sources (routing ambiguity)
21+
22+
Plugin MCPs are reported informationally, not as drift. Plugin .mcp.json files
23+
auto-load when the plugin is installed and prefix tools as `plugin_<plugin>_`.
1524
"""
1625
from __future__ import annotations
1726

@@ -20,6 +29,16 @@
2029
import sys
2130
from pathlib import Path
2231

32+
# Source labels
33+
G_GLOBAL = "global"
34+
G_PROJECT = "project"
35+
G_USER_HOME = "user_home"
36+
G_USER_CLAUDE = "user_claude"
37+
G_PLUGIN = "plugin"
38+
39+
USER_LEVEL_SOURCES = (G_GLOBAL, G_PROJECT, G_USER_HOME, G_USER_CLAUDE)
40+
WHITELIST_GATED_SOURCES = (G_USER_HOME, G_USER_CLAUDE)
41+
2342

2443
def _load_json(path: Path) -> dict | None:
2544
try:
@@ -33,150 +52,200 @@ def _claude_home() -> Path:
3352
return Path.home() / ".claude"
3453

3554

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 {}
55+
def discover_user_level(claude_home: Path) -> dict[str, list[tuple[str, dict]]]:
56+
"""Return {name: [(source_label, defn), ...]} across G, P, Uh, Uc."""
57+
found: dict[str, list[tuple[str, dict]]] = {}
58+
home = Path.home()
59+
60+
# G + P from ~/.claude.json
61+
claude_json = _load_json(home / ".claude.json")
62+
if claude_json:
63+
for name, defn in (claude_json.get("mcpServers") or {}).items():
64+
found.setdefault(name, []).append((G_GLOBAL, defn))
65+
for proj, conf in (claude_json.get("projects") or {}).items():
66+
for name, defn in (conf.get("mcpServers") or {}).items():
67+
found.setdefault(name, []).append((G_PROJECT, {**defn, "_project": proj}))
68+
69+
# Uh from ~/.mcp.json
70+
user_home_mcp = _load_json(home / ".mcp.json")
71+
if user_home_mcp:
72+
for name, defn in (user_home_mcp.get("mcpServers") or {}).items():
73+
found.setdefault(name, []).append((G_USER_HOME, defn))
74+
75+
# Uc from ~/.claude/.mcp.json
76+
user_claude_mcp = _load_json(claude_home / ".mcp.json")
77+
if user_claude_mcp:
78+
for name, defn in (user_claude_mcp.get("mcpServers") or {}).items():
79+
found.setdefault(name, []).append((G_USER_CLAUDE, defn))
4080

81+
return found
4182

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
5183

84+
def discover_plugin_level(claude_home: Path) -> dict[str, list[tuple[str, Path]]]:
85+
"""Return {name: [(plugin_name, path), ...]} from plugin .mcp.json files."""
86+
found: dict[str, list[tuple[str, Path]]] = {}
87+
plugin_root = claude_home / "plugins" / "cache"
88+
if not plugin_root.exists():
89+
return found
90+
for mcp_json in plugin_root.rglob(".mcp.json"):
91+
data = _load_json(mcp_json)
92+
if not data:
93+
continue
94+
rel = mcp_json.relative_to(plugin_root)
95+
plugin_name = rel.parts[0] if rel.parts else "?"
96+
for name in (data.get("mcpServers") or {}):
97+
found.setdefault(name, []).append((plugin_name, mcp_json))
98+
return found
5299

53-
def discover_disabled(claude_json: dict | None) -> set[str]:
100+
101+
def discover_disabled(claude_home: Path) -> set[str]:
102+
home = Path.home()
103+
claude_json = _load_json(home / ".claude.json")
54104
if not claude_json:
55105
return set()
56106
disabled: set[str] = set()
57107
for proj_conf in (claude_json.get("projects") or {}).values():
58-
for name in proj_conf.get("disabledMcpServers") or []:
108+
for name in (proj_conf.get("disabledMcpServers") or []):
59109
disabled.add(name)
60110
return disabled
61111

62112

63-
def discover_whitelist(settings_files: list[Path]) -> set[str]:
113+
def discover_whitelist(claude_home: Path) -> set[str]:
64114
whitelist: set[str] = set()
65-
for f in settings_files:
66-
data = _load_json(f)
115+
for fname in ("settings.json", "settings.local.json"):
116+
data = _load_json(claude_home / fname)
67117
if not data:
68118
continue
69-
for name in data.get("enabledMcpjsonServers") or []:
119+
for name in (data.get("enabledMcpjsonServers") or []):
70120
whitelist.add(name)
71121
return whitelist
72122

73123

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
124+
def audit(claude_home: Path | None = None) -> dict:
125+
home = claude_home or _claude_home()
126+
user_level = discover_user_level(home)
127+
plugin_level = discover_plugin_level(home)
128+
disabled = discover_disabled(home)
129+
whitelist = discover_whitelist(home)
130+
131+
# Names defined at user level (any of G/P/Uh/Uc)
132+
user_level_names = set(user_level)
133+
# Names gated by whitelist (Uh + Uc only)
134+
whitelist_gated_names = {
135+
n for n, srcs in user_level.items()
136+
if any(s in WHITELIST_GATED_SOURCES for s, _ in srcs)
137+
}
138+
139+
# Drift: orphan whitelist = W entry not in any U source
140+
orphan_whitelist = sorted(whitelist - whitelist_gated_names)
86141

142+
# Drift: conflict state = same name in W and D
143+
conflict_state = sorted(whitelist & disabled)
87144

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"
145+
# Drift: duplicate definitions across user-level sources
146+
duplicates = []
147+
for name, sources in user_level.items():
148+
labels = [s for s, _ in sources]
149+
if len(labels) >= 2:
150+
duplicates.append({"name": name, "sources": labels})
94151

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)
152+
# Group plugin-level MCPs by plugin name
153+
by_plugin: dict[str, list[str]] = {}
154+
for name, entries in plugin_level.items():
155+
for plugin_name, _ in entries:
156+
by_plugin.setdefault(plugin_name, []).append(name)
157+
plugin_groups = {p: sorted(set(names)) for p, names in by_plugin.items()}
101158

102159
return {
103160
"summary": {
104-
"global_mcp": sorted(global_mcp),
105-
"project_mcp": sorted(project_mcp),
106-
"disabled": sorted(disabled),
161+
"user_level": {
162+
src: sorted(n for n, srcs in user_level.items() if any(s == src for s, _ in srcs))
163+
for src in USER_LEVEL_SOURCES
164+
},
107165
"whitelist": sorted(whitelist),
108-
"bundled_in_plugins": sorted(bundled),
166+
"disabled": sorted(disabled),
167+
"plugin_count": len(plugin_groups),
168+
"plugin_total_mcps": sum(len(v) for v in plugin_groups.values()),
109169
},
110170
"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()
171+
"orphan_whitelist": orphan_whitelist,
172+
"conflict_state": conflict_state,
173+
"duplicate_definitions": duplicates,
117174
},
175+
"plugin_namespace": plugin_groups,
118176
}
119177

120178

121179
def _print_human(report: dict, home: Path) -> bool:
122-
"""Print human-readable report. Return True if drift was found."""
123180
s = report["summary"]
124181
d = report["drift"]
182+
ul = s["user_level"]
125183

126184
print("=== cli2skill doctor -- MCP routing audit ===")
127185
print(f"Claude home: {home}")
128186
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)'}")
187+
print("## User-level MCPs (loaded as mcp__<name>__)")
188+
print(f" global ~/.claude.json mcpServers ({len(ul[G_GLOBAL])}): {', '.join(ul[G_GLOBAL]) or '(none)'}")
189+
print(f" project ~/.claude.json projects ({len(ul[G_PROJECT])}): {', '.join(ul[G_PROJECT]) or '(none)'}")
190+
print(f" user ~/.mcp.json ({len(ul[G_USER_HOME])}): {', '.join(ul[G_USER_HOME]) or '(none)'}")
191+
print(f" claude ~/.claude/.mcp.json ({len(ul[G_USER_CLAUDE])}): {', '.join(ul[G_USER_CLAUDE]) or '(none)'}")
192+
print()
193+
print(f"## Whitelist (enabledMcpjsonServers, {len(s['whitelist'])}): {', '.join(s['whitelist']) or '(none)'}")
194+
print(f"## Disabled (disabledMcpServers, {len(s['disabled'])}): {', '.join(s['disabled']) or '(none)'}")
134195
print()
135196

136-
has_drift = bool(d["orphan_whitelist"] or d["conflict_state"] or d["bundled_inactive"])
197+
has_drift = bool(d["orphan_whitelist"] or d["conflict_state"] or d["duplicate_definitions"])
137198

138199
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"]:
200+
print("OK: no drift detected.")
201+
else:
202+
print("## Drift detected")
203+
if d["orphan_whitelist"]:
204+
print()
205+
print("[WARN] Orphan whitelist entries (W references no Uh or Uc definition):")
206+
for name in d["orphan_whitelist"]:
207+
print(f" - {name}")
208+
print(f" Fix: remove from enabledMcpjsonServers in ~/.claude/settings.local.json")
209+
print(f" (these whitelist entries don't gate anything; whitelist only")
210+
print(f" gates ~/.mcp.json and ~/.claude/.mcp.json definitions)")
211+
212+
if d["conflict_state"]:
213+
print()
214+
print("[FAIL] Conflict state (MCP in both W and D, resolution ambiguous):")
215+
for name in d["conflict_state"]:
216+
print(f" - {name}")
217+
print(f" Fix: pick one. Remove from enabledMcpjsonServers OR from disabledMcpServers.")
218+
219+
if d["duplicate_definitions"]:
220+
print()
221+
print("[FAIL] Duplicate definitions (same name in 2+ sources, routing ambiguous):")
222+
for dup in d["duplicate_definitions"]:
223+
print(f" - {dup['name']}: defined in {', '.join(dup['sources'])}")
224+
print(f" Fix: keep one definition, remove the others. Same MCP name with")
225+
print(f" different commands across sources causes silent surprises.")
226+
227+
if s["plugin_count"]:
158228
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.")
229+
print(f"## Plugin namespace (informational, auto-loaded as mcp__plugin_<plugin>_<name>__)")
230+
print(f" {s['plugin_count']} plugins bundling {s['plugin_total_mcps']} MCPs total")
231+
for plugin in sorted(report["plugin_namespace"]):
232+
mcps = report["plugin_namespace"][plugin]
233+
preview = ", ".join(mcps[:5]) + (f", +{len(mcps)-5} more" if len(mcps) > 5 else "")
234+
print(f" - {plugin}: {len(mcps)} MCP{'s' if len(mcps)!=1 else ''} ({preview})")
235+
print(f" Note: these are NOT drift. Plugin MCPs auto-load with their plugin.")
166236

167237
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
238+
print("Tip: stateless MCPs (HTTP wrappers) are good candidates for conversion via")
239+
print(" `cli2skill mcp <command>` -- replaces persistent process with skill.")
240+
return has_drift
172241

173242

174243
def cmd_doctor(args: argparse.Namespace) -> None:
175244
home = _claude_home()
176245
report = audit(home)
177246

178247
if args.json:
179-
print(json.dumps(report, indent=2, ensure_ascii=False))
248+
print(json.dumps(report, indent=2, ensure_ascii=False, default=str))
180249
sys.exit(1 if any(report["drift"].values()) else 0)
181250

182251
has_drift = _print_human(report, home)

0 commit comments

Comments
 (0)