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"""
1625from __future__ import annotations
1726
2029import sys
2130from 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
2443def _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
121179def _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
174243def 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