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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NeufAgents (neufagents.com)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# MCP Triage
|
|
2
|
+
|
|
3
|
+
[](https://github.com/neufagents/mcp-triage/actions/workflows/ci.yml) [](https://www.npmjs.com/package/mcp-triage) [](./LICENSE)
|
|
4
|
+
|
|
5
|
+
**Triage broken MCP setups across agent clients.** A [NeufAgents](https://neufagents.com) tool. One command scans the MCP configuration of every agent client on your machine, finds what is broken or fragile, explains it in plain English, and — where it is safe — repairs it.
|
|
6
|
+
|
|
7
|
+
Why *triage*: a triage assesses severity fast and routes the case — a free check-and-fix pass for the mechanical problems, and a precise hint (plus a human path) for setups that need surgery.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- **Scans** (8 clients): Claude Desktop · Claude Code · Codex · Cursor · VS Code · Windsurf · OpenClaw · dsh
|
|
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
|
+
- **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
|
+
- **JSON5-aware**: OpenClaw's `openclaw.json` is JSON5 (comments + trailing commas legal) and is parsed as such — no false syntax errors
|
|
15
|
+
- **CLI-first**: runs even when your client cannot start — that is exactly when you need it
|
|
16
|
+
- **Zero runtime dependencies**
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx mcp-triage # scan standard locations (all clients) + project configs in cwd
|
|
22
|
+
npx mcp-triage --json # machine-readable output
|
|
23
|
+
npx mcp-triage --file ./my-config.json
|
|
24
|
+
npx mcp-triage --fix # repair files that fail to parse (comments / trailing commas)
|
|
25
|
+
npx mcp-triage --fix --dry-run # show what --fix would do; write nothing
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Exit codes: `0` = no error findings, `1` = at least one error finding (post-fix when `--fix` is used).
|
|
29
|
+
|
|
30
|
+
### `--fix` semantics (v0.1)
|
|
31
|
+
|
|
32
|
+
- Only files that **fail to parse** are fix candidates; healthy files are never rewritten.
|
|
33
|
+
- Repairs are mechanical deletions only (comments, trailing commas). A repaired copy must parse as JSON or **nothing is written**.
|
|
34
|
+
- Before the first write the original is saved as `<file>.mcp-triage.bak` (an existing backup is kept, never overwritten — so the pristine version survives repeated runs).
|
|
35
|
+
|
|
36
|
+
## Example output
|
|
37
|
+
|
|
38
|
+
A demo machine with three clients — one broken JSON, one unrunnable command, one healthy config:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
$ npx mcp-triage scan
|
|
42
|
+
|
|
43
|
+
MCP Triage v0.1.0 — scanned 3 config file(s)
|
|
44
|
+
|
|
45
|
+
✗ Claude Desktop — ~/AppData/Roaming/Claude/claude_desktop_config.json — 0 server(s)
|
|
46
|
+
✓ Codex — ~/.codex/config.toml — 2 server(s) (toml-minimal)
|
|
47
|
+
✓ Cursor — ~/.cursor/mcp.json — 1 server(s)
|
|
48
|
+
|
|
49
|
+
Findings (4):
|
|
50
|
+
[ERROR] config.syntax — Claude Desktop: Trailing comma breaks JSON parsing
|
|
51
|
+
line 5: "args": ["-y", "@modelcontextprotocol/server-memory"],
|
|
52
|
+
→ Remove the comma before the closing bracket/brace, then restart the client.
|
|
53
|
+
[ERROR] server.command-unresolvable — Codex · "notes-mcp": command "my-notes-mcp" not found on PATH
|
|
54
|
+
→ This is the #1 cause of "server silently missing" bugs. Common causes: nvm-managed node (the client does not load your shell profile), missing pnpm/uv, or a typo. Use an absolute path or install the runtime the client can see.
|
|
55
|
+
[WARN ] server.relative-path-arg — Codex · "notes-mcp": Relative path argument "./notes" may resolve from the wrong directory
|
|
56
|
+
→ Clients spawn servers from their own working directory. Use an absolute path to make this stable.
|
|
57
|
+
[INFO ] config.cross-client-drift — Codex · "filesystem": Server "filesystem" is configured differently across 2 clients
|
|
58
|
+
codex → ~/.codex/config.toml
|
|
59
|
+
cursor → ~/.cursor/mcp.json
|
|
60
|
+
→ Drift is not always wrong — but when one client works and another does not, this is where to look.
|
|
61
|
+
|
|
62
|
+
Summary: 2 error(s), 1 warning(s), 1 info — 3 server(s) across 3 file(s).
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--fix` takes care of the mechanical class — and nothing else:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
$ npx mcp-triage --fix
|
|
69
|
+
|
|
70
|
+
Fix results:
|
|
71
|
+
[FIXED] Claude Desktop — ~/AppData/Roaming/Claude/claude_desktop_config.json: removed 1 trailing comma
|
|
72
|
+
→ backup: ~/AppData/Roaming/Claude/claude_desktop_config.json.mcp-triage.bak
|
|
73
|
+
|
|
74
|
+
Summary: 1 error(s), 1 warning(s), 1 info — 4 server(s) across 3 file(s).
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
*Sample output from a demo machine; home paths shortened for readability. `--fix --dry-run` prints the same report with `[DRY]` instead of `[FIXED]`, and writes nothing.*
|
|
78
|
+
|
|
79
|
+
## Coverage notes & known limitations (v0.1)
|
|
80
|
+
|
|
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
|
+
- 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).
|
|
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
|
+
|
|
86
|
+
## When `--fix` is not enough
|
|
87
|
+
|
|
88
|
+
`--fix` covers the mechanical class — for free. For everything else (a client that still refuses to start after a clean scan, a setup you want hardened before it breaks, a migration across machines), [NeufAgents](https://neufagents.com) offers a paid fix service: send your triage report to `hi@neufagents.com` and you get a written scope before any work starts. Fully async, no calls.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npm install
|
|
94
|
+
npm test # node:test, 45 specs — dev/test scripts need Node 22.18+ (native type stripping)
|
|
95
|
+
npm run build # tsc → dist/
|
|
96
|
+
node src/cli.ts scan
|
|
97
|
+
node src/cli.ts scan --fix --dry-run
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The dev and test scripts import `.ts` files directly, so they need Node 22.18+. The published package
|
|
101
|
+
itself supports Node 20+ (`engines`) and its `scan` / `--fix` flows are smoke-tested on Node 20.19.
|
|
102
|
+
|
|
103
|
+
CI runs the full spec suite on Node 22 and a build + `--version` smoke on Node 20 on every push and pull request.
|
|
104
|
+
|
|
105
|
+
The package is ESM with zero runtime dependencies; `src/index.ts` is the library entry
|
|
106
|
+
(`import { discoverFiles, parseConfigFile, runChecks, applyFixes } from 'mcp-triage'`),
|
|
107
|
+
`src/cli.ts` is the `mcp-triage` binary. `prepack` builds `dist/`; `prepublishOnly` runs the specs.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT © NeufAgents (neufagents.com)
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Diagnostic, ParsedConfig } from './types.ts';
|
|
2
|
+
export interface CheckContext {
|
|
3
|
+
env: NodeJS.ProcessEnv;
|
|
4
|
+
platform: NodeJS.Platform;
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_CHECK_CONTEXT: CheckContext;
|
|
7
|
+
/** Resolve a bare command name on PATH (or verify an absolute one). Returns resolved path or null. */
|
|
8
|
+
export declare function resolveCommandOnPath(cmd: string, ctx: CheckContext): string | null;
|
|
9
|
+
export declare function findEnvRefs(s: string): string[];
|
|
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. */
|
|
12
|
+
export declare function checkCrossClientDrift(parsed: ParsedConfig[]): Diagnostic[];
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Checks engine: takes parsed configs, returns diagnostics.
|
|
2
|
+
// v0.1 built-ins: command presence, command resolvability on PATH, ${VAR} env refs, relative-path args, insecure http url.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
export const DEFAULT_CHECK_CONTEXT = { env: process.env, platform: process.platform };
|
|
6
|
+
function isFileExecutable(p) {
|
|
7
|
+
try {
|
|
8
|
+
const st = fs.statSync(p);
|
|
9
|
+
if (!st.isFile())
|
|
10
|
+
return false;
|
|
11
|
+
if (process.platform !== 'win32') {
|
|
12
|
+
// any execute bit
|
|
13
|
+
return (st.mode & 0o111) !== 0;
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Resolve a bare command name on PATH (or verify an absolute one). Returns resolved path or null. */
|
|
22
|
+
export function resolveCommandOnPath(cmd, ctx) {
|
|
23
|
+
const hasSep = cmd.includes('/') || cmd.includes('\\');
|
|
24
|
+
if (hasSep)
|
|
25
|
+
return isFileExecutable(cmd) ? cmd : null;
|
|
26
|
+
const pathVar = ctx.env.PATH ?? ctx.env.Path ?? '';
|
|
27
|
+
const dirs = pathVar.split(path.delimiter).filter(Boolean);
|
|
28
|
+
const exts = ctx.platform === 'win32'
|
|
29
|
+
? (ctx.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((e) => e.toLowerCase())
|
|
30
|
+
: [''];
|
|
31
|
+
for (const dir of dirs) {
|
|
32
|
+
for (const ext of exts) {
|
|
33
|
+
const cand = path.join(dir, cmd + ext);
|
|
34
|
+
if (isFileExecutable(cand))
|
|
35
|
+
return cand;
|
|
36
|
+
}
|
|
37
|
+
// Windows: command may already carry its extension
|
|
38
|
+
const direct = path.join(dir, cmd);
|
|
39
|
+
if (isFileExecutable(direct))
|
|
40
|
+
return direct;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const ENV_REF_RE = /\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}|process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
45
|
+
export function findEnvRefs(s) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const m of s.matchAll(ENV_REF_RE))
|
|
48
|
+
out.push((m[1] ?? m[2]));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function stringy(s) {
|
|
52
|
+
const parts = [];
|
|
53
|
+
if (s.command)
|
|
54
|
+
parts.push(s.command);
|
|
55
|
+
if (s.args)
|
|
56
|
+
parts.push(...s.args);
|
|
57
|
+
if (s.url)
|
|
58
|
+
parts.push(s.url);
|
|
59
|
+
if (s.env)
|
|
60
|
+
for (const v of Object.values(s.env))
|
|
61
|
+
if (v)
|
|
62
|
+
parts.push(v);
|
|
63
|
+
if (s.cwd)
|
|
64
|
+
parts.push(s.cwd);
|
|
65
|
+
return parts;
|
|
66
|
+
}
|
|
67
|
+
function checkServer(clientId, file, s, ctx) {
|
|
68
|
+
const diags = [];
|
|
69
|
+
const base = { clientId, file, serverName: s.name };
|
|
70
|
+
if (s.enabled === false) {
|
|
71
|
+
diags.push({
|
|
72
|
+
checkId: 'server.disabled',
|
|
73
|
+
severity: 'info',
|
|
74
|
+
title: 'Server is disabled (enabled: false) — runtime checks skipped',
|
|
75
|
+
...base,
|
|
76
|
+
hint: 'OpenClaw keeps disabled definitions without connecting them. Remove `enabled: false` to activate.',
|
|
77
|
+
});
|
|
78
|
+
return diags;
|
|
79
|
+
}
|
|
80
|
+
if (!s.command && !s.url) {
|
|
81
|
+
diags.push({
|
|
82
|
+
checkId: 'server.command-missing',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
title: 'Server has neither "command" nor "url" — it cannot start',
|
|
85
|
+
...base,
|
|
86
|
+
hint: 'Add a command (stdio) or a url (remote). This entry is inert as written.',
|
|
87
|
+
});
|
|
88
|
+
return diags;
|
|
89
|
+
}
|
|
90
|
+
const KNOWN_TRANSPORTS = ['stdio', 'streamable-http', 'sse', 'http'];
|
|
91
|
+
const t = s.transport?.toLowerCase();
|
|
92
|
+
if (t && !KNOWN_TRANSPORTS.includes(t)) {
|
|
93
|
+
diags.push({
|
|
94
|
+
checkId: 'server.transport-unknown',
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
title: `Unknown transport "${s.transport}"`,
|
|
97
|
+
...base,
|
|
98
|
+
hint: 'Expected one of: stdio, streamable-http, sse, http. The client may reject the entry or fall back to a default.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (t === 'stdio' && s.command === undefined) {
|
|
102
|
+
diags.push({
|
|
103
|
+
checkId: 'server.stdio-command-missing',
|
|
104
|
+
severity: 'error',
|
|
105
|
+
title: 'transport is "stdio" but no command is set',
|
|
106
|
+
...base,
|
|
107
|
+
hint: 'stdio servers must name the executable to spawn (e.g. command: npx). Add it, or switch to an http transport with a url.',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (t && ['streamable-http', 'sse', 'http'].includes(t) && s.url === undefined) {
|
|
111
|
+
diags.push({
|
|
112
|
+
checkId: 'server.http-url-missing',
|
|
113
|
+
severity: 'error',
|
|
114
|
+
title: `transport is "${s.transport}" but no url is set`,
|
|
115
|
+
...base,
|
|
116
|
+
hint: 'HTTP transports must point at an endpoint (url: https://.../mcp). Add it, or use a stdio command instead.',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (s.command) {
|
|
120
|
+
const resolved = resolveCommandOnPath(s.command, ctx);
|
|
121
|
+
if (!resolved) {
|
|
122
|
+
diags.push({
|
|
123
|
+
checkId: 'server.command-unresolvable',
|
|
124
|
+
severity: 'error',
|
|
125
|
+
title: `command "${s.command}" not found on PATH`,
|
|
126
|
+
...base,
|
|
127
|
+
hint: 'This is the #1 cause of "server silently missing" bugs. Common causes: nvm-managed node (the client does not load your shell profile), missing pnpm/uv, or a typo. Use an absolute path or install the runtime the client can see.',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (s.url && /^http:\/\//i.test(s.url)) {
|
|
132
|
+
diags.push({
|
|
133
|
+
checkId: 'server.url-insecure',
|
|
134
|
+
severity: 'warning',
|
|
135
|
+
title: 'Remote server uses plain http://',
|
|
136
|
+
...base,
|
|
137
|
+
detail: s.url,
|
|
138
|
+
hint: 'Prefer https:// unless this is a deliberate localhost setup.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (s.args) {
|
|
142
|
+
for (const a of s.args) {
|
|
143
|
+
if (a.startsWith('./') || a.startsWith('../')) {
|
|
144
|
+
diags.push({
|
|
145
|
+
checkId: 'server.relative-path-arg',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
title: `Relative path argument "${a}" may resolve from the wrong directory`,
|
|
148
|
+
...base,
|
|
149
|
+
hint: 'Clients spawn servers from their own working directory. Use an absolute path to make this stable.',
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const missing = new Set();
|
|
156
|
+
for (const part of stringy(s)) {
|
|
157
|
+
for (const ref of findEnvRefs(part)) {
|
|
158
|
+
if (!ctx.env[ref])
|
|
159
|
+
missing.add(ref);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (missing.size > 0) {
|
|
163
|
+
diags.push({
|
|
164
|
+
checkId: 'server.env-ref-missing',
|
|
165
|
+
severity: 'warning',
|
|
166
|
+
title: `Referenced env var${missing.size > 1 ? 's' : ''} not set: ${[...missing].join(', ')}`,
|
|
167
|
+
...base,
|
|
168
|
+
hint: 'Set it in your shell AND make sure the client process can see it (GUI apps often do not inherit shell env).',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return diags;
|
|
172
|
+
}
|
|
173
|
+
export function runChecks(parsed, ctx = DEFAULT_CHECK_CONTEXT) {
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const p of parsed) {
|
|
176
|
+
for (const s of p.servers)
|
|
177
|
+
out.push(...checkServer(p.clientId, p.file, s, ctx));
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/** Cross-file drift: same server name present in multiple clients but with different launch shape. */
|
|
182
|
+
export function checkCrossClientDrift(parsed) {
|
|
183
|
+
const byName = new Map();
|
|
184
|
+
for (const p of parsed) {
|
|
185
|
+
for (const s of p.servers) {
|
|
186
|
+
const shape = JSON.stringify([s.command ?? s.url ?? '', s.args ?? []]);
|
|
187
|
+
const list = byName.get(s.name) ?? [];
|
|
188
|
+
list.push({ file: p.file, clientId: p.clientId, shape });
|
|
189
|
+
byName.set(s.name, list);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const [name, list] of byName) {
|
|
194
|
+
if (list.length < 2)
|
|
195
|
+
continue;
|
|
196
|
+
const shapes = new Set(list.map((l) => l.shape));
|
|
197
|
+
if (shapes.size > 1) {
|
|
198
|
+
out.push({
|
|
199
|
+
checkId: 'config.cross-client-drift',
|
|
200
|
+
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
|
+
hint: 'Drift is not always wrong — but when one client works and another does not, this is where to look.',
|
|
204
|
+
clientId: list[0].clientId,
|
|
205
|
+
file: list[0].file,
|
|
206
|
+
serverName: name,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mcp-triage CLI — v0.1
|
|
3
|
+
// Usage: mcp-triage [scan] [--file <path>] [--cwd <dir>] [--json] [--fix [--dry-run]] [--version] [--help]
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { discoverFiles } from "./discover.js";
|
|
6
|
+
import { parseConfigFile } from "./parse.js";
|
|
7
|
+
import { runChecks, checkCrossClientDrift, DEFAULT_CHECK_CONTEXT } from "./checks.js";
|
|
8
|
+
import { applyFixes } from "./fix.js";
|
|
9
|
+
import { renderHuman, renderJson } from "./report.js";
|
|
10
|
+
import { VERSION } from "./version.js";
|
|
11
|
+
function help() {
|
|
12
|
+
return `mcp-triage v${VERSION} — triage broken MCP setups across agent clients
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
mcp-triage [scan] Scan standard config locations (all clients) + current dir project configs
|
|
16
|
+
mcp-triage scan --file <path> Scan a single config file
|
|
17
|
+
mcp-triage scan --cwd <dir> Also check project-level configs relative to <dir>
|
|
18
|
+
mcp-triage scan --json Machine-readable output
|
|
19
|
+
|
|
20
|
+
Fix mode (opt-in):
|
|
21
|
+
mcp-triage scan --fix Repair config files that fail to parse (mechanical repairs only:
|
|
22
|
+
JSON comments / trailing commas). A .mcp-triage.bak backup is
|
|
23
|
+
written before any change, and nothing is written unless the
|
|
24
|
+
repaired copy parses cleanly.
|
|
25
|
+
mcp-triage scan --fix --dry-run Show what --fix would do; write nothing.
|
|
26
|
+
|
|
27
|
+
Exit codes: 0 = no error findings, 1 = at least one error finding (post-fix when --fix is used).
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
function guessFormat(file) {
|
|
31
|
+
const ext = path.extname(file).toLowerCase();
|
|
32
|
+
if (ext === '.toml')
|
|
33
|
+
return 'toml';
|
|
34
|
+
if (ext === '.yml' || ext === '.yaml')
|
|
35
|
+
return 'yaml';
|
|
36
|
+
return 'json';
|
|
37
|
+
}
|
|
38
|
+
function collectDiagnostics(parsed) {
|
|
39
|
+
return [
|
|
40
|
+
...parsed.flatMap((p) => p.diagnostics),
|
|
41
|
+
...runChecks(parsed, DEFAULT_CHECK_CONTEXT),
|
|
42
|
+
...checkCrossClientDrift(parsed),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
const argv = process.argv.slice(2);
|
|
46
|
+
if (argv.includes('--version') || argv.includes('-V')) {
|
|
47
|
+
console.log(VERSION);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
51
|
+
console.log(help());
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
const json = argv.includes('--json');
|
|
55
|
+
const fix = argv.includes('--fix');
|
|
56
|
+
const dryRun = argv.includes('--dry-run');
|
|
57
|
+
let cwd = process.cwd();
|
|
58
|
+
const cwdIdx = argv.indexOf('--cwd');
|
|
59
|
+
if (cwdIdx >= 0 && argv[cwdIdx + 1])
|
|
60
|
+
cwd = path.resolve(argv[cwdIdx + 1]);
|
|
61
|
+
const fileIdx = argv.indexOf('--file');
|
|
62
|
+
let files;
|
|
63
|
+
if (fileIdx >= 0 && argv[fileIdx + 1]) {
|
|
64
|
+
const f = path.resolve(argv[fileIdx + 1]);
|
|
65
|
+
files = [{ clientId: 'custom', file: f, format: guessFormat(f), scope: 'project', json5Fallback: true }];
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
files = discoverFiles(cwd);
|
|
69
|
+
}
|
|
70
|
+
let parsed = files.map(parseConfigFile);
|
|
71
|
+
let diagnostics = collectDiagnostics(parsed);
|
|
72
|
+
let fixes;
|
|
73
|
+
if (fix) {
|
|
74
|
+
fixes = applyFixes(files, parsed, { dryRun });
|
|
75
|
+
if (fixes.some((r) => r.status === 'fixed')) {
|
|
76
|
+
parsed = files.map(parseConfigFile); // re-read what is now on disk
|
|
77
|
+
diagnostics = collectDiagnostics(parsed);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const input = { files, parsed, diagnostics };
|
|
81
|
+
console.log(json ? renderJson(input, VERSION, fixes) : renderHuman(input, VERSION, fixes));
|
|
82
|
+
if (diagnostics.some((d) => d.severity === 'error'))
|
|
83
|
+
process.exitCode = 1;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ClientSpec } from './types.ts';
|
|
2
|
+
export declare const CLIENTS: ClientSpec[];
|
|
3
|
+
export interface PathContext {
|
|
4
|
+
platform: NodeJS.Platform;
|
|
5
|
+
home: string;
|
|
6
|
+
appdata: string;
|
|
7
|
+
configDir: string;
|
|
8
|
+
/** Process env (used for path overrides like OPENCLAW_CONFIG_PATH). Optional for tests. */
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}
|
|
11
|
+
export declare function defaultPathContext(): PathContext;
|
|
12
|
+
export declare function expandPlaceholders(p: string, ctx: PathContext): string;
|
|
13
|
+
export declare function clientPathsForPlatform(spec: ClientSpec, ctx: PathContext): string[];
|
package/dist/clients.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Client registry: 8 clients x config locations.
|
|
2
|
+
// Path verification round (2026-09-19, night cycle): all 8 verified against official docs
|
|
3
|
+
// and/or this machine. Sources per entry. `verify: true` marks entries NOT yet verified.
|
|
4
|
+
//
|
|
5
|
+
// Sources:
|
|
6
|
+
// - VS Code: code.visualstudio.com/docs/agents/reference/mcp-configuration (mcp.json; user profile via
|
|
7
|
+
// "MCP: Open User Configuration"; workspace .vscode/mcp.json; servers key). Remote/WSL path
|
|
8
|
+
// ~/.vscode-server/data/User/mcp.json confirmed via StackOverflow 79706687 + microsoft/vscode#256546.
|
|
9
|
+
// - OpenClaw: docs.openclaw.ai/gateway/configuration (JSON5 config at ~/.openclaw/openclaw.json,
|
|
10
|
+
// path overridable via OPENCLAW_CONFIG_PATH) + docs.openclaw.ai/tools/mcp (mcp.servers map).
|
|
11
|
+
// - dsh: machine-verified 2026-09-19 (~/.dsh/profiles/{headless,web}/cordis{,.patch}.yml exist locally)
|
|
12
|
+
// + @deepseek-ai/dsh-mcp-client@0.1.5-rc.2 README (patch entry: name + config.{serverName,transport,...}).
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
export const CLIENTS = [
|
|
16
|
+
{
|
|
17
|
+
id: 'claude-desktop',
|
|
18
|
+
name: 'Claude Desktop',
|
|
19
|
+
format: 'json',
|
|
20
|
+
paths: {
|
|
21
|
+
win32: ['<appdata>/Claude/claude_desktop_config.json'],
|
|
22
|
+
darwin: ['<home>/Library/Application Support/Claude/claude_desktop_config.json'],
|
|
23
|
+
linux: ['<config>/Claude/claude_desktop_config.json'],
|
|
24
|
+
},
|
|
25
|
+
serversHint: 'mcpServers',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'claude-code',
|
|
29
|
+
name: 'Claude Code',
|
|
30
|
+
format: 'json',
|
|
31
|
+
paths: {
|
|
32
|
+
any: ['<home>/.claude.json'],
|
|
33
|
+
},
|
|
34
|
+
projectPaths: ['.mcp.json'],
|
|
35
|
+
serversHint: 'mcpServers (global in ~/.claude.json; project in .mcp.json)',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'codex',
|
|
39
|
+
name: 'Codex',
|
|
40
|
+
format: 'toml',
|
|
41
|
+
paths: {
|
|
42
|
+
any: ['<home>/.codex/config.toml'],
|
|
43
|
+
},
|
|
44
|
+
serversHint: '[mcp_servers.<name>] tables',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'cursor',
|
|
48
|
+
name: 'Cursor',
|
|
49
|
+
format: 'json',
|
|
50
|
+
paths: {
|
|
51
|
+
any: ['<home>/.cursor/mcp.json'],
|
|
52
|
+
},
|
|
53
|
+
projectPaths: ['.cursor/mcp.json'],
|
|
54
|
+
serversHint: 'mcpServers',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'vscode',
|
|
58
|
+
name: 'VS Code',
|
|
59
|
+
format: 'json',
|
|
60
|
+
paths: {
|
|
61
|
+
win32: ['<appdata>/Code/User/mcp.json'],
|
|
62
|
+
// Remote/WSL sessions keep the user config on the server side (~/.vscode-server/data/User/).
|
|
63
|
+
darwin: ['<home>/Library/Application Support/Code/User/mcp.json', '<home>/.vscode-server/data/User/mcp.json'],
|
|
64
|
+
linux: ['<config>/Code/User/mcp.json', '<home>/.vscode-server/data/User/mcp.json'],
|
|
65
|
+
},
|
|
66
|
+
projectPaths: ['.vscode/mcp.json'],
|
|
67
|
+
serversHint: 'servers',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'windsurf',
|
|
71
|
+
name: 'Windsurf',
|
|
72
|
+
format: 'json',
|
|
73
|
+
paths: {
|
|
74
|
+
any: ['<home>/.codeium/windsurf/mcp_config.json'],
|
|
75
|
+
},
|
|
76
|
+
serversHint: 'mcpServers',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'openclaw',
|
|
80
|
+
name: 'OpenClaw',
|
|
81
|
+
format: 'json',
|
|
82
|
+
json5: true,
|
|
83
|
+
envOverride: 'OPENCLAW_CONFIG_PATH',
|
|
84
|
+
paths: {
|
|
85
|
+
any: ['<home>/.openclaw/openclaw.json', '<config>/openclaw/config.json'],
|
|
86
|
+
},
|
|
87
|
+
serversHint: 'mcp.servers (older guides may show mcpServers)',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'dsh',
|
|
91
|
+
name: 'dsh (DeepSeek Harness)',
|
|
92
|
+
format: 'yaml',
|
|
93
|
+
paths: {
|
|
94
|
+
any: ['<home>/.dsh/profiles/*/cordis.patch.yml', '<home>/.dsh/profiles/*/cordis.yml'],
|
|
95
|
+
},
|
|
96
|
+
serversHint: '@deepseek-ai/dsh-mcp-client patch entries (config.serverName/transport/command/args/env)',
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
export function defaultPathContext() {
|
|
100
|
+
const home = os.homedir();
|
|
101
|
+
return {
|
|
102
|
+
platform: process.platform,
|
|
103
|
+
home,
|
|
104
|
+
appdata: process.env.APPDATA ?? path.join(home, 'AppData', 'Roaming'),
|
|
105
|
+
configDir: path.join(home, '.config'),
|
|
106
|
+
env: process.env,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function expandPlaceholders(p, ctx) {
|
|
110
|
+
return p.replace(/<home>/g, ctx.home).replace(/<appdata>/g, ctx.appdata).replace(/<config>/g, ctx.configDir);
|
|
111
|
+
}
|
|
112
|
+
export function clientPathsForPlatform(spec, ctx) {
|
|
113
|
+
const plat = spec.paths[ctx.platform] ?? [];
|
|
114
|
+
const any = spec.paths.any ?? [];
|
|
115
|
+
return [...plat, ...any].map((p) => expandPlaceholders(p, ctx));
|
|
116
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type PathContext } from './clients.ts';
|
|
2
|
+
import type { DiscoveredFile } from './types.ts';
|
|
3
|
+
/** Expand a pattern containing at most one '*' path segment. Returns [] when nothing exists. */
|
|
4
|
+
export declare function expandGlob(pattern: string): string[];
|
|
5
|
+
export declare function discoverFiles(cwd: string, ctx?: PathContext): DiscoveredFile[];
|
package/dist/discover.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Discovery: expand client paths (placeholders + one '*' segment), keep files that exist.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CLIENTS, clientPathsForPlatform, defaultPathContext } from "./clients.js";
|
|
5
|
+
function isFile(p) {
|
|
6
|
+
try {
|
|
7
|
+
return fs.statSync(p).isFile();
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Expand a pattern containing at most one '*' path segment. Returns [] when nothing exists. */
|
|
14
|
+
export function expandGlob(pattern) {
|
|
15
|
+
if (!pattern.includes('*'))
|
|
16
|
+
return [pattern];
|
|
17
|
+
const norm = pattern.split('/');
|
|
18
|
+
const starIdx = norm.findIndex((seg) => seg.includes('*'));
|
|
19
|
+
if (starIdx === -1)
|
|
20
|
+
return [pattern];
|
|
21
|
+
const seg = norm[starIdx];
|
|
22
|
+
if (seg !== '*')
|
|
23
|
+
return [pattern]; // only plain single-star segments supported in v0.1
|
|
24
|
+
const parent = norm.slice(0, starIdx).join('/');
|
|
25
|
+
const rest = norm.slice(starIdx + 1);
|
|
26
|
+
let entries = [];
|
|
27
|
+
try {
|
|
28
|
+
entries = fs.readdirSync(parent);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const e of entries) {
|
|
35
|
+
const candidate = [parent, e, ...rest].join('/');
|
|
36
|
+
if (isFile(candidate))
|
|
37
|
+
out.push(candidate);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export function discoverFiles(cwd, ctx = defaultPathContext()) {
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const out = [];
|
|
44
|
+
const push = (f) => {
|
|
45
|
+
const key = f.file.toLowerCase();
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
return;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
out.push(f);
|
|
50
|
+
};
|
|
51
|
+
for (const spec of CLIENTS) {
|
|
52
|
+
for (const p of clientPathsForPlatform(spec, ctx)) {
|
|
53
|
+
for (const f of expandGlob(p)) {
|
|
54
|
+
if (isFile(f))
|
|
55
|
+
push({ clientId: spec.id, file: f, format: spec.format, scope: 'global', json5: spec.json5 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (spec.envOverride && ctx.env?.[spec.envOverride]) {
|
|
59
|
+
const f = path.resolve(ctx.env[spec.envOverride]);
|
|
60
|
+
if (isFile(f))
|
|
61
|
+
push({ clientId: spec.id, file: f, format: spec.format, scope: 'global', json5: spec.json5 });
|
|
62
|
+
}
|
|
63
|
+
for (const rel of spec.projectPaths ?? []) {
|
|
64
|
+
const f = path.join(cwd, rel);
|
|
65
|
+
if (isFile(f))
|
|
66
|
+
push({ clientId: spec.id, file: f, format: spec.format, scope: 'project', json5: spec.json5 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|