mcp-triage 0.1.0 → 0.1.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/README.md +3 -3
- package/dist/checks.d.ts +1 -1
- package/dist/checks.js +7 -5
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/parse.d.ts +11 -0
- package/dist/parse.js +82 -3
- package/dist/report.js +5 -4
- package/dist/types.d.ts +9 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Why *triage*: a triage assesses severity fast and routes the case — a free che
|
|
|
8
8
|
|
|
9
9
|
## What it does
|
|
10
10
|
|
|
11
|
-
- **Scans** (8 clients): Claude Desktop · Claude Code · Codex · Cursor · VS Code · Windsurf · OpenClaw · dsh
|
|
11
|
+
- **Scans** (8 clients): Claude Desktop · Claude Code (incl. project-scoped servers from `~/.claude.json`) · Codex · Cursor · VS Code · Windsurf · OpenClaw · dsh
|
|
12
12
|
- **Checks** (v0.1): JSON syntax (including the classic trailing comma), Codex-style TOML tables (basic), command resolvable on PATH, missing `${VAR}` / `process.env.VAR` references, relative-path arguments, plain `http://` remote URLs, transport/entry consistency (stdio needs a command, HTTP transports need a url, `serverName` required for dsh entries), cross-client drift for same-named servers
|
|
13
13
|
- **Fixes** (opt-in `--fix`): mechanical repairs, only for files that fail to parse — strips JSON comments and trailing commas, re-verifies the result, keeps a `.mcp-triage.bak` backup. Everything else is escalated with a hint, never guessed at.
|
|
14
14
|
- **JSON5-aware**: OpenClaw's `openclaw.json` is JSON5 (comments + trailing commas legal) and is parsed as such — no false syntax errors
|
|
@@ -80,7 +80,7 @@ Summary: 1 error(s), 1 warning(s), 1 info — 4 server(s) across 3 file(s).
|
|
|
80
80
|
|
|
81
81
|
- All 8 client paths are verified against official docs and/or a real machine (verification log: `docs/verification-log.md` in the repo). OpenClaw paths additionally honor `OPENCLAW_CONFIG_PATH`; VS Code includes the remote/WSL user config (`~/.vscode-server/data/User/mcp.json`).
|
|
82
82
|
- JSON clients: full parsing (OpenClaw: JSON5-light — comments and trailing commas; exotic JSON5 beyond that still fails). TOML (Codex): **basic** — `[mcp_servers.*]` tables only. YAML (dsh cordis profiles): **light** — per-entry extraction of `@deepseek-ai/dsh-mcp-client` patch entries (serverName, transport, command, args, env, cwd; `!!js` expressions kept as text for reference checks).
|
|
83
|
-
- Claude Code project-scoped `mcpServers` inside `~/.claude.json` (`projects.*.mcpServers`) are
|
|
83
|
+
- Claude Code project-scoped `mcpServers` inside `~/.claude.json` (`projects.*.mcpServers`) are scanned too — identical definitions across projects are merged into one entry whose context lists the projects, and findings carry the project path.
|
|
84
84
|
- `--file` on a file we cannot attribute to a client: if it only parses as JSON5, you get an **info** saying so (not an error) — strict-JSON clients would reject such a file.
|
|
85
85
|
|
|
86
86
|
## When `--fix` is not enough
|
|
@@ -91,7 +91,7 @@ Summary: 1 error(s), 1 warning(s), 1 info — 4 server(s) across 3 file(s).
|
|
|
91
91
|
|
|
92
92
|
```bash
|
|
93
93
|
npm install
|
|
94
|
-
npm test # node:test,
|
|
94
|
+
npm test # node:test, 50 specs — dev/test scripts need Node 22.18+ (native type stripping)
|
|
95
95
|
npm run build # tsc → dist/
|
|
96
96
|
node src/cli.ts scan
|
|
97
97
|
node src/cli.ts scan --fix --dry-run
|
package/dist/checks.d.ts
CHANGED
|
@@ -8,5 +8,5 @@ export declare const DEFAULT_CHECK_CONTEXT: CheckContext;
|
|
|
8
8
|
export declare function resolveCommandOnPath(cmd: string, ctx: CheckContext): string | null;
|
|
9
9
|
export declare function findEnvRefs(s: string): string[];
|
|
10
10
|
export declare function runChecks(parsed: ParsedConfig[], ctx?: CheckContext): Diagnostic[];
|
|
11
|
-
/** Cross-file drift: same server name present in multiple
|
|
11
|
+
/** Cross-file drift: same server name present in multiple places but with a different launch shape. */
|
|
12
12
|
export declare function checkCrossClientDrift(parsed: ParsedConfig[]): Diagnostic[];
|
package/dist/checks.js
CHANGED
|
@@ -66,7 +66,7 @@ function stringy(s) {
|
|
|
66
66
|
}
|
|
67
67
|
function checkServer(clientId, file, s, ctx) {
|
|
68
68
|
const diags = [];
|
|
69
|
-
const base = { clientId, file, serverName: s.name };
|
|
69
|
+
const base = { clientId, file, serverName: s.name, ...(s.context !== undefined ? { context: s.context } : {}) };
|
|
70
70
|
if (s.enabled === false) {
|
|
71
71
|
diags.push({
|
|
72
72
|
checkId: 'server.disabled',
|
|
@@ -178,14 +178,14 @@ export function runChecks(parsed, ctx = DEFAULT_CHECK_CONTEXT) {
|
|
|
178
178
|
}
|
|
179
179
|
return out;
|
|
180
180
|
}
|
|
181
|
-
/** Cross-file drift: same server name present in multiple
|
|
181
|
+
/** Cross-file drift: same server name present in multiple places but with a different launch shape. */
|
|
182
182
|
export function checkCrossClientDrift(parsed) {
|
|
183
183
|
const byName = new Map();
|
|
184
184
|
for (const p of parsed) {
|
|
185
185
|
for (const s of p.servers) {
|
|
186
186
|
const shape = JSON.stringify([s.command ?? s.url ?? '', s.args ?? []]);
|
|
187
187
|
const list = byName.get(s.name) ?? [];
|
|
188
|
-
list.push({ file: p.file, clientId: p.clientId, shape });
|
|
188
|
+
list.push({ file: p.file, clientId: p.clientId, context: s.context, shape });
|
|
189
189
|
byName.set(s.name, list);
|
|
190
190
|
}
|
|
191
191
|
}
|
|
@@ -195,11 +195,13 @@ export function checkCrossClientDrift(parsed) {
|
|
|
195
195
|
continue;
|
|
196
196
|
const shapes = new Set(list.map((l) => l.shape));
|
|
197
197
|
if (shapes.size > 1) {
|
|
198
|
+
const clients = new Set(list.map((l) => l.clientId));
|
|
199
|
+
const where = clients.size > 1 ? `across ${clients.size} clients (${list.length} places)` : `in ${list.length} places`;
|
|
198
200
|
out.push({
|
|
199
201
|
checkId: 'config.cross-client-drift',
|
|
200
202
|
severity: 'info',
|
|
201
|
-
title: `Server "${name}" is configured differently
|
|
202
|
-
detail: list.map((l) => `${l.clientId} → ${l.file}`).join('\n'),
|
|
203
|
+
title: `Server "${name}" is configured differently ${where}`,
|
|
204
|
+
detail: list.map((l) => `${l.clientId}${l.context ? ` (${l.context})` : ''} → ${l.file}`).join('\n'),
|
|
203
205
|
hint: 'Drift is not always wrong — but when one client works and another does not, this is where to look.',
|
|
204
206
|
clientId: list[0].clientId,
|
|
205
207
|
file: list[0].file,
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export { VERSION } from './version.ts';
|
|
|
2
2
|
export { CLIENTS, clientPathsForPlatform, defaultPathContext, expandPlaceholders } from './clients.ts';
|
|
3
3
|
export type { PathContext } from './clients.ts';
|
|
4
4
|
export { discoverFiles, expandGlob } from './discover.ts';
|
|
5
|
-
export { parseConfigFile, parseJsonConfig, parseTomlConfig, parseYamlLight, normalizeJson5, extractServersFromJson, findTrailingComma, stripTrailingCommas, readFileSafe, } from './parse.ts';
|
|
5
|
+
export { parseConfigFile, parseJsonConfig, parseTomlConfig, parseYamlLight, normalizeJson5, extractServersFromJson, extractClaudeProjectServers, findTrailingComma, stripTrailingCommas, readFileSafe, } from './parse.ts';
|
|
6
|
+
export type { ClaudeProjectScope } from './parse.ts';
|
|
6
7
|
export { runChecks, checkCrossClientDrift, resolveCommandOnPath, findEnvRefs, DEFAULT_CHECK_CONTEXT } from './checks.ts';
|
|
7
8
|
export type { CheckContext } from './checks.ts';
|
|
8
9
|
export { applyFixes, repairJsonText, stripJsonComments } from './fix.ts';
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export { VERSION } from "./version.js";
|
|
6
6
|
export { CLIENTS, clientPathsForPlatform, defaultPathContext, expandPlaceholders } from "./clients.js";
|
|
7
7
|
export { discoverFiles, expandGlob } from "./discover.js";
|
|
8
|
-
export { parseConfigFile, parseJsonConfig, parseTomlConfig, parseYamlLight, normalizeJson5, extractServersFromJson, findTrailingComma, stripTrailingCommas, readFileSafe, } from "./parse.js";
|
|
8
|
+
export { parseConfigFile, parseJsonConfig, parseTomlConfig, parseYamlLight, normalizeJson5, extractServersFromJson, extractClaudeProjectServers, findTrailingComma, stripTrailingCommas, readFileSafe, } from "./parse.js";
|
|
9
9
|
export { runChecks, checkCrossClientDrift, resolveCommandOnPath, findEnvRefs, DEFAULT_CHECK_CONTEXT } from "./checks.js";
|
|
10
10
|
export { applyFixes, repairJsonText, stripJsonComments } from "./fix.js";
|
|
11
11
|
export { renderHuman, renderJson } from "./report.js";
|
package/dist/parse.d.ts
CHANGED
|
@@ -16,15 +16,26 @@ export declare function findTrailingComma(text: string): {
|
|
|
16
16
|
} | null;
|
|
17
17
|
/** Recognized server container shapes: mcpServers | servers | mcp.servers (first match wins). */
|
|
18
18
|
export declare function extractServersFromJson(data: unknown): ServerEntry[];
|
|
19
|
+
export interface ClaudeProjectScope {
|
|
20
|
+
servers: ServerEntry[];
|
|
21
|
+
/** Number of projects that carry at least one server definition. */
|
|
22
|
+
projects: number;
|
|
23
|
+
}
|
|
24
|
+
/** Human label for the projects an entry was found in. */
|
|
25
|
+
export declare function contextLabel(paths: string[]): string;
|
|
26
|
+
export declare function extractClaudeProjectServers(data: unknown): ClaudeProjectScope;
|
|
19
27
|
export interface ParseOutcome {
|
|
20
28
|
ok: boolean;
|
|
21
29
|
servers: ServerEntry[];
|
|
22
30
|
diagnostics: Diagnostic[];
|
|
23
31
|
caveat?: string;
|
|
32
|
+
/** Coverage note for non-obvious extractions (e.g. project-scoped servers folded in from ~/.claude.json). */
|
|
33
|
+
note?: string;
|
|
24
34
|
}
|
|
25
35
|
export declare function parseJsonConfig(text: string, file: string, clientId: string, opts?: {
|
|
26
36
|
json5?: boolean;
|
|
27
37
|
json5Fallback?: boolean;
|
|
38
|
+
projectScope?: boolean;
|
|
28
39
|
}): ParseOutcome;
|
|
29
40
|
export declare function parseTomlConfig(text: string, file: string, clientId: string): ParseOutcome;
|
|
30
41
|
export declare function parseYamlLight(text: string, file: string, clientId: string): ParseOutcome;
|
package/dist/parse.js
CHANGED
|
@@ -253,6 +253,66 @@ export function extractServersFromJson(data) {
|
|
|
253
253
|
}
|
|
254
254
|
return [];
|
|
255
255
|
}
|
|
256
|
+
function entryShapeKey(s) {
|
|
257
|
+
const envKey = s.env ? Object.entries(s.env).sort(([a], [b]) => (a < b ? -1 : 1)) : null;
|
|
258
|
+
return JSON.stringify([
|
|
259
|
+
s.name,
|
|
260
|
+
s.command ?? null,
|
|
261
|
+
s.args ?? null,
|
|
262
|
+
envKey,
|
|
263
|
+
s.url ?? null,
|
|
264
|
+
s.transport ?? null,
|
|
265
|
+
s.cwd ?? null,
|
|
266
|
+
s.enabled ?? null,
|
|
267
|
+
]);
|
|
268
|
+
}
|
|
269
|
+
/** Human label for the projects an entry was found in. */
|
|
270
|
+
export function contextLabel(paths) {
|
|
271
|
+
if (paths.length === 1)
|
|
272
|
+
return `project: ${paths[0]}`;
|
|
273
|
+
const shown = paths.slice(0, 2).join(', ');
|
|
274
|
+
const more = paths.length > 2 ? ` +${paths.length - 2} more` : '';
|
|
275
|
+
return `projects: ${shown}${more}`;
|
|
276
|
+
}
|
|
277
|
+
export function extractClaudeProjectServers(data) {
|
|
278
|
+
const empty = { servers: [], projects: 0 };
|
|
279
|
+
if (!data || typeof data !== 'object')
|
|
280
|
+
return empty;
|
|
281
|
+
const projects = data.projects;
|
|
282
|
+
if (!projects || typeof projects !== 'object' || Array.isArray(projects))
|
|
283
|
+
return empty;
|
|
284
|
+
const byShape = new Map();
|
|
285
|
+
let projectsWithServers = 0;
|
|
286
|
+
for (const [projPath, projVal] of Object.entries(projects)) {
|
|
287
|
+
if (!projVal || typeof projVal !== 'object' || Array.isArray(projVal))
|
|
288
|
+
continue;
|
|
289
|
+
const bag = projVal.mcpServers;
|
|
290
|
+
if (!bag || typeof bag !== 'object' || Array.isArray(bag))
|
|
291
|
+
continue;
|
|
292
|
+
const names = Object.keys(bag);
|
|
293
|
+
if (names.length === 0)
|
|
294
|
+
continue;
|
|
295
|
+
projectsWithServers++;
|
|
296
|
+
for (const name of names) {
|
|
297
|
+
const entry = toServerEntry(name, bag[name]);
|
|
298
|
+
const key = entryShapeKey(entry);
|
|
299
|
+
const hit = byShape.get(key);
|
|
300
|
+
if (hit) {
|
|
301
|
+
if (!hit.paths.includes(projPath))
|
|
302
|
+
hit.paths.push(projPath);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
byShape.set(key, { entry, paths: [projPath] });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const servers = [];
|
|
310
|
+
for (const { entry, paths } of byShape.values()) {
|
|
311
|
+
entry.context = contextLabel(paths);
|
|
312
|
+
servers.push(entry);
|
|
313
|
+
}
|
|
314
|
+
return { servers, projects: projectsWithServers };
|
|
315
|
+
}
|
|
256
316
|
export function parseJsonConfig(text, file, clientId, opts = {}) {
|
|
257
317
|
const diagnostics = [];
|
|
258
318
|
const clean = text.replace(/^\uFEFF/, '');
|
|
@@ -319,7 +379,15 @@ export function parseJsonConfig(text, file, clientId, opts = {}) {
|
|
|
319
379
|
return { ok: false, servers: [], diagnostics };
|
|
320
380
|
}
|
|
321
381
|
const servers = extractServersFromJson(data);
|
|
322
|
-
|
|
382
|
+
let note;
|
|
383
|
+
if (opts.projectScope) {
|
|
384
|
+
const scope = extractClaudeProjectServers(data);
|
|
385
|
+
if (scope.servers.length > 0) {
|
|
386
|
+
servers.push(...scope.servers);
|
|
387
|
+
note = `${scope.servers.length} project-scoped server(s) from ${scope.projects} project(s)`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { ok: true, servers, diagnostics: [], note };
|
|
323
391
|
}
|
|
324
392
|
// ---------- TOML (minimal: [mcp_servers.*] tables) ----------
|
|
325
393
|
function unquote(s) {
|
|
@@ -636,8 +704,19 @@ export function parseConfigFile(f) {
|
|
|
636
704
|
],
|
|
637
705
|
};
|
|
638
706
|
}
|
|
639
|
-
|
|
707
|
+
// Claude Code's ~/.claude.json also carries project-scoped server bags (projects.*.mcpServers).
|
|
708
|
+
const projectScope = f.clientId === 'claude-code' && f.scope === 'global';
|
|
709
|
+
const r = f.format === 'json' ? parseJsonConfig(text, f.file, f.clientId, { json5: f.json5, json5Fallback: f.json5Fallback, projectScope })
|
|
640
710
|
: f.format === 'toml' ? parseTomlConfig(text, f.file, f.clientId)
|
|
641
711
|
: parseYamlLight(text, f.file, f.clientId);
|
|
642
|
-
return {
|
|
712
|
+
return {
|
|
713
|
+
clientId: f.clientId,
|
|
714
|
+
file: f.file,
|
|
715
|
+
format: f.format,
|
|
716
|
+
ok: r.ok,
|
|
717
|
+
caveat: r.caveat,
|
|
718
|
+
note: r.note,
|
|
719
|
+
servers: r.servers,
|
|
720
|
+
diagnostics: r.diagnostics,
|
|
721
|
+
};
|
|
643
722
|
}
|
package/dist/report.js
CHANGED
|
@@ -28,8 +28,8 @@ export function renderHuman(input, version, fixes) {
|
|
|
28
28
|
for (const p of input.parsed) {
|
|
29
29
|
const flag = p.ok ? '✓' : '✗';
|
|
30
30
|
const n = p.servers.length;
|
|
31
|
-
const
|
|
32
|
-
lines.push(` ${flag} ${clientName(p.clientId)} — ${tilde(p.file)} — ${n} server(s)${
|
|
31
|
+
const note = [p.caveat, p.note].filter(Boolean).join('; ');
|
|
32
|
+
lines.push(` ${flag} ${clientName(p.clientId)} — ${tilde(p.file)} — ${n} server(s)${note ? ` (${note})` : ''}`);
|
|
33
33
|
}
|
|
34
34
|
lines.push('');
|
|
35
35
|
const sorted = [...input.diagnostics].sort((a, b) => ORDER.indexOf(a.severity) - ORDER.indexOf(b.severity));
|
|
@@ -39,7 +39,7 @@ export function renderHuman(input, version, fixes) {
|
|
|
39
39
|
else {
|
|
40
40
|
lines.push(`Findings (${sorted.length}):`);
|
|
41
41
|
for (const d of sorted) {
|
|
42
|
-
const where = [clientName(d.clientId ?? ''), d.serverName ? `"${d.serverName}"` : ''].filter(Boolean).join(' · ');
|
|
42
|
+
const where = [clientName(d.clientId ?? ''), d.context ?? '', d.serverName ? `"${d.serverName}"` : ''].filter(Boolean).join(' · ');
|
|
43
43
|
lines.push(` [${LABEL[d.severity]}] ${d.checkId} — ${where ? where + ': ' : ''}${d.title}`);
|
|
44
44
|
if (d.detail)
|
|
45
45
|
for (const l of d.detail.split('\n'))
|
|
@@ -90,7 +90,8 @@ export function renderJson(input, version, fixes) {
|
|
|
90
90
|
format: p.format,
|
|
91
91
|
ok: p.ok,
|
|
92
92
|
caveat: p.caveat,
|
|
93
|
-
|
|
93
|
+
note: p.note,
|
|
94
|
+
servers: p.servers.map((s) => ({ name: s.name, command: s.command, url: s.url, transport: s.transport, context: s.context })),
|
|
94
95
|
})),
|
|
95
96
|
diagnostics: input.diagnostics,
|
|
96
97
|
...(fixes !== undefined ? { fixes } : {}),
|
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface Diagnostic {
|
|
|
11
11
|
clientId?: string;
|
|
12
12
|
file?: string;
|
|
13
13
|
serverName?: string;
|
|
14
|
+
/** Location detail for entries in shared files (e.g. the Claude Code project path in ~/.claude.json). */
|
|
15
|
+
context?: string;
|
|
14
16
|
/** Whether `--fix` can address this in a future/current version */
|
|
15
17
|
fixable?: boolean;
|
|
16
18
|
}
|
|
@@ -24,6 +26,11 @@ export interface ServerEntry {
|
|
|
24
26
|
cwd?: string;
|
|
25
27
|
/** Explicitly disabled entries (OpenClaw `enabled: false`) — kept but not connected; runtime checks are skipped. */
|
|
26
28
|
enabled?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Where this entry lives when the file is shared and the location is not obvious —
|
|
31
|
+
* e.g. the project path for Claude Code project-scoped servers inside ~/.claude.json.
|
|
32
|
+
*/
|
|
33
|
+
context?: string;
|
|
27
34
|
}
|
|
28
35
|
export interface ParsedConfig {
|
|
29
36
|
clientId: string;
|
|
@@ -32,6 +39,8 @@ export interface ParsedConfig {
|
|
|
32
39
|
ok: boolean;
|
|
33
40
|
/** Set when parsing is intentionally partial, e.g. 'toml-minimal', 'yaml-light' */
|
|
34
41
|
caveat?: string;
|
|
42
|
+
/** Coverage note about non-obvious extractions (e.g. project-scoped servers folded in from ~/.claude.json). */
|
|
43
|
+
note?: string;
|
|
35
44
|
servers: ServerEntry[];
|
|
36
45
|
diagnostics: Diagnostic[];
|
|
37
46
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.1.
|
|
1
|
+
export const VERSION = '0.1.1';
|
package/package.json
CHANGED