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 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 **not yet scanned** (v0.1.1).
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, 45 specs — dev/test scripts need Node 22.18+ (native type stripping)
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 clients but with different launch shape. */
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 clients but with different launch shape. */
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 across ${list.length} clients`,
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
- return { ok: true, servers, diagnostics: [] };
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
- const r = f.format === 'json' ? parseJsonConfig(text, f.file, f.clientId, { json5: f.json5, json5Fallback: f.json5Fallback })
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 { clientId: f.clientId, file: f.file, format: f.format, ok: r.ok, caveat: r.caveat, servers: r.servers, diagnostics: r.diagnostics };
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 caveat = p.caveat ? ` (${p.caveat})` : '';
32
- lines.push(` ${flag} ${clientName(p.clientId)} — ${tilde(p.file)} — ${n} server(s)${caveat}`);
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
- servers: p.servers.map((s) => ({ name: s.name, command: s.command, url: s.url, transport: s.transport })),
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.0";
1
+ export declare const VERSION = "0.1.1";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.0';
1
+ export const VERSION = '0.1.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-triage",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Triage broken MCP setups across 8 agent clients — find what is wrong, fix what is fixable, escalate the rest. Zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "bin": {