@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.
- package/.agents/plugins/marketplace.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/yotta-dev-mcp/SKILL.md +6 -5
- package/skills/yotta-dev-mcp/references/tools.md +35 -2
- package/skills/yotta-dev-mcp/scripts/dev_common.py +13 -2
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +72 -78
- package/skills/yotta-dev-mcp/scripts/dev_mcp_doctor.py +768 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +20 -8
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +13 -3
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +12 -2
- package/skills/yotta-dev-mcp/server.json +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"authentication": "ON_INSTALL"
|
|
13
13
|
},
|
|
14
14
|
"category": "Developer Tools",
|
|
15
|
-
"version": "0.2.
|
|
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.
|
|
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
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.
|
|
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.
|
|
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` |
|
|
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
|
-
|
|
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",
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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({
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
384
|
-
|
|
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
|
-
"
|
|
390
|
-
"
|
|
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.
|
|
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.
|
|
14
|
+
"version": "0.2.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|