mcp-wtf 0.1.0 → 0.2.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/README.md +105 -2
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +64 -12
- package/dist/client/index.js +2 -1
- package/dist/diagnose.d.ts +2 -0
- package/dist/diagnose.js +92 -16
- package/dist/discover.d.ts +11 -2
- package/dist/discover.js +120 -12
- package/dist/index.d.ts +7 -2
- package/dist/index.js +6 -2
- package/dist/logs.d.ts +63 -0
- package/dist/logs.js +315 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +62 -0
- package/dist/remote.d.ts +37 -0
- package/dist/remote.js +550 -0
- package/dist/report/terminal.js +12 -5
- package/dist/types.d.ts +9 -2
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npx mcp-wtf
|
|
|
12
12
|
|
|
13
13
|
The host says *"MCP server failed to connect"*, or *"disconnected"*, or nothing at all — the server just isn't there. There are **17,000 GitHub issues** with that exact complaint, because the host throws away the one thing that would explain it: the server's dying words.
|
|
14
14
|
|
|
15
|
-
mcp-wtf finds every MCP server configured in Claude Desktop, Claude Code, Cursor, Windsurf
|
|
15
|
+
mcp-wtf finds every MCP server configured in Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, Cline, Roo Code, Gemini CLI and Zed, actually launches each one the way the host would — or opens a real connection to it, if it is a remote server — keeps everything the host discards, and tells you exactly what is wrong, with the fix:
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
mcp-wtf 5 of 7 MCP servers are broken -- here is exactly why
|
|
@@ -39,6 +39,11 @@ mcp-wtf finds every MCP server configured in Claude Desktop, Claude Code, Cursor
|
|
|
39
39
|
fix: uv is not on the PATH this process sees. Use the absolute path to uvx
|
|
40
40
|
(run `which uvx` / `where uvx`), or install uv system-wide.
|
|
41
41
|
|
|
42
|
+
DEAD linear Cursor
|
|
43
|
+
x The endpoint returned 401 Unauthorized and points at OAuth protected-resource
|
|
44
|
+
metadata (https://mcp.linear.app/.well-known/oauth-protected-resource).
|
|
45
|
+
fix: This server wants a full OAuth flow, not a token pasted into a config.
|
|
46
|
+
|
|
42
47
|
WARN noisy Claude Desktop, 1 tool, 1ms
|
|
43
48
|
! The handshake succeeded, but the server wrote 2 non-JSON lines to stdout.
|
|
44
49
|
Some hosts survive this; others disconnect at random.
|
|
@@ -70,6 +75,65 @@ No install, no config, no account, zero dependencies.
|
|
|
70
75
|
|
|
71
76
|
**Not-actually-MCP** — the command starts an HTTP server, or the wrong entrypoint, or something that waits forever on input. Diagnosed instead of hanging.
|
|
72
77
|
|
|
78
|
+
**Remote servers** — DNS that doesn't resolve, refused connections, expired certificates, `401 Unauthorized`, OAuth-protected resources, 404 paths, an SSE URL given to a streamable-HTTP client, redirects nobody follows, and web pages pretending to be endpoints. See below.
|
|
79
|
+
|
|
80
|
+
**What already went wrong** — the failures sitting in your host's log files, classified, with the line quoted. See below.
|
|
81
|
+
|
|
82
|
+
## Remote servers
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx mcp-wtf --url https://mcp.example.com/mcp
|
|
86
|
+
npx mcp-wtf --url https://mcp.example.com/mcp --header "Authorization: Bearer $TOKEN"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A remote server gives a client exactly one HTTP response and nothing else, so the host's *"failed to connect"* covers a dozen unrelated causes. mcp-wtf performs a real MCP `initialize` over the wire and reads the status line, the headers and the body:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
DEAD https://mcp.example.com/sse command line
|
|
93
|
+
x The endpoint returned 405 Method Not Allowed to the handshake POST -- something
|
|
94
|
+
is there, but it does not accept the POST that streamable HTTP is made of.
|
|
95
|
+
fix: A GET to this same URL returns text/event-stream, so this is the older
|
|
96
|
+
HTTP+SSE transport. Configure it as an SSE server ("type": "sse") rather
|
|
97
|
+
than streamable HTTP, or ask the operator for the streamable-HTTP path.
|
|
98
|
+
HTTP 405 Method Not Allowed | content-type: text/plain
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
It separates, by name: **DNS resolution failure**, **connection refused**, **connect timeout**, **`certificate has expired`**, self-signed and untrusted-issuer certificates, hostname mismatch, `https://` pointed at a plain-HTTP port, **`401 Unauthorized`** with and without credentials, **OAuth protected-resource metadata** (`/.well-known/oauth-protected-resource` — the client has to run the OAuth flow; no token pasted into a config will do), **403 Forbidden**, **404** (wrong path — try a `/mcp` or `/sse` suffix), **405/406** (right server, wrong transport kind), **3xx redirects** (reported with the `Location`, because most MCP clients do not follow them on the handshake POST), **429**, **5xx**, **HTML pages**, JSON that isn't JSON-RPC, and JSON-RPC that isn't MCP.
|
|
102
|
+
|
|
103
|
+
And when the endpoint is fine, it says so — with the server's name and version — because that is also an answer: the problem is on the client side.
|
|
104
|
+
|
|
105
|
+
Header values are never printed. A `--header` you pass, and a token a 401 body echoes back at you, are both redacted before anything is written out.
|
|
106
|
+
|
|
107
|
+
## Log-file mode
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npx mcp-wtf --logs # find the host's logs and read them
|
|
111
|
+
npx mcp-wtf --logs ~/Library/Logs/Claude/mcp-server-github.log
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Relaunching a server explains why it is broken *now*. It cannot explain why it dropped out at 4pm yesterday, and it cannot help when the failure only happens inside the host — a different `PATH`, a different working directory, a token the GUI has and your terminal does not. The host wrote all of that down and then never showed it to anyone.
|
|
115
|
+
|
|
116
|
+
mcp-wtf reads the last 200 lines of every MCP log it can find (`%APPDATA%\Claude\logs` on Windows, `~/Library/Logs/Claude` on macOS, `~/.config/Claude/logs` on Linux), runs the same signatures over them, attributes every line to the server it names — including the interleaved shared `mcp.log` — and folds rotated files back into one entry per server:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
mcp-wtf 2 of 8 servers failed in the logs -- here is what the host wrote down
|
|
120
|
+
11 log files read
|
|
121
|
+
|
|
122
|
+
DEAD postgres Claude Desktop log
|
|
123
|
+
x The server is launched through Docker, and the Docker daemon was not running.
|
|
124
|
+
fix: Start Docker Desktop before the host, or the server dies on every launch.
|
|
125
|
+
> docker: error during connect: open //./pipe/dockerDesktopLinuxEngine: The
|
|
126
|
+
system cannot find the file specified.
|
|
127
|
+
|
|
128
|
+
DEAD mermaid Claude Desktop log
|
|
129
|
+
x The host failed to parse what the server sent on stdout -- the server is
|
|
130
|
+
printing non-JSON onto the protocol channel.
|
|
131
|
+
fix: Logs belong on stderr. On stdio transport stdout IS the protocol.
|
|
132
|
+
> [error] [mermaid] Unexpected token 'S', "STDIO MCP "... is not valid JSON
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Repeated identical findings are collapsed, the mirrored JSON-RPC traffic is skipped (tool arguments are not evidence), and *"and then it disconnected"* is dropped as soon as something explains why. Quoted lines are redacted: anything token-shaped, anything after `Authorization:`, `api_key=` and friends.
|
|
136
|
+
|
|
73
137
|
## Usage
|
|
74
138
|
|
|
75
139
|
```bash
|
|
@@ -78,14 +142,53 @@ npx mcp-wtf --server github # just one server
|
|
|
78
142
|
npx mcp-wtf --config ./mcp.json # one specific config file
|
|
79
143
|
npx mcp-wtf -- node build/index.js # a server not configured anywhere yet
|
|
80
144
|
npx mcp-wtf --url http://localhost:3000/mcp
|
|
145
|
+
npx mcp-wtf --url https://mcp.example.com/mcp --header "Authorization: Bearer $TOKEN"
|
|
146
|
+
npx mcp-wtf --logs # classify what already failed, from the host's logs
|
|
81
147
|
```
|
|
82
148
|
|
|
83
149
|
`--json` for scripts. Exit codes: `0` all healthy, `1` something is broken, `2` could not run.
|
|
84
150
|
|
|
151
|
+
## Where it looks
|
|
152
|
+
|
|
153
|
+
| Host | Config |
|
|
154
|
+
| --- | --- |
|
|
155
|
+
| Claude Desktop | `claude_desktop_config.json` |
|
|
156
|
+
| Claude Code | `~/.claude.json`, `./.mcp.json` |
|
|
157
|
+
| Cursor | `~/.cursor/mcp.json`, `./.cursor/mcp.json` |
|
|
158
|
+
| Windsurf | `~/.codeium/windsurf/mcp_config.json` |
|
|
159
|
+
| VS Code | `mcp.json` (user and workspace) |
|
|
160
|
+
| Cline | `globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json` |
|
|
161
|
+
| Roo Code | `globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json` |
|
|
162
|
+
| Gemini CLI | `~/.gemini/settings.json`, `./.gemini/settings.json` |
|
|
163
|
+
| Zed | `settings.json` (`context_servers`, including the `{path, args, env}` command form) |
|
|
164
|
+
|
|
165
|
+
All three OS layouts, and the JSONC these files are actually written in — comments and trailing commas are not a broken config, whatever `JSON.parse` thinks.
|
|
166
|
+
|
|
167
|
+
## The errors this explains
|
|
168
|
+
|
|
169
|
+
If you searched for one of these and landed here, that is the point:
|
|
170
|
+
|
|
171
|
+
`MCP server failed to connect` · `Server disconnected` · `MCP error -32000: Connection closed` ·
|
|
172
|
+
`Server transport closed unexpectedly, this is likely due to the process exiting early` ·
|
|
173
|
+
`spawn npx ENOENT` · `spawn uvx ENOENT` · `spawn docker ENOENT` ·
|
|
174
|
+
`Error: Cannot find module` · `ERR_MODULE_NOT_FOUND` · `ModuleNotFoundError: No module named` ·
|
|
175
|
+
`Unexpected token < in JSON at position 0` · `Unexpected token 'I', "[INFO]..." is not valid JSON` ·
|
|
176
|
+
`Unexpected non-whitespace character after JSON` ·
|
|
177
|
+
`401 Unauthorized MCP` · `403 Forbidden` · `WWW-Authenticate` · `oauth-protected-resource` ·
|
|
178
|
+
`405 Method Not Allowed` · `406 Not Acceptable` · `404 Not Found` ·
|
|
179
|
+
`fetch failed` · `ECONNREFUSED` · `ENOTFOUND` · `ETIMEDOUT` ·
|
|
180
|
+
`certificate has expired` · `self signed certificate in certificate chain` ·
|
|
181
|
+
`unable to verify the first certificate` · `ERR_TLS_CERT_ALTNAME_INVALID` ·
|
|
182
|
+
`EADDRINUSE: address already in use` ·
|
|
183
|
+
`docker: error during connect` · `Cannot connect to the Docker daemon` ·
|
|
184
|
+
`MCP error -32001: Request timed out` · `it works in my terminal but not in Claude`
|
|
185
|
+
|
|
85
186
|
## Safety
|
|
86
187
|
|
|
87
188
|
- mcp-wtf launches your servers exactly as configured, performs the MCP handshake, lists their tools, and shuts them down. **It never invokes a tool.**
|
|
88
|
-
- Env values from your configs are passed to the servers they belong to and are **never printed** — reports name the offending *key*, never the value.
|
|
189
|
+
- Env values from your configs are passed to the servers they belong to and are **never printed** — reports name the offending *key*, never the value. The same goes for `headers` on a remote server, in `--json` output too.
|
|
190
|
+
- Log lines and HTTP response bodies are quoted back at you **redacted**: values after `Authorization:` / `api_key=` / `*_TOKEN=`, and anything shaped like a token (`ghp_…`, `sk-…`, `xox…`, JWTs, long hex and base64 runs). A report is safe to paste into a bug report without reading it first.
|
|
191
|
+
- Nothing is uploaded anywhere. The only network traffic is to the MCP endpoints you asked it to check.
|
|
89
192
|
|
|
90
193
|
## See also
|
|
91
194
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import type { ServerSpec, WtfOptions } from './types.js';
|
|
3
|
+
interface Parsed {
|
|
4
|
+
options: WtfOptions;
|
|
5
|
+
json: boolean;
|
|
6
|
+
config?: string;
|
|
7
|
+
serverFilter: string[];
|
|
8
|
+
direct?: ServerSpec;
|
|
9
|
+
logs: boolean;
|
|
10
|
+
logFiles: string[];
|
|
11
|
+
help: boolean;
|
|
12
|
+
version: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function parseArgs(argv: string[]): Parsed;
|
|
2
16
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { discover } from './discover.js';
|
|
4
4
|
import { diagnoseAll } from './diagnose.js';
|
|
5
|
+
import { diagnoseLogs } from './logs.js';
|
|
6
|
+
import { redactSpecSecrets } from './redact.js';
|
|
5
7
|
import { renderTerminal } from './report/terminal.js';
|
|
6
|
-
|
|
8
|
+
import { VERSION } from './version.js';
|
|
7
9
|
const HELP = `
|
|
8
10
|
mcp-wtf ${VERSION}
|
|
9
11
|
Your MCP server won't connect. Find out why in 10 seconds.
|
|
@@ -13,9 +15,12 @@ const HELP = `
|
|
|
13
15
|
mcp-wtf --config <file> diagnose the servers in one config file
|
|
14
16
|
mcp-wtf --server <name> only the named server(s); repeatable
|
|
15
17
|
mcp-wtf -- <command> [...] diagnose one stdio server directly
|
|
16
|
-
mcp-wtf --url <url> diagnose one
|
|
18
|
+
mcp-wtf --url <url> diagnose one remote (HTTP/SSE) server
|
|
19
|
+
mcp-wtf --logs [file] classify failures in the host's log files
|
|
20
|
+
(auto-discovered when no file is given)
|
|
17
21
|
|
|
18
22
|
OPTIONS
|
|
23
|
+
--header "Name: value" sent with --url; repeatable
|
|
19
24
|
--json machine-readable report
|
|
20
25
|
--timeout <ms> per-server handshake timeout (default 15000)
|
|
21
26
|
--concurrency <n> servers checked at once (default 4)
|
|
@@ -30,18 +35,27 @@ const HELP = `
|
|
|
30
35
|
packages, ports already in use, rejected keys
|
|
31
36
|
the protocol handshake completes, stdout carries only JSON
|
|
32
37
|
(stdout pollution = "disconnects randomly")
|
|
38
|
+
remote endpoints DNS, refused connections, expired certificates,
|
|
39
|
+
401/403 (including OAuth-protected resources),
|
|
40
|
+
404 paths, SSE-vs-streamable mismatches,
|
|
41
|
+
redirects, and HTML pages pretending to be MCP
|
|
42
|
+
host logs the same signatures, read out of the log files
|
|
43
|
+
Claude Desktop already wrote
|
|
33
44
|
|
|
34
45
|
Exit codes: 0 all healthy, 1 something is broken, 2 could not run.
|
|
35
46
|
|
|
36
47
|
Configs searched: Claude Desktop, Claude Code (~/.claude.json, ./.mcp.json),
|
|
37
|
-
Cursor, Windsurf, VS Code
|
|
38
|
-
prints the values of env secrets
|
|
48
|
+
Cursor, Windsurf, VS Code, Cline, Roo Code, Gemini CLI, Zed. mcp-wtf never
|
|
49
|
+
invokes your tools, never prints the values of env secrets, and redacts
|
|
50
|
+
anything token-shaped out of the log lines it quotes.
|
|
39
51
|
`;
|
|
40
|
-
function parseArgs(argv) {
|
|
52
|
+
export function parseArgs(argv) {
|
|
41
53
|
const out = {
|
|
42
54
|
options: { timeoutMs: 15_000, concurrency: 4 },
|
|
43
55
|
json: false,
|
|
44
56
|
serverFilter: [],
|
|
57
|
+
logs: false,
|
|
58
|
+
logFiles: [],
|
|
45
59
|
help: false,
|
|
46
60
|
version: false,
|
|
47
61
|
};
|
|
@@ -78,6 +92,14 @@ function parseArgs(argv) {
|
|
|
78
92
|
case '--url':
|
|
79
93
|
url = next() ?? null;
|
|
80
94
|
break;
|
|
95
|
+
case '--logs': {
|
|
96
|
+
// The file is optional: `--logs` alone means "find them yourself".
|
|
97
|
+
out.logs = true;
|
|
98
|
+
const peek = argv[i + 1];
|
|
99
|
+
if (peek !== undefined && !peek.startsWith('-'))
|
|
100
|
+
out.logFiles.push(argv[++i]);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
81
103
|
case '--header': {
|
|
82
104
|
const raw = next() ?? '';
|
|
83
105
|
const idx = raw.indexOf(':');
|
|
@@ -122,6 +144,40 @@ async function main() {
|
|
|
122
144
|
process.stderr.write(`mcp-wtf: ${parsed.error}\n`);
|
|
123
145
|
process.exit(2);
|
|
124
146
|
}
|
|
147
|
+
const emit = (report) => {
|
|
148
|
+
// Whatever leaves this process is safe to paste into a bug report.
|
|
149
|
+
const safe = { ...report, diagnoses: report.diagnoses.map(redactSpecSecrets) };
|
|
150
|
+
if (parsed.json)
|
|
151
|
+
process.stdout.write(JSON.stringify(safe, null, 2) + '\n');
|
|
152
|
+
else
|
|
153
|
+
process.stdout.write(renderTerminal(safe));
|
|
154
|
+
process.exit(safe.broken > 0 || safe.configErrors.length > 0 ? 1 : 0);
|
|
155
|
+
};
|
|
156
|
+
if (parsed.logs) {
|
|
157
|
+
const missing = parsed.logFiles.filter((f) => !existsSync(f));
|
|
158
|
+
if (missing.length > 0) {
|
|
159
|
+
process.stderr.write(`mcp-wtf: no such log file: ${missing.join(', ')}\n`);
|
|
160
|
+
process.exit(2);
|
|
161
|
+
}
|
|
162
|
+
const t0 = Date.now();
|
|
163
|
+
const { diagnoses, scanned } = diagnoseLogs(parsed.logFiles);
|
|
164
|
+
if (scanned.length === 0) {
|
|
165
|
+
process.stderr.write('mcp-wtf: no MCP log files found. Claude Desktop writes them to %APPDATA%\\Claude\\logs (Windows), ~/Library/Logs/Claude (macOS) or ~/.config/Claude/logs (Linux). Point at one with `mcp-wtf --logs <file>`.\n');
|
|
166
|
+
process.exit(2);
|
|
167
|
+
}
|
|
168
|
+
emit({
|
|
169
|
+
diagnoses,
|
|
170
|
+
mode: 'logs',
|
|
171
|
+
logsScanned: scanned,
|
|
172
|
+
configsSearched: [],
|
|
173
|
+
configErrors: [],
|
|
174
|
+
healthy: diagnoses.filter((d) => d.verdict === 'healthy').length,
|
|
175
|
+
broken: diagnoses.filter((d) => d.verdict === 'broken').length,
|
|
176
|
+
warnings: diagnoses.filter((d) => d.verdict === 'warning').length,
|
|
177
|
+
durationMs: Date.now() - t0,
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
125
181
|
let specs;
|
|
126
182
|
let configsSearched = [];
|
|
127
183
|
let configErrors = [];
|
|
@@ -154,20 +210,16 @@ async function main() {
|
|
|
154
210
|
}
|
|
155
211
|
const t0 = Date.now();
|
|
156
212
|
const diagnoses = await diagnoseAll(specs, parsed.options);
|
|
157
|
-
|
|
213
|
+
emit({
|
|
158
214
|
diagnoses,
|
|
215
|
+
mode: 'config',
|
|
159
216
|
configsSearched,
|
|
160
217
|
configErrors,
|
|
161
218
|
healthy: diagnoses.filter((d) => d.verdict === 'healthy').length,
|
|
162
219
|
broken: diagnoses.filter((d) => d.verdict === 'broken').length,
|
|
163
220
|
warnings: diagnoses.filter((d) => d.verdict === 'warning').length,
|
|
164
221
|
durationMs: Date.now() - t0,
|
|
165
|
-
};
|
|
166
|
-
if (parsed.json)
|
|
167
|
-
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
168
|
-
else
|
|
169
|
-
process.stdout.write(renderTerminal(report));
|
|
170
|
-
process.exit(report.broken > 0 || configErrors.length > 0 ? 1 : 0);
|
|
222
|
+
});
|
|
171
223
|
}
|
|
172
224
|
main().catch((e) => {
|
|
173
225
|
process.stderr.write(`mcp-wtf: internal error: ${e.stack ?? String(e)}\n`);
|
package/dist/client/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { VERSION } from '../version.js';
|
|
1
2
|
export { StdioTransport } from './stdio.js';
|
|
2
3
|
export { HttpTransport } from './http.js';
|
|
3
4
|
/** Versions we will negotiate, newest first. */
|
|
@@ -25,7 +26,7 @@ export class McpClient {
|
|
|
25
26
|
const raw = await this.transport.request('initialize', {
|
|
26
27
|
protocolVersion,
|
|
27
28
|
capabilities: { roots: { listChanged: true }, sampling: {}, elicitation: {} },
|
|
28
|
-
clientInfo: { name: 'mcp-wtf', version:
|
|
29
|
+
clientInfo: { name: 'mcp-wtf', version: VERSION },
|
|
29
30
|
}, this.timeoutMs);
|
|
30
31
|
const ms = Date.now() - t0;
|
|
31
32
|
const result = (raw.result ?? {});
|
package/dist/diagnose.d.ts
CHANGED
|
@@ -7,5 +7,7 @@ import type { Diagnosis, Finding, ServerSpec, WtfOptions } from './types.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function resolveCommand(command: string, env?: NodeJS.ProcessEnv): string | null;
|
|
9
9
|
export declare function staticChecks(spec: ServerSpec): Finding[];
|
|
10
|
+
/** Exported so log-file mode can run the same signatures over host logs. */
|
|
11
|
+
export declare function classifyStderr(stderr: string[], cfg: string, command: string): Finding | null;
|
|
10
12
|
export declare function diagnoseServer(spec: ServerSpec, options: WtfOptions): Promise<Diagnosis>;
|
|
11
13
|
export declare function diagnoseAll(specs: ServerSpec[], options: WtfOptions): Promise<Diagnosis[]>;
|
package/dist/diagnose.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
-
import { McpClient, StdioTransport
|
|
3
|
+
import { McpClient, StdioTransport } from './client/index.js';
|
|
4
|
+
import { probeRemote } from './remote.js';
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Static checks: everything knowable without starting the server. Most broken
|
|
6
7
|
// setups are broken right here, and these diagnoses are exact.
|
|
@@ -51,6 +52,60 @@ function configFileOf(spec) {
|
|
|
51
52
|
const m = spec.sources[0]?.match(/\((.+)\)$/);
|
|
52
53
|
return m?.[1] ?? 'your MCP config file';
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* What can be decided about a remote server without opening a socket. The
|
|
57
|
+
* headers block is the remote equivalent of `env`: the same pasted-and-never-
|
|
58
|
+
* replaced placeholders end up in it, and the same rule applies -- name the
|
|
59
|
+
* header, never the value.
|
|
60
|
+
*/
|
|
61
|
+
function remoteStaticChecks(spec, cfg) {
|
|
62
|
+
const findings = [];
|
|
63
|
+
const url = spec.url ?? '';
|
|
64
|
+
let parsed = null;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(url);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* Reported below; `fetch` would fail with an opaque TypeError. */
|
|
70
|
+
}
|
|
71
|
+
if (!parsed) {
|
|
72
|
+
findings.push({
|
|
73
|
+
code: 'config.url_invalid',
|
|
74
|
+
severity: 'fatal',
|
|
75
|
+
message: `"${url}" is not a usable URL.`,
|
|
76
|
+
fix: `A remote MCP server needs the scheme too: "url": "https://example.com/mcp", not "example.com/mcp". (Config: ${cfg})`,
|
|
77
|
+
});
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
81
|
+
findings.push({
|
|
82
|
+
code: 'config.url_invalid',
|
|
83
|
+
severity: 'fatal',
|
|
84
|
+
message: `The URL uses the "${parsed.protocol.replace(':', '')}" scheme, which no MCP transport speaks.`,
|
|
85
|
+
fix: `Remote MCP is http:// or https:// only. Fix the "url" for this server in ${cfg}.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
for (const [key, value] of Object.entries(spec.headers ?? {})) {
|
|
89
|
+
const bare = value.replace(/^(bearer|basic|token)\s+/i, '');
|
|
90
|
+
if (PLACEHOLDER.test(bare)) {
|
|
91
|
+
findings.push({
|
|
92
|
+
code: 'header.placeholder',
|
|
93
|
+
severity: 'fatal',
|
|
94
|
+
message: `The "${key}" header is still the placeholder value from the instructions.`,
|
|
95
|
+
fix: `Put the real credential into the "headers" block of this server in ${cfg}. As configured, the server will answer 401.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
else if (SECRET_KEY.test(key) && bare.trim() === '') {
|
|
99
|
+
findings.push({
|
|
100
|
+
code: 'header.empty_secret',
|
|
101
|
+
severity: 'fatal',
|
|
102
|
+
message: `The "${key}" header is empty.`,
|
|
103
|
+
fix: `Set it in ${cfg}, or remove the header entirely so the server can answer with a proper auth challenge.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return findings;
|
|
108
|
+
}
|
|
54
109
|
export function staticChecks(spec) {
|
|
55
110
|
const findings = [];
|
|
56
111
|
const cfg = configFileOf(spec);
|
|
@@ -64,6 +119,8 @@ export function staticChecks(spec) {
|
|
|
64
119
|
return findings;
|
|
65
120
|
}
|
|
66
121
|
if (spec.kind === 'http')
|
|
122
|
+
return remoteStaticChecks(spec, cfg);
|
|
123
|
+
if (spec.kind !== 'stdio')
|
|
67
124
|
return findings;
|
|
68
125
|
const command = spec.command;
|
|
69
126
|
// "command": "npx -y some-server" -- the whole line pasted into `command`.
|
|
@@ -129,12 +186,12 @@ export function staticChecks(spec) {
|
|
|
129
186
|
/** Known stderr signatures, most specific first. */
|
|
130
187
|
const STDERR_SIGNATURES = [
|
|
131
188
|
[
|
|
132
|
-
/Cannot find module '([^']+)'|ERR_MODULE_NOT_FOUND.*?'([^']+)'/,
|
|
189
|
+
/Cannot find module '([^']+)'|ERR_MODULE_NOT_FOUND.*?'([^']+)'|ModuleNotFoundError: No module named '([^']+)'/,
|
|
133
190
|
(m, cfg) => ({
|
|
134
191
|
code: 'deps.module_missing',
|
|
135
192
|
severity: 'fatal',
|
|
136
|
-
message: `The server crashed because a module is missing: ${m[1] ?? m[2]}.`,
|
|
137
|
-
fix: `Its dependencies are not installed. If this is your own server, run npm install in its directory; if it is configured with a path into someone's repo, that checkout was never built. (Config: ${cfg})`,
|
|
193
|
+
message: `The server crashed because a module is missing: ${m[1] ?? m[2] ?? m[3]}.`,
|
|
194
|
+
fix: `Its dependencies are not installed. If this is your own server, run npm install (or pip install / uv sync) in its directory; if it is configured with a path into someone's repo, that checkout was never built. (Config: ${cfg})`,
|
|
138
195
|
}),
|
|
139
196
|
],
|
|
140
197
|
[
|
|
@@ -186,7 +243,8 @@ const STDERR_SIGNATURES = [
|
|
|
186
243
|
}),
|
|
187
244
|
],
|
|
188
245
|
];
|
|
189
|
-
|
|
246
|
+
/** Exported so log-file mode can run the same signatures over host logs. */
|
|
247
|
+
export function classifyStderr(stderr, cfg, command) {
|
|
190
248
|
const text = stderr.join('\n');
|
|
191
249
|
for (const [pattern, build] of STDERR_SIGNATURES) {
|
|
192
250
|
const m = text.match(pattern);
|
|
@@ -196,11 +254,18 @@ function classifyStderr(stderr, cfg, command) {
|
|
|
196
254
|
return null;
|
|
197
255
|
}
|
|
198
256
|
async function liveCheck(spec, options) {
|
|
257
|
+
// A remote server has no process to watch: its whole story is one HTTP
|
|
258
|
+
// response, so it gets its own reader.
|
|
259
|
+
if (spec.kind === 'http')
|
|
260
|
+
return probeRemote(spec, options);
|
|
199
261
|
const cfg = configFileOf(spec);
|
|
200
262
|
const findings = [];
|
|
201
|
-
const transport =
|
|
202
|
-
|
|
203
|
-
|
|
263
|
+
const transport = new StdioTransport({
|
|
264
|
+
command: spec.command,
|
|
265
|
+
args: spec.args ?? [],
|
|
266
|
+
env: spec.env,
|
|
267
|
+
cwd: spec.cwd,
|
|
268
|
+
});
|
|
204
269
|
const client = new McpClient(transport, options.timeoutMs);
|
|
205
270
|
const stderrTail = () => transport.stderr.slice(-12).join('\n');
|
|
206
271
|
try {
|
|
@@ -304,6 +369,16 @@ async function liveCheck(spec, options) {
|
|
|
304
369
|
return { findings, serverInfo, toolCount: tools.length, connectMs };
|
|
305
370
|
}
|
|
306
371
|
// ---------------------------------------------------------------------------
|
|
372
|
+
/**
|
|
373
|
+
* `info` findings are notes on a server that works -- "the handshake took 12s",
|
|
374
|
+
* "this endpoint is fine, so your client is not". They print, but they must not
|
|
375
|
+
* downgrade a healthy verdict, or the summary line starts lying.
|
|
376
|
+
*/
|
|
377
|
+
function verdictOf(findings) {
|
|
378
|
+
if (findings.some((f) => f.severity === 'fatal'))
|
|
379
|
+
return 'broken';
|
|
380
|
+
return findings.some((f) => f.severity === 'warn') ? 'warning' : 'healthy';
|
|
381
|
+
}
|
|
307
382
|
export async function diagnoseServer(spec, options) {
|
|
308
383
|
const findings = staticChecks(spec);
|
|
309
384
|
const fatalAlready = findings.some((f) => f.severity === 'fatal');
|
|
@@ -316,17 +391,18 @@ export async function diagnoseServer(spec, options) {
|
|
|
316
391
|
const filtered = fatalAlready ? live.findings.filter((f) => f.code !== 'spawn.failed') : live.findings;
|
|
317
392
|
findings.push(...filtered);
|
|
318
393
|
if (live.serverInfo !== undefined) {
|
|
319
|
-
|
|
320
|
-
|
|
394
|
+
return {
|
|
395
|
+
spec,
|
|
396
|
+
verdict: verdictOf(findings),
|
|
397
|
+
findings,
|
|
398
|
+
serverInfo: live.serverInfo,
|
|
399
|
+
toolCount: live.toolCount,
|
|
400
|
+
connectMs: live.connectMs,
|
|
401
|
+
};
|
|
321
402
|
}
|
|
322
403
|
}
|
|
323
404
|
}
|
|
324
|
-
|
|
325
|
-
? 'broken'
|
|
326
|
-
: findings.length > 0
|
|
327
|
-
? 'warning'
|
|
328
|
-
: 'healthy';
|
|
329
|
-
return { spec, verdict, findings };
|
|
405
|
+
return { spec, verdict: verdictOf(findings), findings };
|
|
330
406
|
}
|
|
331
407
|
export async function diagnoseAll(specs, options) {
|
|
332
408
|
const results = new Array(specs.length);
|
package/dist/discover.d.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import type { ServerSpec } from './types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Every place the well-known hosts keep their MCP configuration.
|
|
3
|
+
* Every place the well-known hosts keep their MCP configuration. Three shapes
|
|
4
4
|
* exist in the wild: `mcpServers` (Claude Desktop, Claude Code, Cursor,
|
|
5
|
-
* Windsurf)
|
|
5
|
+
* Windsurf, Gemini CLI, Cline, Roo Code), `servers` (VS Code) and
|
|
6
|
+
* `context_servers` (Zed).
|
|
6
7
|
*/
|
|
7
8
|
export declare function knownConfigPaths(platform?: NodeJS.Platform, home?: string, cwd?: string): Array<{
|
|
8
9
|
path: string;
|
|
9
10
|
host: string;
|
|
10
11
|
}>;
|
|
12
|
+
/**
|
|
13
|
+
* JSON.parse, but tolerant of what these files actually contain. VS Code's
|
|
14
|
+
* mcp.json and Zed's settings.json are JSONC -- Zed ships a default settings
|
|
15
|
+
* file that is nothing but comments -- and calling those "not valid JSON"
|
|
16
|
+
* would be a confident, wrong diagnosis. Comments and trailing commas are
|
|
17
|
+
* stripped; anything still broken is genuinely broken.
|
|
18
|
+
*/
|
|
19
|
+
export declare function parseJsonc(text: string): unknown;
|
|
11
20
|
/** Pull every server out of one config file. Returns [] when unreadable. */
|
|
12
21
|
export declare function readConfigFile(path: string, host: string): ServerSpec[];
|
|
13
22
|
/**
|