@yottameta/yotta-dev-mcp-plugin 0.2.0 → 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.
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Developer Tools",
15
- "version": "0.2.0",
15
+ "version": "0.2.1",
16
16
  "description": "YuanKai (yotta-dev-mcp) - deterministic local development tools exposed over stdio MCP: repo_map, system_model, architecture_review, impact_analysis, verify_change, self_test, run_adapter, find_code, compress_output, review_code, review_diff, mcp_doctor, scan_secrets, scan_dependencies, check_publish_readiness, run_checks, scaffold_skill and workflow_state. Reads an optional .yotta/architecture.json contract plus .yotta/verification.json policy for architecture reviews, change impact cones and the L0-L5 evidence ledger. Optional explicit adapters integrate import-linter, dependency-cruiser and Repomix without auto-install or network access. Python 3.8+ standard library, offline by default; writes and command execution require explicit flags.",
17
17
  "interface": {
18
18
  "displayName": "元开 yotta-dev-mcp",
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Developer Tools",
15
- "version": "0.2.0",
15
+ "version": "0.2.1",
16
16
  "description": "YuanKai (yotta-dev-mcp) - deterministic local development tools exposed over stdio MCP: repo_map, system_model, architecture_review, impact_analysis, verify_change, self_test, run_adapter, find_code, compress_output, review_code, review_diff, mcp_doctor, scan_secrets, scan_dependencies, check_publish_readiness, run_checks, scaffold_skill and workflow_state. Reads an optional .yotta/architecture.json contract plus .yotta/verification.json policy for architecture reviews, change impact cones and the L0-L5 evidence ledger. Optional explicit adapters integrate import-linter, dependency-cruiser and Repomix without auto-install or network access. Python 3.8+ standard library, offline by default; writes and command execution require explicit flags.",
17
17
  "interface": {
18
18
  "displayName": "元开 yotta-dev-mcp",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-dev-mcp-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "YuanKai (yotta-dev-mcp) Agent Plugin: deterministic local development tools over stdio MCP.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "yotta-dev-mcp",
4
- "version": "0.2.0",
4
+ "version": "0.2.1",
5
5
  "description": "YuanKai (yotta-dev-mcp) - deterministic local development tools exposed over stdio MCP: repo_map, system_model, architecture_review, impact_analysis, verify_change, self_test, run_adapter, find_code, compress_output, review_code, review_diff, mcp_doctor, scan_secrets, scan_dependencies, check_publish_readiness, run_checks, scaffold_skill and workflow_state. Reads an optional .yotta/architecture.json contract plus .yotta/verification.json policy for architecture reviews, change impact cones and the L0-L5 evidence ledger. Optional explicit adapters integrate import-linter, dependency-cruiser and Repomix without auto-install or network access. Python 3.8+ standard library, offline by default; writes and command execution require explicit flags.",
6
6
  "author": {
7
7
  "name": "YottaMeta",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: yotta-dev-mcp
3
3
  description: 元开(yotta-dev-mcp)—— 本地、确定性的开发工具 MCP,把只读默认的开发能力暴露为 stdio MCP server:repo_map / system_model / architecture_review / impact_analysis / verify_change / self_test / run_adapter / find_code / compress_output / review_code / review_diff / mcp_doctor / scan_secrets / scan_dependencies / check_publish_readiness / run_checks / scaffold_skill / workflow_state。触发:让 AI 在陌生项目里先做结构盘点、构建系统模型或架构契约(.yotta/architecture.json)、按契约评审架构、分析改动影响锥与回归面、按验证阶梯产出证据账本、对元开自身做完整性 / 反证自测、探测或显式运行 import-linter / dependency-cruiser / Repomix 可选适配器、定位代码、评审改动、扫描密钥/依赖、检查发布就绪、运行白名单检查、生成脚手架或读取 .workflow 状态时;或用户说 元开 / 开发能力 MCP / yotta-dev-mcp / 代码库地图 / 系统模型 / 架构契约 / 架构评审 / 影响分析 / 验证账本 / 反证自测 / 代码评审 MCP / 适配器 等。边界:Python 3.8+ 标准库、离线默认;除 run_checks(显式 allow_execute)、verify_change 的 L2-L4 策略检查(显式 allow_execute)与 self_test 的测试子集(显式 allow_execute)、run_adapter 的 action=run(显式 allow_execute)、scaffold_skill / workflow_state 的显式 apply 外均为只读;不上传源码、不自动修改、不提交、不联网查询包是否存在、不自动安装或下载适配器。
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  license: MIT
6
6
  ---
7
7
 
@@ -45,7 +45,7 @@ python scripts/yotta_dev_mcp.py
45
45
 
46
46
  | 工具 | 用途 | 写入 |
47
47
  |---|---|---|
48
- | `repo_map` | 模块、导入、入口点地图 | 否 |
48
+ | `repo_map` | 模块、导入、入口点地图;支持 `from . import X` / 别名 / 包,忽略临时与探针目录 | 否 |
49
49
  | `system_model` | 系统模型:模块、依赖、入口、测试映射、配置与数据归属;附带契约分层,输出 PASS / FAIL / UNKNOWN | 否 |
50
50
  | `architecture_review` | 按契约评审依赖规则、边界可见性与数据归属;critical / high 判 FAIL,medium / low 只告警 | 否 |
51
51
  | `impact_analysis` | 变更影响锥:直接消费者、受影响层 / 边界 / 存储 / 不变量、映射测试、风险分级与回滚探针 | 否 |
@@ -54,10 +54,10 @@ python scripts/yotta_dev_mcp.py
54
54
  | `run_adapter` | 探测或显式运行 import-linter / dependency-cruiser / Repomix;固定 argv、项目内 cwd、有界输出与哈希 | 仅 action=run + 显式 allow_execute |
55
55
  | `find_code` | 符号 / 文本定位,结果有上限 | 否 |
56
56
  | `compress_output` | 保留错误与首尾的长输出压缩 | 否 |
57
- | `review_code` | 规则化代码评审,带行号与建议 | 否 |
57
+ | `review_code` | 规则化代码评审,带行号与建议;忽略 `.workflow` / `scratch` / `_probe` 等临时目录 | 否 |
58
58
  | `review_diff` | 只评审 diff 的新增行 | 否 |
59
- | `mcp_doctor` | 技能版本与 MCP JSON 配置体检 | 否 |
60
- | `scan_secrets` | 密钥 / 凭据 / 高熵令牌扫描(强制脱敏) | 否 |
59
+ | `mcp_doctor` | 技能版本与多宿主 MCP 配置体检;返回 coverage,只列 server 名,不含 command / env | 否 |
60
+ | `scan_secrets` | 密钥 / 凭据 / 高熵令牌扫描(强制脱敏;路径 / 哈希 / 文件名噪声过滤) | 否 |
61
61
  | `scan_dependencies` | 依赖清单、lockfile、来源与 typosquat 启发式检查 | 否 |
62
62
  | `check_publish_readiness` | 版本四件、发布文件、仓库与 publishConfig 检查 | 否 |
63
63
  | `run_checks` | 白名单测试 / lint / compile 并返回结构化摘要 | 仅显式 allow_execute |
@@ -77,5 +77,6 @@ python scripts/yotta_dev_mcp.py
77
77
 
78
78
  ## 当前版本
79
79
 
80
+ - v0.2.1(2026-09-25):缺陷修复批次——`mcp_doctor` 改为多宿主 / 多根 / JSON+JSONC+TOML 子集发现并返回 coverage;`self_test(mode="installed")` 按精简分发副本 / npm / plugin / r0 形态判断 banner;`repo_map` 修复 `from . import X` 解析;`scan_secrets` 过滤路径 / 哈希 / 文件名噪声;`review_code` / `repo_map` 忽略 `.workflow` / `scratch` / `_probe` 等临时目录。
80
81
  - v0.2.0(2026-09-25):新增 `system_model`、`architecture_review`、`impact_analysis`、`verify_change`、`self_test`、`run_adapter`,以及 `.yotta/architecture.json` 与可选 `.yotta/verification.json` 契约(版本 1);工具总数 18,原 12 个工具行为不变。
81
82
  - v0.1.1:品牌显示名统一为「元开」;功能与 12 个工具不变。
@@ -14,6 +14,12 @@ Input:
14
14
 
15
15
  Output: `root`, `modules`, `imports`, `entrypoints`, `truncated`.
16
16
 
17
+ Python imports include absolute, `from .X import Y`, `from . import X`,
18
+ `as` aliases, packages and multi-name forms. Temporary / agent-state
19
+ directories (`.workflow`, `.codex`, `.cursor`, `.claude`, `.agents`,
20
+ `scratch`, `_probe`, `probe`, `sandbox`, `debug`, `.tmp`) and probe/temp
21
+ file names are ignored by default.
22
+
17
23
  ## system_model
18
24
 
19
25
  Input:
@@ -201,14 +207,36 @@ Input:
201
207
 
202
208
  Only added lines are reviewed. Output: `files`, `findings`, `truncated`.
203
209
 
210
+ `review_code` uses the same default ignore set as `repo_map`: agent state
211
+ directories, scratch / probe / sandbox / debug directories and probe/temp
212
+ file names do not flood the result. `review_diff` only reviews added lines,
213
+ so it is unaffected by directory traversal.
214
+
204
215
  ## mcp_doctor
205
216
 
206
217
  Input:
207
218
 
208
219
  - `skills_dirs` (optional array).
209
220
  - `config_paths` (optional array).
210
-
211
- Output: `skills`, `mcp_configs`, `issues`, `checked_skills`, `checked_configs`.
221
+ - `include_defaults` (optional boolean): when `config_paths` is supplied, also
222
+ scan the built-in host registry instead of explicit-only scope.
223
+
224
+ Output: `skills`, `mcp_configs`, `coverage`, `skills_coverage`, `issues`,
225
+ `coverage_gaps`, `unknown_hosts`, `summary`, `scope`, `checked_skills`,
226
+ `checked_configs`, `checked_hosts`.
227
+
228
+ Discovery is tiered and environment-aware: verified hosts include Codex
229
+ (`$CODEX_HOME/config.toml`, JSON fallbacks), Cursor, WorkBuddy
230
+ (`~/.workbuddy/mcp.json` plus `connectors/*/mcp.json`), OpenCode
231
+ (`$XDG_CONFIG_HOME/opencode/opencode.jsonc|json`), Claude Code, Windsurf,
232
+ Continue, Gemini, Qwen, Trae, Comate, CodeBuddy, Kimi, Kiro, VS Code and Zed;
233
+ additional hosts are best-effort candidates and are reported as
234
+ `unverified` when absent. JSON, JSONC and a narrow TOML `[mcp_servers.*]`
235
+ subset are parsed; YAML is reported as `unsupported`, never silently skipped.
236
+ Only server names are returned; commands, args and env values are never
237
+ included. `summary.all_clear` is true only for a fully covered, issue-free
238
+ default scan; always read `coverage_confidence` before treating it as
239
+ all-clear.
212
240
 
213
241
  ## scan_secrets
214
242
 
@@ -218,6 +246,11 @@ Input: `path` or `text`, optional `max_findings`, optional `include_git_history`
218
246
  Output: `findings` with `path`, `line`, `rule`, `severity`, redacted `evidence`,
219
247
  `suggestion`; `truncated`.
220
248
 
249
+ High-entropy findings apply a narrow noise filter for absolute paths,
250
+ URL / `file://` percent-encoded paths, common binary/source/document
251
+ suffixes and hash-context hex values (SHA-1/256/512, MD5, checksum, digest,
252
+ integrity). Credential-name, AWS-key and private-key rules are not relaxed.
253
+
221
254
  ## scan_dependencies
222
255
 
223
256
  Input: `path`.
@@ -10,7 +10,14 @@ from pathlib import Path
10
10
  IGNORE_DIRS = {
11
11
  ".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv",
12
12
  "dist", "build", ".next", ".nuxt", ".cache", ".tmp", ".tmp2",
13
+ ".workflow", ".codex", ".cursor", ".claude", ".agents",
14
+ "scratch", "_scratch", "_probe", "probe", "probes",
15
+ "sandbox", ".sandbox", "debug", "_debug",
13
16
  }
17
+ IGNORE_TEMP_FILE_RE = re.compile(
18
+ r"(?i)^(?:probe[_-].*|_probe[_-].*|scratch[_-].*|_scratch[_-].*|tmp_.*|temp_.*|.*_tmp)"
19
+ r"\.(?:py|js|jsx|mjs|cjs|ts|tsx|sh|ps1|go|rs|java|kt|kts|rb|php)$"
20
+ )
14
21
  SOURCE_EXTS = {
15
22
  ".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".sh", ".ps1",
16
23
  ".go", ".rs", ".java", ".kt", ".kts", ".rb", ".php",
@@ -54,7 +61,9 @@ VERIFY_REQUIRED_SOURCE_FILES = (
54
61
  "references/tools.md", "references/adapters.md",
55
62
  "references/architecture-contract.md", "assets/banner.png",
56
63
  )
57
- VERIFY_REQUIRED_INSTALLED_FILES = ("SKILL.md", "assets/banner.png")
64
+ VERIFY_REQUIRED_INSTALLED_FILES = ("SKILL.md",)
65
+ VERIFY_REQUIRED_INSTALLED_ASSETS = ("assets/banner.png",)
66
+ LEAN_INSTALL_MARKERS = ("_icon.png",)
58
67
  VERIFY_WRITE_GATES = (
59
68
  ("run_checks", "allow_execute"),
60
69
  ("scaffold_skill", "apply"),
@@ -94,13 +103,15 @@ def _read_text(path):
94
103
  return data.decode("utf-8", errors="replace")
95
104
 
96
105
 
97
- def _iter_files(root, extensions=None, max_files=5000, all_files=False):
106
+ def _iter_files(root, extensions=None, max_files=5000, all_files=False, ignore_temp=True):
98
107
  root = Path(root)
99
108
  extensions = set(extensions or TEXT_EXTS)
100
109
  count = 0
101
110
  for current, dirs, files in os.walk(str(root)):
102
111
  dirs[:] = sorted(d for d in dirs if d not in IGNORE_DIRS)
103
112
  for name in sorted(files):
113
+ if ignore_temp and IGNORE_TEMP_FILE_RE.match(name):
114
+ continue
104
115
  path = Path(current) / name
105
116
  if path.is_symlink():
106
117
  continue
@@ -26,12 +26,22 @@ from dev_common import (
26
26
  _frontmatter_name, _frontmatter_version, source_exts,
27
27
  )
28
28
  from dev_impact import impact_analysis as _impact_analysis_impl
29
+ from dev_mcp_doctor import (
30
+ default_config_paths as _default_config_paths_impl,
31
+ default_skill_dirs as _default_skill_dirs_impl,
32
+ mcp_doctor as _mcp_doctor_impl,
33
+ )
29
34
  from dev_verify import verify_change as _verify_change_impl
30
35
  from dev_selftest import self_test
31
- from dev_model import _repo_map_js, _repo_map_python, system_model as _system_model_impl
36
+ from dev_model import (
37
+ _classify_python_import,
38
+ _repo_map_js,
39
+ _repo_map_python,
40
+ system_model as _system_model_impl,
41
+ )
32
42
  from dev_rules import REVIEW_RULES
33
43
 
34
- VERSION = "0.2.0"
44
+ VERSION = "0.2.1"
35
45
 
36
46
 
37
47
 
@@ -54,6 +64,7 @@ def repo_map(path, max_files=2000):
54
64
  if len(files) > max_files:
55
65
  truncated = True
56
66
  files = files[:max_files]
67
+ module_set = {_rel(root, file_path) for file_path in files}
57
68
  for file_path in files:
58
69
  rel = _rel(root, file_path)
59
70
  try:
@@ -63,7 +74,11 @@ def repo_map(path, max_files=2000):
63
74
  language = _language(file_path)
64
75
  modules.append({"path": rel, "language": language, "lines": len(text.splitlines())})
65
76
  if file_path.suffix.lower() == ".py":
66
- imports.extend(_repo_map_python(file_path, rel))
77
+ for raw in _repo_map_python(file_path, rel):
78
+ _, target = _classify_python_import(
79
+ raw["target"], rel, module_set, relative=raw.get("relative", False)
80
+ )
81
+ imports.append({"source": rel, "target": target, "line": raw["line"]})
67
82
  elif file_path.suffix.lower() in (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"):
68
83
  imports.extend(_repo_map_js(file_path, rel))
69
84
  if (
@@ -274,81 +289,19 @@ def review_diff(diff_text=None, path=None, base=None, max_findings=200):
274
289
 
275
290
 
276
291
  def _default_skill_dirs():
277
- home = Path.home()
278
- candidates = [
279
- home / ".codex" / "skills",
280
- home / ".claude" / "skills",
281
- home / ".cursor" / "skills",
282
- home / ".config" / "opencode" / "skills",
283
- ]
284
- codex_home = os.environ.get("CODEX_HOME")
285
- if codex_home:
286
- candidates.insert(0, Path(codex_home) / "skills")
287
- claude_home = os.environ.get("CLAUDE_CONFIG_DIR")
288
- if claude_home:
289
- candidates.insert(0, Path(claude_home) / "skills")
290
- xdg_home = os.environ.get("XDG_CONFIG_HOME")
291
- if xdg_home:
292
- candidates.insert(0, Path(xdg_home) / "opencode" / "skills")
293
- return candidates
292
+ return _default_skill_dirs_impl()
294
293
 
295
294
 
296
295
  def _default_config_paths():
297
- home = Path.home()
298
- return [
299
- home / ".codex" / "config.json",
300
- home / ".codex" / "mcp.json",
301
- home / ".config" / "opencode" / "opencode.json",
302
- home / ".claude" / "settings.json",
303
- home / ".cursor" / "mcp.json",
304
- ]
305
-
306
-
307
- def mcp_doctor(skills_dirs=None, config_paths=None):
308
- skills = []
309
- issues = []
310
- for directory in (skills_dirs or _default_skill_dirs()):
311
- root = Path(directory)
312
- if not root.is_dir():
313
- continue
314
- for child in sorted(root.iterdir(), key=lambda item: item.name):
315
- skill_file = child / "SKILL.md"
316
- if not child.is_dir() or not skill_file.is_file():
317
- continue
318
- try:
319
- text = _read_text(skill_file)
320
- except (OSError, ValueError) as exc:
321
- issues.append("%s: %s" % (skill_file, exc))
322
- continue
323
- skills.append({
324
- "name": _frontmatter_name(text) or child.name,
325
- "version": _frontmatter_version(text),
326
- "path": str(skill_file),
327
- })
328
- mcp_configs = []
329
- for config_path in (config_paths or _default_config_paths()):
330
- path = Path(config_path)
331
- if not path.is_file():
332
- continue
333
- try:
334
- payload = json.loads(_read_text(path))
335
- except Exception as exc: # noqa: BLE001
336
- issues.append("%s: %s" % (path, exc))
337
- continue
338
- servers = payload.get("mcpServers") if isinstance(payload, dict) else None
339
- mcp_configs.append({
340
- "path": str(path),
341
- "servers": sorted(servers.keys()) if isinstance(servers, dict) else [],
342
- })
343
- skills.sort(key=lambda item: (item["name"], item["path"]))
344
- mcp_configs.sort(key=lambda item: item["path"])
345
- return {
346
- "skills": skills,
347
- "mcp_configs": mcp_configs,
348
- "issues": issues,
349
- "checked_skills": len(skills),
350
- "checked_configs": len(mcp_configs),
351
- }
296
+ return _default_config_paths_impl()
297
+
298
+
299
+ def mcp_doctor(skills_dirs=None, config_paths=None, include_defaults=None):
300
+ return _mcp_doctor_impl(
301
+ skills_dirs=skills_dirs,
302
+ config_paths=config_paths,
303
+ include_defaults=include_defaults,
304
+ )
352
305
 
353
306
 
354
307
  SECRET_KEY_RE = re.compile(
@@ -357,6 +310,35 @@ SECRET_KEY_RE = re.compile(
357
310
  AWS_KEY_RE = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
358
311
  PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
359
312
  HIGH_ENTROPY_RE = re.compile(r"[A-Za-z0-9_+/=\-]{32,}")
313
+ HASH_CONTEXT_RE = re.compile(r"(?i)(sha1|sha256|sha512|hash|checksum|digest|integrity)")
314
+ HEX_TOKEN_RE = re.compile(r"^[0-9a-fA-F]+$")
315
+ PATH_HINT_RE = re.compile(r"(?i)(?:[a-z]:[\\/]|\\\\|https?://|file://)")
316
+ URL_HINT_RE = re.compile(r"(?i)(?:file|https?)://")
317
+ PERCENT_ESCAPE_RE = re.compile(r"%[0-9A-Fa-f]{2}")
318
+ HASH_PREFIX_RE = re.compile(r"(?i)^(?:sha1|sha256|sha512|md5)[=:]([0-9a-fA-F]{32,128})$")
319
+ FILE_SUFFIX_RE = re.compile(
320
+ r"(?i)\.(exe|dll|sys|py|js|ts|tsx|json|md|txt|log|whl|tar|gz|zip|png|jpg|jpeg|svg)"
321
+ )
322
+
323
+
324
+ def _high_entropy_noise(line, token, start, end):
325
+ """Return True for common non-secret high-entropy noise."""
326
+ if HASH_PREFIX_RE.match(token):
327
+ return True
328
+ if PERCENT_ESCAPE_RE.search(token) and URL_HINT_RE.search(line):
329
+ return True
330
+ if start > 0 and line[start - 1] == "%":
331
+ return True
332
+ if HEX_TOKEN_RE.match(token) and len(token) in (32, 40, 64, 128) and HASH_CONTEXT_RE.search(line):
333
+ return True
334
+ window = line[max(0, start - 16):min(len(line), end + 16)]
335
+ if PATH_HINT_RE.search(window):
336
+ return True
337
+ if URL_HINT_RE.search(line) and PERCENT_ESCAPE_RE.search(window):
338
+ return True
339
+ if FILE_SUFFIX_RE.match(line[end:end + 8]):
340
+ return True
341
+ return False
360
342
 
361
343
 
362
344
  def _entropy(value):
@@ -385,7 +367,9 @@ def scan_secrets(path=None, text=None, max_findings=200, include_git_history=Fal
385
367
  root = Path(path)
386
368
  if not root.exists():
387
369
  raise ValueError("路径不存在: %s" % path)
388
- files = [root] if root.is_file() else list(_iter_files(root, all_files=True))
370
+ files = [root] if root.is_file() else list(
371
+ _iter_files(root, all_files=True, ignore_temp=False)
372
+ )
389
373
  base = root.parent if root.is_file() else root
390
374
  for file_path in files:
391
375
  try:
@@ -421,7 +405,11 @@ def scan_secrets(path=None, text=None, max_findings=200, include_git_history=Fal
421
405
  })
422
406
  for match in HIGH_ENTROPY_RE.finditer(line):
423
407
  token = match.group(0)
424
- if _entropy(token) >= 4.0 and not PRIVATE_KEY_RE.search(line):
408
+ if (
409
+ _entropy(token) >= 4.0
410
+ and not PRIVATE_KEY_RE.search(line)
411
+ and not _high_entropy_noise(line, token, match.start(), match.end())
412
+ ):
425
413
  findings.append({
426
414
  "path": rel, "line": line_no, "rule": "high-entropy-token",
427
415
  "severity": "medium", "evidence": _redact(token),
@@ -1083,6 +1071,8 @@ def main():
1083
1071
  doctor = sub.add_parser("mcp-doctor")
1084
1072
  doctor.add_argument("--skills-dir", action="append")
1085
1073
  doctor.add_argument("--config", action="append")
1074
+ doctor.add_argument("--include-defaults", action="store_true",
1075
+ help="scan built-in host registry in addition to --config paths")
1086
1076
  args = parser.parse_args()
1087
1077
  if args.command == "repo-map":
1088
1078
  result = repo_map(args.path)
@@ -1126,7 +1116,11 @@ def main():
1126
1116
  elif args.command == "review-code":
1127
1117
  result = review_code(args.path)
1128
1118
  elif args.command == "mcp-doctor":
1129
- result = mcp_doctor(skills_dirs=args.skills_dir, config_paths=args.config)
1119
+ result = mcp_doctor(
1120
+ skills_dirs=args.skills_dir,
1121
+ config_paths=args.config,
1122
+ include_defaults=args.include_defaults,
1123
+ )
1130
1124
  else:
1131
1125
  parser.print_help()
1132
1126
  return 2
@@ -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
+ }
@@ -30,9 +30,14 @@ def _resolve_python_import(source_rel, node):
30
30
  if node.level > 1:
31
31
  parts = parts[:-(node.level - 1)] if len(parts) >= node.level - 1 else []
32
32
  prefix = "/".join(parts)
33
+ if not node.module:
34
+ return [
35
+ (prefix + "/" + alias.name.replace(".", "/")).strip("/")
36
+ for alias in node.names
37
+ ]
33
38
  module = node.module or ""
34
39
  target = (prefix + "/" + module.replace(".", "/")).strip("/")
35
- return [target + ".py" if target else source_rel]
40
+ return [target] if target else [source_rel]
36
41
  if node.module:
37
42
  return [node.module]
38
43
  return []
@@ -47,7 +52,12 @@ def _repo_map_python(path, rel):
47
52
  for node in ast.walk(tree):
48
53
  if isinstance(node, (ast.Import, ast.ImportFrom)):
49
54
  for target in _resolve_python_import(rel, node):
50
- imports.append({"source": rel, "target": target, "line": getattr(node, "lineno", 1)})
55
+ imports.append({
56
+ "source": rel,
57
+ "target": target,
58
+ "line": getattr(node, "lineno", 1),
59
+ "relative": isinstance(node, ast.ImportFrom) and bool(node.level),
60
+ })
51
61
  return imports
52
62
 
53
63
  def _repo_map_js(path, rel):
@@ -77,7 +87,7 @@ def _resolve_relative_module(raw_target, source_rel, module_set, root=None):
77
87
  return base, "file"
78
88
  return None, None
79
89
 
80
- def _classify_python_import(raw_target, source_rel, module_set):
90
+ def _classify_python_import(raw_target, source_rel, module_set, relative=False):
81
91
  if raw_target.endswith(".py"):
82
92
  if raw_target in module_set:
83
93
  return "internal", raw_target
@@ -91,7 +101,7 @@ def _classify_python_import(raw_target, source_rel, module_set):
91
101
  for candidate in (base + ".py", base + "/__init__.py"):
92
102
  if candidate in module_set:
93
103
  return "internal", candidate
94
- return "external", raw_target
104
+ return ("unresolved" if relative else "external"), raw_target
95
105
 
96
106
  def _classify_js_import(raw_target, source_rel, module_set, root=None):
97
107
  if raw_target.startswith("."):
@@ -265,19 +275,21 @@ def system_model(path, max_files=2000, contract_file=None):
265
275
  suffix = file_path.suffix.lower()
266
276
  if suffix == ".py":
267
277
  raw_imports = _repo_map_python(file_path, rel)
268
- classifier = lambda raw: _classify_python_import(raw, rel, module_set) # noqa: E731
269
278
  elif suffix in JS_EXTS:
270
279
  raw_imports = _repo_map_js(file_path, rel)
271
- classifier = lambda raw: _classify_js_import(raw, rel, module_set, root) # noqa: E731
272
280
  else:
273
281
  raw_imports = []
274
- classifier = None
275
282
  module_imports = []
276
283
  for raw in raw_imports:
277
284
  target_raw = raw["target"]
278
285
  if target_raw == rel:
279
286
  continue
280
- kind, target = classifier(target_raw)
287
+ if suffix == ".py":
288
+ kind, target = _classify_python_import(
289
+ target_raw, rel, module_set, relative=raw.get("relative", False)
290
+ )
291
+ else:
292
+ kind, target = _classify_js_import(target_raw, rel, module_set, root)
281
293
  entry = {
282
294
  "source": rel,
283
295
  "target": target,
@@ -15,7 +15,8 @@ from pathlib import Path
15
15
 
16
16
  from dev_architecture import architecture_review
17
17
  from dev_common import (
18
- EVIDENCE_LIMIT, VERIFY_REQUIRED_INSTALLED_FILES,
18
+ EVIDENCE_LIMIT, LEAN_INSTALL_MARKERS, VERIFY_REQUIRED_INSTALLED_ASSETS,
19
+ VERIFY_REQUIRED_INSTALLED_FILES,
19
20
  VERIFY_REQUIRED_SOURCE_FILES, VERIFY_WRITE_GATES, _frontmatter_version,
20
21
  _read_text,
21
22
  )
@@ -44,6 +45,9 @@ def _function_default(source_text, function_name, parameter_name):
44
45
  def _string_constant(node):
45
46
  return isinstance(node, ast.Constant) and isinstance(node.value, str)
46
47
 
48
+ def _is_lean_install(root):
49
+ return any((root / marker).is_file() for marker in LEAN_INSTALL_MARKERS)
50
+
47
51
  def _protocol_tool_contracts(source_text):
48
52
  tree = ast.parse(source_text)
49
53
  function = None
@@ -380,8 +384,14 @@ def self_test(path, mode="auto", allow_execute=False, timeout=120):
380
384
  else:
381
385
  mode = "source"
382
386
 
383
- required = (VERIFY_REQUIRED_SOURCE_FILES if mode == "source"
384
- else VERIFY_REQUIRED_INSTALLED_FILES)
387
+ if mode == "source":
388
+ required = list(VERIFY_REQUIRED_SOURCE_FILES)
389
+ else:
390
+ required = list(VERIFY_REQUIRED_INSTALLED_FILES)
391
+ # Lean distribution strips assets/ by platform policy; only require
392
+ # the banner when the copy is not lean or it already ships assets/.
393
+ if not _is_lean_install(root) or (root / "assets").is_dir():
394
+ required.extend(VERIFY_REQUIRED_INSTALLED_ASSETS)
385
395
  missing = [rel for rel in required if not (root / rel).is_file()]
386
396
  files_check = {
387
397
  "id": "files",
@@ -386,14 +386,24 @@ def mcp_tools():
386
386
  {
387
387
  "name": "mcp_doctor",
388
388
  "description": (
389
- "Inspect installed skills and MCP JSON configuration files for "
390
- "versions and obvious configuration issues. Read-only."
389
+ "Broad, read-only health check for installed skills and MCP client "
390
+ "configuration. Discovers Codex, Cursor, WorkBuddy, OpenCode, Claude "
391
+ "Code and other known hosts (plus project-level configs), parses JSON, "
392
+ "JSONC and a narrow TOML subset, and always returns a coverage report "
393
+ "with checked / missing / unsupported / unverified hosts. Only server "
394
+ "names are returned; commands, args and env values are never included. "
395
+ "A clean result applies only to the covered scope: read coverage and "
396
+ "coverage_confidence before treating it as all-clear."
391
397
  ),
392
398
  "inputSchema": {
393
399
  "type": "object",
394
400
  "properties": {
395
401
  "skills_dirs": {"type": "array", "items": {"type": "string"}},
396
402
  "config_paths": {"type": "array", "items": {"type": "string"}},
403
+ "include_defaults": {
404
+ "type": "boolean",
405
+ "description": "Also scan the built-in host registry when config_paths is supplied",
406
+ },
397
407
  },
398
408
  "additionalProperties": False,
399
409
  },
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/YottaMeta/yotta-dev-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.2.0",
9
+ "version": "0.2.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@yottameta/yotta-dev-mcp",
14
- "version": "0.2.0",
14
+ "version": "0.2.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }