mcp-triage 0.1.0
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/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/checks.d.ts +12 -0
- package/dist/checks.js +211 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +83 -0
- package/dist/clients.d.ts +13 -0
- package/dist/clients.js +116 -0
- package/dist/discover.d.ts +5 -0
- package/dist/discover.js +70 -0
- package/dist/fix.d.ts +24 -0
- package/dist/fix.js +131 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/parse.d.ts +31 -0
- package/dist/parse.js +643 -0
- package/dist/report.d.ts +9 -0
- package/dist/report.js +99 -0
- package/dist/types.d.ts +86 -0
- package/dist/types.js +2 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +61 -0
package/dist/parse.js
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
// Config parsers. JSON: full (+ JSON5-light for OpenClaw). TOML: minimal (codex-style [mcp_servers.*] tables).
|
|
2
|
+
// YAML: light (dsh cordis profiles: @deepseek-ai/dsh-mcp-client patch entries).
|
|
3
|
+
// Partial parsers set `caveat` and stay honest in the report.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
export function readFileSafe(file) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.readFileSync(file, 'utf8');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// ---------- JSON ----------
|
|
14
|
+
/**
|
|
15
|
+
* JSON5-light normalization: strips // and block comments and trailing commas outside strings,
|
|
16
|
+
* converts single-quoted strings to double-quoted, and quotes unquoted identifier keys.
|
|
17
|
+
* Covers what OpenClaw documents as legal JSON5 (comments + trailing commas; the parser also
|
|
18
|
+
* accepts unquoted keys). Deliberately light: exotic JSON5 beyond this (hex numbers, multiline
|
|
19
|
+
* strings, +/- Infinity) is out of scope and will still fail JSON.parse.
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeJson5(text) {
|
|
22
|
+
return quoteUnquotedKeys(stripTrailingCommas(stripCommentsAndSingleQuotes(text)));
|
|
23
|
+
}
|
|
24
|
+
/** Pass 1: strip comments; convert single-quoted strings to double-quoted. */
|
|
25
|
+
function stripCommentsAndSingleQuotes(text) {
|
|
26
|
+
let out = '';
|
|
27
|
+
let inString = false;
|
|
28
|
+
let quote = '"';
|
|
29
|
+
for (let i = 0; i < text.length; i++) {
|
|
30
|
+
const c = text[i];
|
|
31
|
+
if (inString) {
|
|
32
|
+
if (quote === "'") {
|
|
33
|
+
// Convert content to a double-quoted scalar.
|
|
34
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
35
|
+
const n = text[i + 1];
|
|
36
|
+
if (n === "'")
|
|
37
|
+
out += "'";
|
|
38
|
+
else
|
|
39
|
+
out += '\\' + n;
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (c === "'") {
|
|
44
|
+
out += '"';
|
|
45
|
+
inString = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (c === '"')
|
|
49
|
+
out += '\\"';
|
|
50
|
+
else
|
|
51
|
+
out += c;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out += c;
|
|
55
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
56
|
+
out += text[i + 1];
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '"')
|
|
61
|
+
inString = false;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (c === '"') {
|
|
65
|
+
inString = true;
|
|
66
|
+
quote = '"';
|
|
67
|
+
out += c;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (c === "'") {
|
|
71
|
+
inString = true;
|
|
72
|
+
quote = "'";
|
|
73
|
+
out += '"';
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (c === '/' && text[i + 1] === '/') {
|
|
77
|
+
while (i < text.length && text[i] !== '\n')
|
|
78
|
+
i++;
|
|
79
|
+
out += '\n';
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
83
|
+
i += 2;
|
|
84
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
|
|
85
|
+
i++;
|
|
86
|
+
i++; // skip the closing '/'
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
out += c;
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** Pass 2: drop trailing commas before } or ] (outside strings). Also used by the fix engine. */
|
|
94
|
+
export function stripTrailingCommas(text) {
|
|
95
|
+
let out = '';
|
|
96
|
+
let inString = false;
|
|
97
|
+
for (let i = 0; i < text.length; i++) {
|
|
98
|
+
const c = text[i];
|
|
99
|
+
if (inString) {
|
|
100
|
+
out += c;
|
|
101
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
102
|
+
out += text[i + 1];
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c === '"')
|
|
107
|
+
inString = false;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (c === '"') {
|
|
111
|
+
inString = true;
|
|
112
|
+
out += c;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (c === ',') {
|
|
116
|
+
let j = i + 1;
|
|
117
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
118
|
+
j++;
|
|
119
|
+
if (text[j] === '}' || text[j] === ']')
|
|
120
|
+
continue; // drop the comma
|
|
121
|
+
}
|
|
122
|
+
out += c;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/** Pass 3: quote unquoted identifier keys ({ foo: 1 } → { "foo": 1 }). Strings are double-quoted by now. */
|
|
127
|
+
function quoteUnquotedKeys(text) {
|
|
128
|
+
let out = '';
|
|
129
|
+
let inString = false;
|
|
130
|
+
const stack = [];
|
|
131
|
+
let expectKey = false;
|
|
132
|
+
for (let i = 0; i < text.length; i++) {
|
|
133
|
+
const c = text[i];
|
|
134
|
+
if (inString) {
|
|
135
|
+
out += c;
|
|
136
|
+
if (c === '\\' && i + 1 < text.length) {
|
|
137
|
+
out += text[i + 1];
|
|
138
|
+
i++;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === '"')
|
|
142
|
+
inString = false;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (c === '"') {
|
|
146
|
+
inString = true;
|
|
147
|
+
out += c;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c === '{') {
|
|
151
|
+
stack.push('obj');
|
|
152
|
+
expectKey = true;
|
|
153
|
+
out += c;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (c === '[') {
|
|
157
|
+
stack.push('arr');
|
|
158
|
+
expectKey = false;
|
|
159
|
+
out += c;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (c === '}' || c === ']') {
|
|
163
|
+
stack.pop();
|
|
164
|
+
expectKey = false;
|
|
165
|
+
out += c;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (c === ',') {
|
|
169
|
+
expectKey = stack[stack.length - 1] === 'obj';
|
|
170
|
+
out += c;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (c === ':') {
|
|
174
|
+
expectKey = false;
|
|
175
|
+
out += c;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (expectKey && /[A-Za-z_$]/.test(c)) {
|
|
179
|
+
let j = i;
|
|
180
|
+
let tok = '';
|
|
181
|
+
while (j < text.length && /[A-Za-z0-9_$]/.test(text[j])) {
|
|
182
|
+
tok += text[j];
|
|
183
|
+
j++;
|
|
184
|
+
}
|
|
185
|
+
let k = j;
|
|
186
|
+
while (k < text.length && /\s/.test(text[k]))
|
|
187
|
+
k++;
|
|
188
|
+
if (text[k] === ':') {
|
|
189
|
+
out += '"' + tok + '"';
|
|
190
|
+
i = j - 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Not a key (e.g. a bare literal in an odd spot) — leave untouched.
|
|
194
|
+
out += tok;
|
|
195
|
+
i = j - 1;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
out += c;
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
export function findTrailingComma(text) {
|
|
203
|
+
const re = /,\s*[}\]]/g;
|
|
204
|
+
const m = re.exec(text);
|
|
205
|
+
if (!m)
|
|
206
|
+
return null;
|
|
207
|
+
const idx = m.index;
|
|
208
|
+
const line = text.slice(0, idx).split('\n').length;
|
|
209
|
+
const snippet = text.split('\n')[line - 1]?.trim().slice(0, 80) ?? '';
|
|
210
|
+
return { line, snippet };
|
|
211
|
+
}
|
|
212
|
+
function toServerEntry(name, v) {
|
|
213
|
+
const o = (v ?? {});
|
|
214
|
+
const entry = { name };
|
|
215
|
+
if (typeof o.command === 'string')
|
|
216
|
+
entry.command = o.command;
|
|
217
|
+
if (Array.isArray(o.args))
|
|
218
|
+
entry.args = o.args.filter((a) => typeof a === 'string');
|
|
219
|
+
if (o.env && typeof o.env === 'object' && !Array.isArray(o.env)) {
|
|
220
|
+
entry.env = {};
|
|
221
|
+
for (const [k, val] of Object.entries(o.env)) {
|
|
222
|
+
entry.env[k] = typeof val === 'string' ? val : undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (typeof o.url === 'string')
|
|
226
|
+
entry.url = o.url;
|
|
227
|
+
if (typeof o.transport === 'string')
|
|
228
|
+
entry.transport = o.transport;
|
|
229
|
+
else if (typeof o.type === 'string' && ['stdio', 'streamable-http', 'sse', 'http'].includes(o.type.toLowerCase())) {
|
|
230
|
+
entry.transport = o.type; // VS Code & co. spell the transport "type"
|
|
231
|
+
}
|
|
232
|
+
if (typeof o.cwd === 'string')
|
|
233
|
+
entry.cwd = o.cwd;
|
|
234
|
+
if (typeof o.enabled === 'boolean')
|
|
235
|
+
entry.enabled = o.enabled;
|
|
236
|
+
return entry;
|
|
237
|
+
}
|
|
238
|
+
/** Recognized server container shapes: mcpServers | servers | mcp.servers (first match wins). */
|
|
239
|
+
export function extractServersFromJson(data) {
|
|
240
|
+
if (!data || typeof data !== 'object')
|
|
241
|
+
return [];
|
|
242
|
+
const root = data;
|
|
243
|
+
const bags = [root.mcpServers, root.servers];
|
|
244
|
+
if (root.mcp && typeof root.mcp === 'object')
|
|
245
|
+
bags.push(root.mcp.servers);
|
|
246
|
+
for (const bag of bags) {
|
|
247
|
+
if (bag && typeof bag === 'object' && !Array.isArray(bag)) {
|
|
248
|
+
const out = [];
|
|
249
|
+
for (const [name, v] of Object.entries(bag))
|
|
250
|
+
out.push(toServerEntry(name, v));
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
export function parseJsonConfig(text, file, clientId, opts = {}) {
|
|
257
|
+
const diagnostics = [];
|
|
258
|
+
const clean = text.replace(/^\uFEFF/, '');
|
|
259
|
+
let data;
|
|
260
|
+
try {
|
|
261
|
+
data = JSON.parse(clean);
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
if (opts.json5 || opts.json5Fallback) {
|
|
265
|
+
try {
|
|
266
|
+
const normalized = normalizeJson5(clean);
|
|
267
|
+
data = JSON.parse(normalized);
|
|
268
|
+
const servers = extractServersFromJson(data);
|
|
269
|
+
if (opts.json5) {
|
|
270
|
+
// The client accepts JSON5 by design (OpenClaw) — no finding.
|
|
271
|
+
return { ok: true, servers, diagnostics: [], caveat: 'json5-light' };
|
|
272
|
+
}
|
|
273
|
+
// Unknown client (explicit --file): report exactly what we found, don't guess.
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
servers,
|
|
277
|
+
caveat: 'json5-light',
|
|
278
|
+
diagnostics: [
|
|
279
|
+
{
|
|
280
|
+
checkId: 'config.json5-only',
|
|
281
|
+
severity: 'info',
|
|
282
|
+
title: 'Valid JSON5, but not strict JSON (comments and/or trailing commas)',
|
|
283
|
+
hint: 'Fine for OpenClaw (JSON5 by design). Strict-JSON clients (Claude Desktop, Cursor, VS Code, …) will reject this file — remove comments and trailing commas if it feeds one of them.',
|
|
284
|
+
clientId,
|
|
285
|
+
file,
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// fall through to the diagnostics below, reported from the strict error
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
295
|
+
const trailing = findTrailingComma(clean);
|
|
296
|
+
if (trailing) {
|
|
297
|
+
diagnostics.push({
|
|
298
|
+
checkId: 'config.syntax',
|
|
299
|
+
severity: 'error',
|
|
300
|
+
title: 'Trailing comma breaks JSON parsing',
|
|
301
|
+
detail: `line ${trailing.line}: ${trailing.snippet}`,
|
|
302
|
+
hint: 'Remove the comma before the closing bracket/brace, then restart the client.',
|
|
303
|
+
clientId,
|
|
304
|
+
file,
|
|
305
|
+
fixable: true,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
diagnostics.push({
|
|
310
|
+
checkId: 'config.syntax',
|
|
311
|
+
severity: 'error',
|
|
312
|
+
title: 'Config file is not valid JSON',
|
|
313
|
+
detail: msg,
|
|
314
|
+
hint: 'Fix the syntax; most clients silently ignore a broken config file.',
|
|
315
|
+
clientId,
|
|
316
|
+
file,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return { ok: false, servers: [], diagnostics };
|
|
320
|
+
}
|
|
321
|
+
const servers = extractServersFromJson(data);
|
|
322
|
+
return { ok: true, servers, diagnostics: [] };
|
|
323
|
+
}
|
|
324
|
+
// ---------- TOML (minimal: [mcp_servers.*] tables) ----------
|
|
325
|
+
function unquote(s) {
|
|
326
|
+
const t = s.trim();
|
|
327
|
+
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
|
328
|
+
return t.slice(1, -1);
|
|
329
|
+
}
|
|
330
|
+
return t;
|
|
331
|
+
}
|
|
332
|
+
function parseTomlValue(v) {
|
|
333
|
+
const t = v.trim();
|
|
334
|
+
if (t.startsWith('[')) {
|
|
335
|
+
try {
|
|
336
|
+
const arr = JSON.parse(t);
|
|
337
|
+
if (Array.isArray(arr))
|
|
338
|
+
return arr.filter((x) => typeof x === 'string');
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// fall through to lenient extraction
|
|
342
|
+
}
|
|
343
|
+
const items = [...t.matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2] ?? '');
|
|
344
|
+
return items;
|
|
345
|
+
}
|
|
346
|
+
return unquote(t);
|
|
347
|
+
}
|
|
348
|
+
function parseSectionHeader(line) {
|
|
349
|
+
const m = line.match(/^\[([^\]]+)\]\s*$/);
|
|
350
|
+
if (!m)
|
|
351
|
+
return null;
|
|
352
|
+
return m[1].split('.').map((seg) => unquote(seg));
|
|
353
|
+
}
|
|
354
|
+
export function parseTomlConfig(text, file, clientId) {
|
|
355
|
+
const lines = text.split(/\r?\n/);
|
|
356
|
+
const byName = new Map();
|
|
357
|
+
let section = [];
|
|
358
|
+
let sawMcpServers = false;
|
|
359
|
+
for (const raw of lines) {
|
|
360
|
+
const line = raw.trim();
|
|
361
|
+
if (!line || line.startsWith('#'))
|
|
362
|
+
continue;
|
|
363
|
+
const sec = parseSectionHeader(line);
|
|
364
|
+
if (sec) {
|
|
365
|
+
section = sec;
|
|
366
|
+
if (sec[0] === 'mcp_servers') {
|
|
367
|
+
sawMcpServers = true;
|
|
368
|
+
if (sec.length >= 2) {
|
|
369
|
+
const name = sec[1];
|
|
370
|
+
if (!byName.has(name))
|
|
371
|
+
byName.set(name, { name });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (section[0] !== 'mcp_servers')
|
|
377
|
+
continue;
|
|
378
|
+
const kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
|
|
379
|
+
if (!kv)
|
|
380
|
+
continue;
|
|
381
|
+
const key = kv[1];
|
|
382
|
+
const value = parseTomlValue(kv[2]);
|
|
383
|
+
if (section.length === 2) {
|
|
384
|
+
const entry = byName.get(section[1]);
|
|
385
|
+
if (key === 'command' && typeof value === 'string')
|
|
386
|
+
entry.command = value;
|
|
387
|
+
else if (key === 'args' && Array.isArray(value))
|
|
388
|
+
entry.args = value;
|
|
389
|
+
else if (key === 'cwd' && typeof value === 'string')
|
|
390
|
+
entry.cwd = value;
|
|
391
|
+
else if (key === 'url' && typeof value === 'string')
|
|
392
|
+
entry.url = value;
|
|
393
|
+
else if (key === 'transport' && typeof value === 'string')
|
|
394
|
+
entry.transport = value;
|
|
395
|
+
}
|
|
396
|
+
else if (section.length === 3 && section[2] === 'env') {
|
|
397
|
+
const entry = byName.get(section[1]);
|
|
398
|
+
entry.env = entry.env ?? {};
|
|
399
|
+
entry.env[key] = typeof value === 'string' ? value : undefined;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const diagnostics = [];
|
|
403
|
+
if (sawMcpServers && byName.size === 0) {
|
|
404
|
+
diagnostics.push({
|
|
405
|
+
checkId: 'config.toml-partial',
|
|
406
|
+
severity: 'info',
|
|
407
|
+
title: 'mcp_servers table found but no named entries could be read',
|
|
408
|
+
hint: 'Likely a parser limitation of basic TOML support — report this file to the maintainers.',
|
|
409
|
+
clientId,
|
|
410
|
+
file,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, servers: [...byName.values()], diagnostics, caveat: 'toml-minimal' };
|
|
414
|
+
}
|
|
415
|
+
// ---------- YAML (light: dsh cordis profiles, @deepseek-ai/dsh-mcp-client entries) ----------
|
|
416
|
+
//
|
|
417
|
+
// dsh MCP servers are patch entries shaped like:
|
|
418
|
+
// - id: mcp-github
|
|
419
|
+
// name: '@deepseek-ai/dsh-mcp-client'
|
|
420
|
+
// config:
|
|
421
|
+
// serverName: github
|
|
422
|
+
// transport: stdio # stdio | streamable-http
|
|
423
|
+
// command: npx
|
|
424
|
+
// args: ['-y', '@modelcontextprotocol/server-github'] # inline or block sequence
|
|
425
|
+
// env:
|
|
426
|
+
// GITHUB_TOKEN: !!js process.env.GITHUB_TOKEN # block map; !!js expressions kept as text
|
|
427
|
+
// (Source: @deepseek-ai/dsh-mcp-client README, v0.1.5-rc.2.)
|
|
428
|
+
/** Strip a YAML cast prefix like `!!js ` from a scalar. */
|
|
429
|
+
function stripCast(v) {
|
|
430
|
+
return v.replace(/^!!js\s+/, '').trim();
|
|
431
|
+
}
|
|
432
|
+
/** Remove a trailing ` # comment` from a plain scalar (leaves # inside quotes alone). */
|
|
433
|
+
function stripInlineComment(t) {
|
|
434
|
+
let inS = false;
|
|
435
|
+
let q = '';
|
|
436
|
+
for (let i = 0; i < t.length; i++) {
|
|
437
|
+
const c = t[i];
|
|
438
|
+
if (inS) {
|
|
439
|
+
if (c === '\\') {
|
|
440
|
+
i++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (c === q)
|
|
444
|
+
inS = false;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (c === '"' || c === "'") {
|
|
448
|
+
inS = true;
|
|
449
|
+
q = c;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (c === '#' && i > 0 && /\s/.test(t[i - 1]))
|
|
453
|
+
return t.slice(0, i).trimEnd();
|
|
454
|
+
}
|
|
455
|
+
return t;
|
|
456
|
+
}
|
|
457
|
+
/** Parse a YAML/JSON-ish scalar to a plain string. */
|
|
458
|
+
function yamlScalar(raw) {
|
|
459
|
+
return unquote(stripCast(stripInlineComment(raw).trim())).trim();
|
|
460
|
+
}
|
|
461
|
+
/** Split a flow collection body on top-level commas (ignores commas inside quotes). */
|
|
462
|
+
function splitFlow(body) {
|
|
463
|
+
const parts = [];
|
|
464
|
+
let cur = '';
|
|
465
|
+
let inS = false;
|
|
466
|
+
let q = '';
|
|
467
|
+
for (let i = 0; i < body.length; i++) {
|
|
468
|
+
const c = body[i];
|
|
469
|
+
if (inS) {
|
|
470
|
+
cur += c;
|
|
471
|
+
if (c === '\\') {
|
|
472
|
+
cur += body[i + 1] ?? '';
|
|
473
|
+
i++;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (c === q)
|
|
477
|
+
inS = false;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (c === '"' || c === "'") {
|
|
481
|
+
inS = true;
|
|
482
|
+
q = c;
|
|
483
|
+
cur += c;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (c === ',') {
|
|
487
|
+
parts.push(cur);
|
|
488
|
+
cur = '';
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
cur += c;
|
|
492
|
+
}
|
|
493
|
+
if (cur.trim() !== '' || parts.length > 0)
|
|
494
|
+
parts.push(cur);
|
|
495
|
+
return parts.map((p) => p.trim()).filter((p) => p !== '');
|
|
496
|
+
}
|
|
497
|
+
/** Parse an inline array body (without brackets) into strings. Unquoted tokens are kept as-is. */
|
|
498
|
+
function parseFlowArray(body) {
|
|
499
|
+
return splitFlow(body).map((p) => yamlScalar(p));
|
|
500
|
+
}
|
|
501
|
+
/** Parse an inline map body (without braces) into string values. */
|
|
502
|
+
function parseFlowMap(body) {
|
|
503
|
+
const out = {};
|
|
504
|
+
for (const part of splitFlow(body)) {
|
|
505
|
+
const m = part.match(/^("[^"]*"|'[^']*'|[A-Za-z0-9_.-]+)\s*:\s*(.*)$/);
|
|
506
|
+
if (m)
|
|
507
|
+
out[unquote(m[1])] = yamlScalar(m[2]);
|
|
508
|
+
}
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
511
|
+
function chunkIndent(line) {
|
|
512
|
+
const m = line.match(/^(\s*)/);
|
|
513
|
+
return m ? m[1].length : 0;
|
|
514
|
+
}
|
|
515
|
+
export function parseYamlLight(text, file, clientId) {
|
|
516
|
+
const servers = [];
|
|
517
|
+
const diagnostics = [];
|
|
518
|
+
const normalized = text.replace(/\r\n/g, '\n');
|
|
519
|
+
// Each patch entry starts at a `- id:` list item (any indent, e.g. nested under `insert:`).
|
|
520
|
+
const chunks = normalized.split(/\n(?=\s*-\s+id:)/);
|
|
521
|
+
for (const chunk of chunks) {
|
|
522
|
+
if (!chunk.includes('dsh-mcp-client'))
|
|
523
|
+
continue;
|
|
524
|
+
const idMatch = chunk.match(/-\s*id:\s*(\S+)/);
|
|
525
|
+
const entry = { name: 'dsh-mcp-client' };
|
|
526
|
+
const lines = chunk.split('\n');
|
|
527
|
+
for (let i = 0; i < lines.length; i++) {
|
|
528
|
+
const line = lines[i];
|
|
529
|
+
const keyMatch = line.match(/^(\s*)([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
530
|
+
if (!keyMatch)
|
|
531
|
+
continue;
|
|
532
|
+
const [, indent, key, restRaw] = keyMatch;
|
|
533
|
+
const rest = restRaw.trim();
|
|
534
|
+
if (key === 'serverName' && rest) {
|
|
535
|
+
entry.name = yamlScalar(rest) || entry.name;
|
|
536
|
+
}
|
|
537
|
+
else if (key === 'transport' && rest) {
|
|
538
|
+
entry.transport = yamlScalar(rest);
|
|
539
|
+
}
|
|
540
|
+
else if (key === 'command' && rest) {
|
|
541
|
+
entry.command = yamlScalar(rest);
|
|
542
|
+
}
|
|
543
|
+
else if (key === 'url' && rest) {
|
|
544
|
+
entry.url = yamlScalar(rest);
|
|
545
|
+
}
|
|
546
|
+
else if (key === 'cwd' && rest) {
|
|
547
|
+
entry.cwd = yamlScalar(rest);
|
|
548
|
+
}
|
|
549
|
+
else if (key === 'args') {
|
|
550
|
+
if (rest && rest.startsWith('[')) {
|
|
551
|
+
entry.args = parseFlowArray(rest.replace(/^\[/, '').replace(/\]\s*$/, ''));
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
const items = [];
|
|
555
|
+
const keyIndent = indent.length;
|
|
556
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
557
|
+
const l = lines[j];
|
|
558
|
+
if (l.trim() === '')
|
|
559
|
+
continue;
|
|
560
|
+
if (chunkIndent(l) <= keyIndent)
|
|
561
|
+
break;
|
|
562
|
+
const m = l.match(/^\s*-\s+(.*)$/);
|
|
563
|
+
if (!m)
|
|
564
|
+
break;
|
|
565
|
+
items.push(yamlScalar(m[1]));
|
|
566
|
+
}
|
|
567
|
+
if (items.length > 0)
|
|
568
|
+
entry.args = items;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else if (key === 'env') {
|
|
572
|
+
const env = {};
|
|
573
|
+
if (rest.startsWith('{')) {
|
|
574
|
+
Object.assign(env, parseFlowMap(rest.replace(/^\{/, '').replace(/\}\s*$/, '')));
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
const keyIndent = indent.length;
|
|
578
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
579
|
+
const l = lines[j];
|
|
580
|
+
if (l.trim() === '')
|
|
581
|
+
continue;
|
|
582
|
+
if (chunkIndent(l) <= keyIndent)
|
|
583
|
+
break;
|
|
584
|
+
const m = l.match(/^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/);
|
|
585
|
+
if (!m)
|
|
586
|
+
continue;
|
|
587
|
+
env[m[1]] = yamlScalar(m[2]);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (Object.keys(env).length > 0)
|
|
591
|
+
entry.env = env;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const idTail = idMatch ? idMatch[1] : undefined;
|
|
595
|
+
if (entry.name === 'dsh-mcp-client' && idTail)
|
|
596
|
+
entry.name = `dsh:${idTail}`;
|
|
597
|
+
const serverNameMatch = chunk.match(/^\s*serverName:\s*(\S.*)$/m);
|
|
598
|
+
if (!serverNameMatch) {
|
|
599
|
+
diagnostics.push({
|
|
600
|
+
checkId: 'dsh.serverName-missing',
|
|
601
|
+
severity: 'error',
|
|
602
|
+
title: 'dsh MCP entry has no serverName — the entry will fail to load',
|
|
603
|
+
detail: idTail ? `entry id: ${idTail}` : undefined,
|
|
604
|
+
clientId,
|
|
605
|
+
file,
|
|
606
|
+
hint: "Add `serverName: <short-name>` (required, [A-Za-z0-9_-]{1,32}) — it namespaces the server's tools as mcp__<serverName>__<tool>.",
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
else if (!/^[A-Za-z0-9_-]{1,32}$/.test(entry.name)) {
|
|
610
|
+
diagnostics.push({
|
|
611
|
+
checkId: 'dsh.serverName-invalid',
|
|
612
|
+
severity: 'warning',
|
|
613
|
+
title: `serverName "${entry.name}" does not match [A-Za-z0-9_-]{1,32}`,
|
|
614
|
+
clientId,
|
|
615
|
+
file,
|
|
616
|
+
serverName: entry.name,
|
|
617
|
+
hint: 'Use letters, digits, _ and - only (max 32 chars); names become model-facing tool prefixes.',
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
servers.push(entry);
|
|
621
|
+
}
|
|
622
|
+
return { ok: true, servers, diagnostics, caveat: 'yaml-light' };
|
|
623
|
+
}
|
|
624
|
+
// ---------- Entry ----------
|
|
625
|
+
export function parseConfigFile(f) {
|
|
626
|
+
const text = readFileSafe(f.file);
|
|
627
|
+
if (text === null) {
|
|
628
|
+
return {
|
|
629
|
+
clientId: f.clientId,
|
|
630
|
+
file: f.file,
|
|
631
|
+
format: f.format,
|
|
632
|
+
ok: false,
|
|
633
|
+
servers: [],
|
|
634
|
+
diagnostics: [
|
|
635
|
+
{ checkId: 'config.unreadable', severity: 'error', title: 'Cannot read config file', clientId: f.clientId, file: f.file },
|
|
636
|
+
],
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const r = f.format === 'json' ? parseJsonConfig(text, f.file, f.clientId, { json5: f.json5, json5Fallback: f.json5Fallback })
|
|
640
|
+
: f.format === 'toml' ? parseTomlConfig(text, f.file, f.clientId)
|
|
641
|
+
: parseYamlLight(text, f.file, f.clientId);
|
|
642
|
+
return { clientId: f.clientId, file: f.file, format: f.format, ok: r.ok, caveat: r.caveat, servers: r.servers, diagnostics: r.diagnostics };
|
|
643
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Diagnostic, DiscoveredFile, FixOutcome, ParsedConfig } from './types.ts';
|
|
2
|
+
export interface ReportInput {
|
|
3
|
+
files: DiscoveredFile[];
|
|
4
|
+
parsed: ParsedConfig[];
|
|
5
|
+
diagnostics: Diagnostic[];
|
|
6
|
+
}
|
|
7
|
+
export declare function tilde(p: string): string;
|
|
8
|
+
export declare function renderHuman(input: ReportInput, version: string, fixes?: FixOutcome[]): string;
|
|
9
|
+
export declare function renderJson(input: ReportInput, version: string, fixes?: FixOutcome[]): string;
|