@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,768 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Broad, read-only MCP client config discovery for mcp_doctor.
4
+
5
+ The module deliberately separates discovery from parsing:
6
+
7
+ * a declarative host registry describes candidate paths, formats and keys;
8
+ * parsers only extract server names (never commands, args or env values);
9
+ * every registry entry appears in a coverage report, so a caller can never
10
+ mistake "the one file I happened to parse" for "all configs are healthy".
11
+
12
+ Python 3.8+ standard library only. No network, no writes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import fnmatch
18
+ import glob
19
+ import json
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+
24
+ from dev_common import _frontmatter_name, _frontmatter_version, _read_text
25
+
26
+ VERSION = 1
27
+ DEFAULT_MAX_CONFIG_BYTES = 2_000_000
28
+ DEFAULT_MAX_CONFIGS = 500
29
+ DEFAULT_MAX_SKILL_DIRS = 120
30
+
31
+ DEFAULT_JSON_KEYS = ("mcpServers", "mcp", "servers", "context_servers")
32
+ DEFAULT_TOML_KEYS = ("mcp_servers",)
33
+
34
+
35
+ def _host(host_id, label, candidates, formats=("json",), server_keys=None,
36
+ tier="verified", note="", exclude=()):
37
+ return {
38
+ "id": host_id,
39
+ "label": label,
40
+ "candidates": list(candidates),
41
+ "formats": list(formats),
42
+ "server_keys": list(server_keys or DEFAULT_JSON_KEYS),
43
+ "tier": tier,
44
+ "note": note,
45
+ "exclude": list(exclude),
46
+ }
47
+
48
+
49
+ def _builtin_hosts():
50
+ """A broad, tiered registry.
51
+
52
+ ``verified`` means the path/format is documented or directly observed.
53
+ ``unverified`` means the path is a best-effort candidate: if it exists it
54
+ is parsed, but a missing file is reported as a coverage gap instead of
55
+ being silently treated as "not installed".
56
+ """
57
+ return [
58
+ _host(
59
+ "codex", "Codex",
60
+ [
61
+ "{codex_home}/config.toml",
62
+ "{home}/.codex/config.toml",
63
+ "{codex_home}/config.json",
64
+ "{codex_home}/mcp.json",
65
+ "{home}/.codex/config.json",
66
+ "{home}/.codex/mcp.json",
67
+ ],
68
+ formats=("toml", "json"),
69
+ server_keys=("mcp_servers",) + DEFAULT_JSON_KEYS,
70
+ ),
71
+ _host(
72
+ "claude-code", "Claude Code",
73
+ [
74
+ "{claude_config_dir}/settings.json",
75
+ "{home}/.claude/settings.json",
76
+ "{home}/.claude.json",
77
+ "{cwd}/.mcp.json",
78
+ ],
79
+ ),
80
+ _host(
81
+ "cursor", "Cursor",
82
+ ["{home}/.cursor/mcp.json", "{cwd}/.cursor/mcp.json"],
83
+ ),
84
+ _host(
85
+ "opencode", "OpenCode",
86
+ [
87
+ "{xdg_config_home}/opencode/opencode.jsonc",
88
+ "{xdg_config_home}/opencode/opencode.json",
89
+ "{home}/.config/opencode/opencode.jsonc",
90
+ "{home}/.config/opencode/opencode.json",
91
+ "{cwd}/opencode.jsonc",
92
+ "{cwd}/opencode.json",
93
+ ],
94
+ formats=("jsonc", "json"),
95
+ server_keys=("mcp", "mcpServers", "servers"),
96
+ ),
97
+ _host(
98
+ "workbuddy", "WorkBuddy",
99
+ [
100
+ "{home}/.workbuddy/mcp.json",
101
+ "{home}/.workbuddy/connectors/*/mcp.json",
102
+ ],
103
+ exclude=("**/connectors-marketplace/**",),
104
+ ),
105
+ _host(
106
+ "windsurf", "Windsurf",
107
+ [
108
+ "{home}/.codeium/windsurf/mcp_config.json",
109
+ "{home}/.codeium/windsurf/mcp.json",
110
+ ],
111
+ ),
112
+ _host(
113
+ "continue", "Continue",
114
+ ["{home}/.continue/config.json", "{home}/.continue/config.yaml"],
115
+ formats=("json", "yaml"),
116
+ ),
117
+ _host(
118
+ "gemini", "Gemini CLI",
119
+ ["{home}/.gemini/settings.json", "{home}/.gemini/mcp.json"],
120
+ ),
121
+ _host(
122
+ "qwen", "Qwen Code",
123
+ ["{home}/.qwen/settings.json", "{home}/.qwen/mcp.json"],
124
+ ),
125
+ _host(
126
+ "trae", "Trae Code CLI",
127
+ ["{home}/.traecli/mcp.json", "{home}/.trae/mcp.json"],
128
+ ),
129
+ _host(
130
+ "trae-cn", "Trae IDE",
131
+ ["{home}/.trae-cn/mcp.json"],
132
+ ),
133
+ _host(
134
+ "comate", "Comate",
135
+ ["{home}/.comate/mcp.json", "{home}/.comate/settings.json"],
136
+ ),
137
+ _host(
138
+ "codebuddy", "CodeBuddy Code",
139
+ ["{home}/.codebuddy/mcp.json", "{home}/.codebuddy/settings.json"],
140
+ ),
141
+ _host(
142
+ "kimi", "Kimi Code CLI",
143
+ ["{home}/.kimi/mcp.json", "{home}/.kimi/settings.json"],
144
+ ),
145
+ _host(
146
+ "kiro", "Kiro",
147
+ ["{home}/.kiro/mcp.json", "{home}/.kiro/settings.json"],
148
+ ),
149
+ _host(
150
+ "vscode", "VS Code",
151
+ [
152
+ "{cwd}/.vscode/mcp.json",
153
+ "{env:APPDATA}/Code/User/mcp.json",
154
+ "{xdg_config_home}/Code/User/mcp.json",
155
+ ],
156
+ ),
157
+ _host(
158
+ "zed", "Zed",
159
+ [
160
+ "{xdg_config_home}/zed/settings.json",
161
+ "{home}/.config/zed/settings.json",
162
+ ],
163
+ server_keys=("context_servers", "mcpServers", "mcp", "servers"),
164
+ ),
165
+ _host(
166
+ "goose", "Goose",
167
+ ["{xdg_config_home}/goose/config.yaml", "{home}/.config/goose/config.yaml"],
168
+ formats=("yaml",),
169
+ note="Goose YAML is reported as unsupported, never silently skipped.",
170
+ ),
171
+ # The following paths are useful best-effort candidates but are not
172
+ # claimed as verified. If they are missing they show up as coverage
173
+ # gaps, which keeps "all_clear" honest.
174
+ _host("cline", "Cline", ["{home}/.cline/mcp.json", "{home}/.config/cline/mcp.json"], tier="unverified"),
175
+ _host("roo-code", "Roo Code", ["{home}/.roo/mcp.json", "{home}/.config/roo/mcp.json"], tier="unverified"),
176
+ _host("amp", "Amp", ["{home}/.config/amp/settings.json"], tier="unverified"),
177
+ _host("lm-studio", "LM Studio", ["{home}/.lmstudio/mcp.json", "{home}/.cache/lm-studio/mcp.json"], tier="unverified"),
178
+ _host("jetbrains", "JetBrains", ["{cwd}/.idea/mcp.json", "{home}/.config/JetBrains/*/mcp.json"], tier="unverified"),
179
+ _host("warp", "Warp", ["{home}/.warp/mcp.json"], tier="unverified"),
180
+ _host("5ire", "5ire", ["{home}/.5ire/mcp.json"], tier="unverified"),
181
+ _host("witsy", "Witsy", ["{home}/.witsy/mcp.json"], tier="unverified"),
182
+ _host("enconvo", "Enconvo", ["{home}/.enconvo/mcp.json"], tier="unverified"),
183
+ _host("chatwise", "ChatWise", ["{home}/.chatwise/mcp.json"], tier="unverified"),
184
+ _host("jan", "Jan", ["{home}/.jan/mcp.json"], tier="unverified"),
185
+ _host("msty", "Msty", ["{home}/.msty/mcp.json"], tier="unverified"),
186
+ _host("boltai", "BoltAI", ["{home}/.boltai/mcp.json"], tier="unverified"),
187
+ _host("copilot-cli", "GitHub Copilot CLI", ["{home}/.copilot/mcp-config.json"], tier="unverified"),
188
+ _host("factory-droid", "Factory Droid", ["{home}/.factory/mcp.json"], tier="unverified"),
189
+ _host("qoder", "Qoder", ["{home}/.qoder/mcp.json"], tier="unverified"),
190
+ _host("lingma", "Lingma", ["{home}/.lingma/mcp.json"], tier="unverified"),
191
+ # Hosts with no verified config path are still surfaced as named gaps.
192
+ _host("windsurf-cascade", "Windsurf Cascade", [], tier="unverified",
193
+ note="config path not verified; use config_paths to add it explicitly"),
194
+ _host("cursor-cli", "Cursor CLI", [], tier="unverified",
195
+ note="config path not verified; Cursor desktop config is covered by the cursor host"),
196
+ ]
197
+
198
+
199
+ def _builtin_skill_hosts():
200
+ """Skill directories aligned with the installer host map."""
201
+ return [
202
+ _host("codex", "Codex", ["{codex_home}/skills", "{home}/.codex/skills"]),
203
+ _host("claude", "Claude Code", ["{claude_config_dir}/skills", "{home}/.claude/skills"]),
204
+ _host("cursor", "Cursor", ["{home}/.cursor/skills", "{home}/.agents/skills"]),
205
+ _host("opencode", "OpenCode", ["{xdg_config_home}/opencode/skills", "{home}/.config/opencode/skills"]),
206
+ _host("gemini", "Gemini CLI", ["{home}/.gemini/skills", "{home}/.agents/skills"]),
207
+ _host("goose", "Goose", ["{home}/.config/goose/skills", "{home}/.agents/skills"]),
208
+ _host("amp", "Amp", ["{home}/.config/agents/skills", "{home}/.agents/skills"]),
209
+ _host("windsurf", "Windsurf", ["{home}/.codeium/windsurf/skills"]),
210
+ _host("workbuddy", "WorkBuddy", ["{home}/.workbuddy/skills"]),
211
+ _host("kiro", "Kiro", ["{home}/.kiro/skills"]),
212
+ _host("trae", "Trae Code CLI", ["{home}/.traecli/skills"]),
213
+ _host("trae-cn", "Trae IDE", ["{home}/.trae-cn/skills"]),
214
+ _host("qwen", "Qwen Code", ["{home}/.qwen/skills"]),
215
+ _host("comate", "Comate", ["{home}/.comate/skills"]),
216
+ _host("codebuddy", "CodeBuddy Code", ["{home}/.codebuddy/skills"]),
217
+ _host("kimi", "Kimi Code CLI", ["{home}/.kimi/skills"]),
218
+ _host("agents", "AGENTS.md", ["{home}/.agents/skills"]),
219
+ ]
220
+
221
+
222
+ def _environment(environ, home):
223
+ env = dict(environ or {})
224
+ return {
225
+ "home": str(home),
226
+ "cwd": "",
227
+ "codex_home": env.get("CODEX_HOME") or str(Path(home) / ".codex"),
228
+ "xdg_config_home": env.get("XDG_CONFIG_HOME") or str(Path(home) / ".config"),
229
+ "claude_config_dir": env.get("CLAUDE_CONFIG_DIR") or str(Path(home) / ".claude"),
230
+ "environ": env,
231
+ }
232
+
233
+
234
+ def _expand_candidate(pattern, home, cwd, environ):
235
+ roots = [Path(home)]
236
+ for name in ("USERPROFILE", "HOME"):
237
+ value = environ.get(name) if environ else None
238
+ if not value:
239
+ continue
240
+ candidate = Path(value)
241
+ if _normalise_path(candidate) not in {_normalise_path(item) for item in roots}:
242
+ roots.append(candidate)
243
+
244
+ results = []
245
+ skipped = None
246
+ for root in roots:
247
+ values = _environment(environ, root)
248
+ values["cwd"] = str(cwd)
249
+
250
+ def replace(match, values=values):
251
+ token = match.group(1)
252
+ if token in values and token != "environ":
253
+ return values[token]
254
+ if token.startswith("env:"):
255
+ return values["environ"].get(token[4:], "")
256
+ return match.group(0)
257
+
258
+ expanded = re.sub(r"\{([^{}]+)\}", replace, pattern)
259
+ if re.search(r"\{[^{}]+\}", expanded):
260
+ skipped = "unresolved-placeholder:" + pattern
261
+ continue
262
+ if any(char in expanded for char in "*?["):
263
+ results.extend(Path(item) for item in glob.glob(expanded, recursive=True))
264
+ else:
265
+ results.append(Path(expanded))
266
+ if not results and skipped:
267
+ return [], skipped
268
+ return sorted(set(results), key=lambda item: _normalise_path(item)), None
269
+
270
+
271
+ def _normalise_path(path):
272
+ try:
273
+ return str(Path(path).resolve())
274
+ except OSError:
275
+ return str(path)
276
+
277
+
278
+ def _excluded(path, patterns):
279
+ text = str(path).replace("\\", "/")
280
+ return any(fnmatch.fnmatch(text, pattern.replace("\\", "/")) for pattern in patterns)
281
+
282
+
283
+ def _strip_jsonc_comments(text):
284
+ out = []
285
+ index = 0
286
+ in_string = False
287
+ quote = ""
288
+ escaped = False
289
+ while index < len(text):
290
+ char = text[index]
291
+ if in_string:
292
+ out.append(char)
293
+ if escaped:
294
+ escaped = False
295
+ elif char == "\\":
296
+ escaped = True
297
+ elif char == quote:
298
+ in_string = False
299
+ index += 1
300
+ continue
301
+ if char in ('"', "'"):
302
+ in_string = True
303
+ quote = char
304
+ out.append(char)
305
+ index += 1
306
+ continue
307
+ if char == "/" and index + 1 < len(text) and text[index + 1] == "/":
308
+ index += 2
309
+ while index < len(text) and text[index] not in "\r\n":
310
+ index += 1
311
+ continue
312
+ if char == "/" and index + 1 < len(text) and text[index + 1] == "*":
313
+ index += 2
314
+ while index + 1 < len(text) and not (text[index] == "*" and text[index + 1] == "/"):
315
+ index += 1
316
+ index += 2
317
+ continue
318
+ out.append(char)
319
+ index += 1
320
+ return "".join(out)
321
+
322
+
323
+ def _strip_jsonc_trailing_commas(text):
324
+ out = []
325
+ index = 0
326
+ in_string = False
327
+ quote = ""
328
+ escaped = False
329
+ while index < len(text):
330
+ char = text[index]
331
+ if in_string:
332
+ out.append(char)
333
+ if escaped:
334
+ escaped = False
335
+ elif char == "\\":
336
+ escaped = True
337
+ elif char == quote:
338
+ in_string = False
339
+ index += 1
340
+ continue
341
+ if char in ('"', "'"):
342
+ in_string = True
343
+ quote = char
344
+ out.append(char)
345
+ index += 1
346
+ continue
347
+ if char == ",":
348
+ lookahead = index + 1
349
+ while lookahead < len(text) and text[lookahead].isspace():
350
+ lookahead += 1
351
+ if lookahead < len(text) and text[lookahead] in "}]":
352
+ index += 1
353
+ continue
354
+ out.append(char)
355
+ index += 1
356
+ return "".join(out)
357
+
358
+
359
+ def _strip_jsonc(text):
360
+ return _strip_jsonc_trailing_commas(_strip_jsonc_comments(text))
361
+
362
+
363
+ def _collect_json_servers(payload, keys):
364
+ if not isinstance(payload, dict):
365
+ return [], "", "top-level-json-value-is-not-an-object"
366
+ names = set()
367
+ used = []
368
+ for key in keys:
369
+ if key not in payload:
370
+ continue
371
+ value = payload.get(key)
372
+ if not isinstance(value, dict):
373
+ return [], key, "mcp-key-is-not-an-object"
374
+ used.append(key)
375
+ if key == "mcp" and isinstance(value.get("servers"), dict):
376
+ names.update(str(name) for name in value["servers"].keys())
377
+ else:
378
+ names.update(str(name) for name in value.keys())
379
+ return sorted(names), ",".join(used), None
380
+
381
+
382
+ def _parse_toml_servers(text):
383
+ names = set()
384
+ section = None
385
+ inline_value = False
386
+ saw_root = False
387
+ header_re = re.compile(
388
+ r"^\s*\[\s*mcp_servers\s*\.\s*(?:\"([^\"]+)\"|'([^']+)'|([A-Za-z0-9_.-]+))\s*\]\s*(?:#.*)?$"
389
+ )
390
+ root_re = re.compile(r"^\s*\[\s*mcp_servers\s*\]\s*(?:#.*)?$")
391
+ for line in text.splitlines():
392
+ match = header_re.match(line)
393
+ if match:
394
+ names.add(next(group for group in match.groups() if group is not None))
395
+ section = "child"
396
+ continue
397
+ if root_re.match(line):
398
+ saw_root = True
399
+ section = "root"
400
+ continue
401
+ if section == "root" and re.match(r"^\s*[A-Za-z0-9_.-]+\s*=", line):
402
+ inline_value = True
403
+ if inline_value:
404
+ return [], "mcp_servers", "toml-inline-mcp-servers-not-supported"
405
+ if names:
406
+ return sorted(names), "mcp_servers", None
407
+ if saw_root:
408
+ return [], "mcp_servers", None
409
+ return [], "", None
410
+
411
+
412
+ def _parse_config(path, fmt, keys, max_bytes):
413
+ try:
414
+ size = path.stat().st_size
415
+ except OSError as exc:
416
+ return {"ok": False, "kind": "error", "reason": "%s: %s" % (path, exc)}
417
+ if size > max_bytes:
418
+ return {
419
+ "ok": False,
420
+ "kind": "unsupported",
421
+ "reason": "%s: file exceeds max_config_bytes=%d" % (path, max_bytes),
422
+ }
423
+ try:
424
+ text = _read_text(path)
425
+ except (OSError, ValueError) as exc:
426
+ return {"ok": False, "kind": "error", "reason": "%s: %s" % (path, exc)}
427
+ suffix = path.suffix.lower()
428
+ if fmt in ("yaml", "yml") or suffix in (".yaml", ".yml"):
429
+ return {"ok": False, "kind": "unsupported", "reason": "%s: yaml-not-supported" % path}
430
+ if fmt == "toml" or suffix == ".toml":
431
+ servers, key, reason = _parse_toml_servers(text)
432
+ if reason:
433
+ return {"ok": False, "kind": "unsupported", "reason": "%s: %s" % (path, reason)}
434
+ return {"ok": True, "format": "toml", "key": key, "servers": servers}
435
+ if fmt == "jsonc" or suffix == ".jsonc":
436
+ text = _strip_jsonc(text)
437
+ parse_format = "jsonc"
438
+ else:
439
+ parse_format = "json"
440
+ try:
441
+ payload = json.loads(text)
442
+ except json.JSONDecodeError as exc:
443
+ return {
444
+ "ok": False,
445
+ "kind": "error",
446
+ "reason": "%s: json parse error at line %d column %d: %s" % (
447
+ path, exc.lineno, exc.colno, exc.msg),
448
+ }
449
+ servers, key, reason = _collect_json_servers(payload, keys)
450
+ if reason:
451
+ return {"ok": False, "kind": "unsupported", "reason": "%s: %s" % (path, reason)}
452
+ return {"ok": True, "format": parse_format, "key": key, "servers": servers}
453
+
454
+
455
+ def _read_json_array_or_pathsep(value):
456
+ if not value:
457
+ return []
458
+ text = str(value).strip()
459
+ if text.startswith("["):
460
+ try:
461
+ payload = json.loads(text)
462
+ return [str(item) for item in payload] if isinstance(payload, list) else []
463
+ except json.JSONDecodeError:
464
+ return []
465
+ return [item for item in text.split(os.pathsep) if item]
466
+
467
+
468
+ def _custom_hosts(environ, home, cwd):
469
+ hosts = []
470
+ registry_path = environ.get("YOTTA_DEV_MCP_CONFIG_REGISTRY")
471
+ if registry_path:
472
+ path = Path(registry_path)
473
+ try:
474
+ payload = json.loads(path.read_text(encoding="utf-8"))
475
+ except (OSError, ValueError):
476
+ payload = None
477
+ if isinstance(payload, dict) and isinstance(payload.get("hosts"), list):
478
+ for index, item in enumerate(payload["hosts"]):
479
+ if not isinstance(item, dict) or not item.get("id"):
480
+ continue
481
+ hosts.append(_host(
482
+ str(item["id"]),
483
+ str(item.get("label") or item["id"]),
484
+ [str(value) for value in item.get("candidates", [])],
485
+ formats=item.get("formats") or ("json",),
486
+ server_keys=item.get("server_keys") or DEFAULT_JSON_KEYS,
487
+ tier="custom",
488
+ note=str(item.get("note") or "custom-registry"),
489
+ ))
490
+ extra = _read_json_array_or_pathsep(environ.get("YOTTA_DEV_MCP_CONFIG_PATHS"))
491
+ if extra:
492
+ hosts.append(_host("custom-paths", "Custom config paths", extra, tier="custom",
493
+ note="from YOTTA_DEV_MCP_CONFIG_PATHS"))
494
+ return hosts
495
+
496
+
497
+ def _custom_skill_hosts(environ):
498
+ extra = _read_json_array_or_pathsep(environ.get("YOTTA_DEV_MCP_SKILL_DIRS"))
499
+ if not extra:
500
+ return []
501
+ return [_host("custom-skills", "Custom skill paths", extra, tier="custom",
502
+ note="from YOTTA_DEV_MCP_SKILL_DIRS")]
503
+
504
+
505
+ def default_config_paths(home=None, cwd=None, environ=None):
506
+ home = Path(home) if home is not None else Path.home()
507
+ cwd = Path(cwd) if cwd is not None else Path.cwd()
508
+ environ = dict(environ if environ is not None else os.environ)
509
+ paths = []
510
+ for host in _builtin_hosts():
511
+ for pattern in host["candidates"]:
512
+ expanded, _ = _expand_candidate(pattern, home, cwd, environ)
513
+ paths.extend(expanded)
514
+ return sorted(set(paths), key=lambda item: str(item))
515
+
516
+
517
+ def default_skill_dirs(home=None, cwd=None, environ=None):
518
+ home = Path(home) if home is not None else Path.home()
519
+ cwd = Path(cwd) if cwd is not None else Path.cwd()
520
+ environ = dict(environ if environ is not None else os.environ)
521
+ paths = []
522
+ for host in _builtin_skill_hosts():
523
+ for pattern in host["candidates"]:
524
+ expanded, _ = _expand_candidate(pattern, home, cwd, environ)
525
+ paths.extend(expanded)
526
+ return sorted(set(paths), key=lambda item: str(item))
527
+
528
+
529
+ def _scan_skills(hosts, home, cwd, environ, max_skill_dirs):
530
+ skills = []
531
+ coverage = []
532
+ seen = {}
533
+ for host in hosts:
534
+ found = []
535
+ candidate_paths = []
536
+ skipped = []
537
+ for pattern in host["candidates"]:
538
+ expanded, reason = _expand_candidate(pattern, home, cwd, environ)
539
+ if reason:
540
+ skipped.append(reason)
541
+ continue
542
+ for path in expanded:
543
+ candidate_paths.append(_normalise_path(path))
544
+ if path.is_dir():
545
+ found.append(path)
546
+ for root in found[:max_skill_dirs]:
547
+ try:
548
+ children = sorted(root.iterdir(), key=lambda item: item.name)
549
+ except OSError:
550
+ continue
551
+ for child in children:
552
+ skill_file = child / "SKILL.md"
553
+ if not child.is_dir() or not skill_file.is_file():
554
+ continue
555
+ try:
556
+ text = _read_text(skill_file)
557
+ except (OSError, ValueError):
558
+ continue
559
+ key = _normalise_path(skill_file)
560
+ entry = seen.get(key)
561
+ if entry is None:
562
+ entry = {
563
+ "name": _frontmatter_name(text) or child.name,
564
+ "version": _frontmatter_version(text),
565
+ "path": key,
566
+ "hosts": [host["id"]],
567
+ }
568
+ seen[key] = entry
569
+ skills.append(entry)
570
+ elif host["id"] not in entry["hosts"]:
571
+ entry["hosts"].append(host["id"])
572
+ if found:
573
+ status = "checked"
574
+ elif skipped:
575
+ status = "missing"
576
+ elif host["tier"] == "unverified":
577
+ status = "unverified"
578
+ else:
579
+ status = "missing"
580
+ coverage.append({
581
+ "host": host["id"],
582
+ "label": host["label"],
583
+ "tier": host["tier"],
584
+ "status": status,
585
+ "candidate_paths": sorted(set(candidate_paths)),
586
+ "checked_paths": sorted(set(_normalise_path(path) for path in found)),
587
+ "count": sum(1 for item in seen.values() if host["id"] in item["hosts"]),
588
+ "reason": host.get("note", ""),
589
+ })
590
+ return skills, coverage
591
+
592
+
593
+ def mcp_doctor(skills_dirs=None, config_paths=None, include_defaults=None,
594
+ home=None, cwd=None, environ=None, max_configs=DEFAULT_MAX_CONFIGS,
595
+ max_config_bytes=DEFAULT_MAX_CONFIG_BYTES, include_unverified=True):
596
+ home = Path(home) if home is not None else Path.home()
597
+ cwd = Path(cwd) if cwd is not None else Path.cwd()
598
+ environ = dict(environ if environ is not None else os.environ)
599
+
600
+ config_scope = "default"
601
+ skills_scope = "default"
602
+ default_coverage_skipped = False
603
+
604
+ if skills_dirs:
605
+ skill_hosts = [_host("explicit", "Explicit skill dirs", [str(item) for item in skills_dirs],
606
+ tier="custom", note="from skills_dirs")]
607
+ skills_scope = "explicit"
608
+ else:
609
+ skill_hosts = _builtin_skill_hosts() + _custom_skill_hosts(environ)
610
+
611
+ if config_paths and include_defaults is not True:
612
+ hosts = [_host("explicit", "Explicit config paths", [str(item) for item in config_paths],
613
+ formats=("json", "jsonc", "toml", "yaml"),
614
+ server_keys=DEFAULT_JSON_KEYS + DEFAULT_TOML_KEYS,
615
+ tier="custom", note="from config_paths")]
616
+ config_scope = "explicit"
617
+ default_coverage_skipped = True
618
+ else:
619
+ hosts = _builtin_hosts() + _custom_hosts(environ, home, cwd)
620
+ if config_paths:
621
+ hosts.append(_host("explicit", "Explicit config paths",
622
+ [str(item) for item in config_paths],
623
+ formats=("json", "jsonc", "toml", "yaml"),
624
+ server_keys=DEFAULT_JSON_KEYS + DEFAULT_TOML_KEYS,
625
+ tier="custom", note="from config_paths"))
626
+ config_scope = "default+explicit"
627
+
628
+ if not include_unverified:
629
+ hosts = [host for host in hosts if host["tier"] != "unverified"]
630
+
631
+ configs = []
632
+ coverage = []
633
+ issues = []
634
+ coverage_gaps = []
635
+ seen_paths = {}
636
+ for host in hosts:
637
+ checked = []
638
+ unsupported = []
639
+ errors = []
640
+ candidate_paths = []
641
+ skipped = []
642
+ seen_host_paths = set()
643
+ for pattern in host["candidates"]:
644
+ expanded, reason = _expand_candidate(pattern, home, cwd, environ)
645
+ if reason:
646
+ skipped.append(reason)
647
+ continue
648
+ for path in expanded:
649
+ if _excluded(path, host.get("exclude", ())):
650
+ continue
651
+ candidate_paths.append(_normalise_path(path))
652
+ if not path.is_file():
653
+ continue
654
+ normalised = _normalise_path(path)
655
+ if normalised in seen_host_paths:
656
+ continue
657
+ seen_host_paths.add(normalised)
658
+ if len(checked) + len(unsupported) + len(errors) >= max_configs:
659
+ break
660
+ formats = host["formats"] or ("json",)
661
+ selected = formats[0] if len(formats) == 1 else None
662
+ parsed = _parse_config(path, selected, host["server_keys"], max_config_bytes)
663
+ if parsed.get("ok"):
664
+ item = {
665
+ "host": host["id"],
666
+ "path": _normalise_path(path),
667
+ "format": parsed["format"],
668
+ "key": parsed.get("key", ""),
669
+ "servers": parsed.get("servers", []),
670
+ }
671
+ key = item["path"]
672
+ existing = seen_paths.get(key)
673
+ if existing is None:
674
+ seen_paths[key] = item
675
+ configs.append(item)
676
+ elif host["id"] not in existing.get("also_hosts", []):
677
+ existing.setdefault("also_hosts", []).append(host["id"])
678
+ checked.append(item)
679
+ elif parsed.get("kind") == "unsupported":
680
+ unsupported.append(parsed["reason"])
681
+ issues.append(parsed["reason"])
682
+ else:
683
+ errors.append(parsed["reason"])
684
+ issues.append(parsed["reason"])
685
+ if checked and not unsupported and not errors:
686
+ status = "checked"
687
+ elif checked:
688
+ status = "checked-with-gaps"
689
+ elif unsupported:
690
+ status = "unsupported"
691
+ elif errors:
692
+ status = "error"
693
+ elif skipped:
694
+ status = "missing"
695
+ elif host["tier"] == "unverified":
696
+ status = "unverified"
697
+ else:
698
+ status = "missing"
699
+ coverage.append({
700
+ "host": host["id"],
701
+ "label": host["label"],
702
+ "tier": host["tier"],
703
+ "status": status,
704
+ "candidate_paths": sorted(set(candidate_paths)),
705
+ "checked_paths": sorted(set(item["path"] for item in checked)),
706
+ "unsupported_paths": sorted(set(item.split(":", 1)[0] for item in unsupported)),
707
+ "error_paths": sorted(set(item.split(":", 1)[0] for item in errors)),
708
+ "configs": checked,
709
+ "servers": sorted(set(name for item in checked for name in item["servers"])),
710
+ "reason": unsupported[0] if unsupported else (errors[0] if errors else host.get("note", "")),
711
+ })
712
+ if status == "unverified":
713
+ coverage_gaps.append("%s: config path/format not verified" % host["id"])
714
+ if unsupported or errors:
715
+ coverage_gaps.append("%s: unsupported or unreadable config file(s)" % host["id"])
716
+ if skipped and host["tier"] in ("unverified", "custom"):
717
+ coverage_gaps.append("%s: skipped candidates (%s)" % (host["id"], ", ".join(skipped)))
718
+
719
+ skills, skills_coverage = _scan_skills(skill_hosts, home, cwd, environ, DEFAULT_MAX_SKILL_DIRS)
720
+ for entry in skills_coverage:
721
+ if entry["status"] == "unverified":
722
+ coverage_gaps.append("skills/%s: skill directory not verified" % entry["host"])
723
+
724
+ configs.sort(key=lambda item: item["path"])
725
+ coverage.sort(key=lambda item: item["host"])
726
+ skills.sort(key=lambda item: (item["name"], item["path"]))
727
+ skills_coverage.sort(key=lambda item: item["host"])
728
+ issues = sorted(set(issues))
729
+ coverage_gaps = sorted(set(coverage_gaps))
730
+
731
+ counts = {
732
+ "checked_hosts": sum(1 for item in coverage if item["status"].startswith("checked")),
733
+ "missing_hosts": sum(1 for item in coverage if item["status"] == "missing"),
734
+ "unsupported_hosts": sum(1 for item in coverage if item["status"] == "unsupported"),
735
+ "error_hosts": sum(1 for item in coverage if item["status"] == "error"),
736
+ "unverified_hosts": sum(1 for item in coverage if item["status"] == "unverified"),
737
+ }
738
+ unknown_hosts = [item["host"] for item in coverage if item["status"] == "unverified"]
739
+ checked_scope_clear = not issues and not counts["unsupported_hosts"] and not counts["error_hosts"]
740
+ full_coverage = not unknown_hosts and config_scope == "default" and not coverage_gaps
741
+ all_clear = bool(full_coverage and checked_scope_clear)
742
+ summary = dict(counts)
743
+ summary.update({
744
+ "hosts_total": len(coverage),
745
+ "configs_found": len(configs),
746
+ "servers_total": len(set(name for item in configs for name in item["servers"])),
747
+ "all_clear": all_clear,
748
+ "checked_scope_clear": checked_scope_clear,
749
+ "full_coverage": full_coverage,
750
+ "coverage_confidence": "full" if full_coverage else "partial",
751
+ "issues": len(issues),
752
+ })
753
+ return {
754
+ "skills": skills,
755
+ "mcp_configs": configs,
756
+ "coverage": coverage,
757
+ "skills_coverage": skills_coverage,
758
+ "unknown_hosts": sorted(unknown_hosts),
759
+ "coverage_gaps": coverage_gaps,
760
+ "issues": issues,
761
+ "summary": summary,
762
+ "scope": config_scope,
763
+ "skills_scope": skills_scope,
764
+ "default_coverage_skipped": default_coverage_skipped,
765
+ "checked_skills": len(skills),
766
+ "checked_configs": len(configs),
767
+ "checked_hosts": counts["checked_hosts"],
768
+ }