@yottameta/yotta-dev-mcp-plugin 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +3 -3
- package/.claude-plugin/marketplace.json +3 -3
- package/package.json +1 -1
- package/plugin.json +2 -2
- package/skills/yotta-dev-mcp/SKILL.md +24 -9
- package/skills/yotta-dev-mcp/references/adapters.md +69 -0
- package/skills/yotta-dev-mcp/references/architecture-contract.md +256 -0
- package/skills/yotta-dev-mcp/references/tools.md +182 -3
- package/skills/yotta-dev-mcp/scripts/dev_adapters.py +609 -0
- package/skills/yotta-dev-mcp/scripts/dev_architecture.py +379 -0
- package/skills/yotta-dev-mcp/scripts/dev_common.py +155 -0
- package/skills/yotta-dev-mcp/scripts/dev_contract.py +830 -0
- package/skills/yotta-dev-mcp/scripts/dev_engine.py +328 -210
- package/skills/yotta-dev-mcp/scripts/dev_impact.py +556 -0
- package/skills/yotta-dev-mcp/scripts/dev_mcp_doctor.py +768 -0
- package/skills/yotta-dev-mcp/scripts/dev_model.py +447 -0
- package/skills/yotta-dev-mcp/scripts/dev_selftest.py +544 -0
- package/skills/yotta-dev-mcp/scripts/dev_verify.py +450 -0
- package/skills/yotta-dev-mcp/scripts/yotta_dev_mcp.py +246 -7
- package/skills/yotta-dev-mcp/server.json +3 -3
|
@@ -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
|
+
}
|