mcp-wtf 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 +101 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/client/http.d.ts +37 -0
- package/dist/client/http.js +133 -0
- package/dist/client/index.d.ts +47 -0
- package/dist/client/index.js +79 -0
- package/dist/client/jsonrpc.d.ts +40 -0
- package/dist/client/jsonrpc.js +28 -0
- package/dist/client/stdio.d.ts +55 -0
- package/dist/client/stdio.js +213 -0
- package/dist/client/transport.d.ts +21 -0
- package/dist/client/transport.js +1 -0
- package/dist/diagnose.d.ts +11 -0
- package/dist/diagnose.js +344 -0
- package/dist/discover.d.ts +26 -0
- package/dist/discover.js +135 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +92 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Transport } from './transport.js';
|
|
2
|
+
import { type JsonRpcResponse } from './jsonrpc.js';
|
|
3
|
+
export interface StdioOptions {
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Newline-delimited JSON-RPC over a child process's stdio.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately hand-rolled rather than built on the official SDK: the SDK
|
|
13
|
+
* discards anything it cannot parse, and the unparseable bytes are exactly
|
|
14
|
+
* what we are here to find. A single stray `console.log` in a server puts a
|
|
15
|
+
* non-JSON line on stdout, which corrupts the stream for every client that
|
|
16
|
+
* connects to it -- and the server author never sees an error.
|
|
17
|
+
*/
|
|
18
|
+
export declare class StdioTransport implements Transport {
|
|
19
|
+
private opts;
|
|
20
|
+
readonly kind = "stdio";
|
|
21
|
+
readonly target: string;
|
|
22
|
+
private child;
|
|
23
|
+
private buffer;
|
|
24
|
+
private nextId;
|
|
25
|
+
private pending;
|
|
26
|
+
private exited;
|
|
27
|
+
/** stdout lines that were not valid JSON. Almost always a logging bug. */
|
|
28
|
+
readonly stdoutNoise: string[];
|
|
29
|
+
readonly stderr: string[];
|
|
30
|
+
/** Pipe faults (EPIPE and friends) seen after the child went away. */
|
|
31
|
+
readonly pipeErrors: string[];
|
|
32
|
+
/** Notifications the server pushed at us, kept for later assertions. */
|
|
33
|
+
readonly serverNotifications: JsonRpcResponse[];
|
|
34
|
+
constructor(opts: StdioOptions);
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
private onStdout;
|
|
37
|
+
private consumeLine;
|
|
38
|
+
request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
39
|
+
/** Send a request with a caller-chosen id/shape, for malformed-input probes. */
|
|
40
|
+
requestRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
41
|
+
private send;
|
|
42
|
+
notify(method: string, params?: unknown): void;
|
|
43
|
+
/**
|
|
44
|
+
* Write bytes verbatim. Used to test how the server handles garbage, and the
|
|
45
|
+
* single choke point for every unsolicited write, so a dead pipe is handled
|
|
46
|
+
* in exactly one place.
|
|
47
|
+
*/
|
|
48
|
+
writeRaw(text: string): void;
|
|
49
|
+
isAlive(): boolean;
|
|
50
|
+
exitInfo(): {
|
|
51
|
+
code: number | null;
|
|
52
|
+
signal: string | null;
|
|
53
|
+
} | null;
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { TimeoutError, TransportClosedError } from './jsonrpc.js';
|
|
3
|
+
/**
|
|
4
|
+
* Newline-delimited JSON-RPC over a child process's stdio.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately hand-rolled rather than built on the official SDK: the SDK
|
|
7
|
+
* discards anything it cannot parse, and the unparseable bytes are exactly
|
|
8
|
+
* what we are here to find. A single stray `console.log` in a server puts a
|
|
9
|
+
* non-JSON line on stdout, which corrupts the stream for every client that
|
|
10
|
+
* connects to it -- and the server author never sees an error.
|
|
11
|
+
*/
|
|
12
|
+
export class StdioTransport {
|
|
13
|
+
opts;
|
|
14
|
+
kind = 'stdio';
|
|
15
|
+
target;
|
|
16
|
+
child = null;
|
|
17
|
+
buffer = '';
|
|
18
|
+
nextId = 1;
|
|
19
|
+
pending = new Map();
|
|
20
|
+
exited = null;
|
|
21
|
+
/** stdout lines that were not valid JSON. Almost always a logging bug. */
|
|
22
|
+
stdoutNoise = [];
|
|
23
|
+
stderr = [];
|
|
24
|
+
/** Pipe faults (EPIPE and friends) seen after the child went away. */
|
|
25
|
+
pipeErrors = [];
|
|
26
|
+
/** Notifications the server pushed at us, kept for later assertions. */
|
|
27
|
+
serverNotifications = [];
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
this.target = [opts.command, ...opts.args].join(' ');
|
|
31
|
+
}
|
|
32
|
+
async start() {
|
|
33
|
+
// On Windows, a bare command name often resolves to a .cmd shim -- npx,
|
|
34
|
+
// pnpm, yarn all do -- and CreateProcess cannot execute those directly, so
|
|
35
|
+
// they need a shell. A path to a real executable must NOT go through the
|
|
36
|
+
// shell, because cmd.exe splits it on spaces and
|
|
37
|
+
// "C:\Program Files\nodejs\node.exe" becomes "C:\Program".
|
|
38
|
+
const isWin = process.platform === 'win32';
|
|
39
|
+
const hasPathSeparator = /[\\/]/.test(this.opts.command);
|
|
40
|
+
const isExe = /\.(exe|com)$/i.test(this.opts.command);
|
|
41
|
+
const useShell = isWin && !hasPathSeparator && !isExe;
|
|
42
|
+
// When the shell is in play it re-parses the whole line, so anything
|
|
43
|
+
// containing whitespace has to carry its own quotes.
|
|
44
|
+
const quote = (s) => (useShell && /[\s"^&|<>]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
|
|
45
|
+
const child = spawn(quote(this.opts.command), this.opts.args.map(quote), {
|
|
46
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
47
|
+
env: { ...process.env, ...this.opts.env },
|
|
48
|
+
cwd: this.opts.cwd,
|
|
49
|
+
shell: useShell,
|
|
50
|
+
windowsVerbatimArguments: useShell,
|
|
51
|
+
});
|
|
52
|
+
this.child = child;
|
|
53
|
+
child.stdout.setEncoding('utf8');
|
|
54
|
+
child.stdout.on('data', (chunk) => this.onStdout(chunk));
|
|
55
|
+
child.stderr.setEncoding('utf8');
|
|
56
|
+
child.stderr.on('data', (chunk) => {
|
|
57
|
+
for (const line of chunk.split('\n'))
|
|
58
|
+
if (line.trim())
|
|
59
|
+
this.stderr.push(line);
|
|
60
|
+
});
|
|
61
|
+
// Writing to a pipe whose far end has gone emits EPIPE on the stream. With
|
|
62
|
+
// no listener Node promotes that to an uncaught exception, which would
|
|
63
|
+
// crash mcp-probe instead of reporting the dead server -- and the whole
|
|
64
|
+
// contract here is that a broken server produces a report, not a crash.
|
|
65
|
+
// Whether the write or the exit lands first is a race, so this shows up
|
|
66
|
+
// on some platforms and not others.
|
|
67
|
+
const swallow = (e) => {
|
|
68
|
+
this.pipeErrors.push(e.message);
|
|
69
|
+
};
|
|
70
|
+
child.stdin.on('error', swallow);
|
|
71
|
+
child.stdout.on('error', swallow);
|
|
72
|
+
child.stderr.on('error', swallow);
|
|
73
|
+
child.on('exit', (code, signal) => {
|
|
74
|
+
this.exited = { code, signal };
|
|
75
|
+
const err = new TransportClosedError(`Server exited (code ${code ?? 'null'}${signal ? `, signal ${signal}` : ''}) with ${this.pending.size} request(s) in flight`, code, this.stderr.slice(-20).join('\n'));
|
|
76
|
+
for (const [, p] of this.pending) {
|
|
77
|
+
clearTimeout(p.timer);
|
|
78
|
+
p.reject(err);
|
|
79
|
+
}
|
|
80
|
+
this.pending.clear();
|
|
81
|
+
});
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
const onError = (e) => reject(new TransportClosedError(`Failed to spawn \`${this.target}\`: ${e.message}`));
|
|
84
|
+
child.once('error', onError);
|
|
85
|
+
// Give spawn a tick to fail loudly; a server that dies later is caught
|
|
86
|
+
// by the in-flight rejection above.
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
child.off('error', onError);
|
|
89
|
+
resolve();
|
|
90
|
+
}, 50);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
onStdout(chunk) {
|
|
94
|
+
this.buffer += chunk;
|
|
95
|
+
let idx;
|
|
96
|
+
while ((idx = this.buffer.indexOf('\n')) !== -1) {
|
|
97
|
+
const line = this.buffer.slice(0, idx).replace(/\r$/, '');
|
|
98
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
99
|
+
if (!line.trim())
|
|
100
|
+
continue;
|
|
101
|
+
this.consumeLine(line);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
consumeLine(line) {
|
|
105
|
+
let msg;
|
|
106
|
+
try {
|
|
107
|
+
msg = JSON.parse(line);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not JSON. Record it and keep going -- we want the full list, not just
|
|
111
|
+
// the first one, so the report can show the author every offending line.
|
|
112
|
+
if (this.stdoutNoise.length < 50)
|
|
113
|
+
this.stdoutNoise.push(line);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (msg && typeof msg === 'object' && msg.id !== undefined && msg.id !== null) {
|
|
117
|
+
const waiter = this.pending.get(msg.id);
|
|
118
|
+
if (waiter) {
|
|
119
|
+
clearTimeout(waiter.timer);
|
|
120
|
+
this.pending.delete(msg.id);
|
|
121
|
+
waiter.resolve(msg);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// No id, or an id nobody is waiting on: a notification or a stray reply.
|
|
126
|
+
this.serverNotifications.push(msg);
|
|
127
|
+
}
|
|
128
|
+
request(method, params, timeoutMs = 10_000) {
|
|
129
|
+
const id = this.nextId++;
|
|
130
|
+
return this.send({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }, id, method, timeoutMs);
|
|
131
|
+
}
|
|
132
|
+
/** Send a request with a caller-chosen id/shape, for malformed-input probes. */
|
|
133
|
+
requestRaw(payload, id, method, timeoutMs = 10_000) {
|
|
134
|
+
return this.send(payload, id, method, timeoutMs);
|
|
135
|
+
}
|
|
136
|
+
send(payload, id, method, timeoutMs) {
|
|
137
|
+
if (!this.child || this.exited) {
|
|
138
|
+
return Promise.reject(new TransportClosedError(`Server is not running (exit code ${this.exited?.code ?? 'unknown'})`, this.exited?.code ?? null, this.stderr.slice(-20).join('\n')));
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
this.pending.delete(id);
|
|
143
|
+
reject(new TimeoutError(method, timeoutMs));
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
146
|
+
const failWrite = (message) => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
this.pending.delete(id);
|
|
149
|
+
reject(new TransportClosedError(`Write to stdin failed: ${message}`));
|
|
150
|
+
};
|
|
151
|
+
// write() reports asynchronously through the callback, but it can also
|
|
152
|
+
// throw synchronously once the stream is destroyed.
|
|
153
|
+
try {
|
|
154
|
+
this.child.stdin.write(JSON.stringify(payload) + '\n', (err) => {
|
|
155
|
+
if (err)
|
|
156
|
+
failWrite(err.message);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
failWrite(e.message);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
notify(method, params) {
|
|
165
|
+
this.writeRaw(JSON.stringify({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }) + '\n');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Write bytes verbatim. Used to test how the server handles garbage, and the
|
|
169
|
+
* single choke point for every unsolicited write, so a dead pipe is handled
|
|
170
|
+
* in exactly one place.
|
|
171
|
+
*/
|
|
172
|
+
writeRaw(text) {
|
|
173
|
+
if (!this.child || this.exited || !this.child.stdin.writable)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
this.child.stdin.write(text);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
// The child can exit between the liveness check and the write landing.
|
|
180
|
+
this.pipeErrors.push(e.message);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
isAlive() {
|
|
184
|
+
return this.child !== null && this.exited === null;
|
|
185
|
+
}
|
|
186
|
+
exitInfo() {
|
|
187
|
+
return this.exited;
|
|
188
|
+
}
|
|
189
|
+
async close() {
|
|
190
|
+
for (const [, p] of this.pending)
|
|
191
|
+
clearTimeout(p.timer);
|
|
192
|
+
this.pending.clear();
|
|
193
|
+
const child = this.child;
|
|
194
|
+
if (!child || this.exited)
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
child.stdin.end();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* Pipe already torn down; there is nothing left to close. */
|
|
201
|
+
}
|
|
202
|
+
await new Promise((resolve) => {
|
|
203
|
+
const t = setTimeout(() => {
|
|
204
|
+
child.kill('SIGKILL');
|
|
205
|
+
resolve();
|
|
206
|
+
}, 1500);
|
|
207
|
+
child.once('exit', () => {
|
|
208
|
+
clearTimeout(t);
|
|
209
|
+
resolve();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { JsonRpcResponse } from './jsonrpc.js';
|
|
2
|
+
/** The surface the checks are written against, so they work over stdio or HTTP. */
|
|
3
|
+
export interface Transport {
|
|
4
|
+
readonly kind: 'stdio' | 'http';
|
|
5
|
+
readonly target: string;
|
|
6
|
+
/** stdout lines that were not valid JSON (stdio only; empty for HTTP). */
|
|
7
|
+
readonly stdoutNoise: string[];
|
|
8
|
+
readonly stderr: string[];
|
|
9
|
+
readonly serverNotifications: JsonRpcResponse[];
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
12
|
+
requestRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
13
|
+
notify(method: string, params?: unknown): void;
|
|
14
|
+
writeRaw(text: string): void;
|
|
15
|
+
isAlive(): boolean;
|
|
16
|
+
exitInfo(): {
|
|
17
|
+
code: number | null;
|
|
18
|
+
signal: string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Diagnosis, Finding, ServerSpec, WtfOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve a command the way the OS launcher will, so "it works in my
|
|
4
|
+
* terminal" stops being a mystery. On Windows this must honour PATHEXT --
|
|
5
|
+
* `npx` is really `npx.cmd`, and GUI-launched hosts often have a different
|
|
6
|
+
* PATH than the user's shell.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveCommand(command: string, env?: NodeJS.ProcessEnv): string | null;
|
|
9
|
+
export declare function staticChecks(spec: ServerSpec): Finding[];
|
|
10
|
+
export declare function diagnoseServer(spec: ServerSpec, options: WtfOptions): Promise<Diagnosis>;
|
|
11
|
+
export declare function diagnoseAll(specs: ServerSpec[], options: WtfOptions): Promise<Diagnosis[]>;
|
package/dist/diagnose.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { McpClient, StdioTransport, HttpTransport } from './client/index.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Static checks: everything knowable without starting the server. Most broken
|
|
6
|
+
// setups are broken right here, and these diagnoses are exact.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/** Values people paste from READMEs and forget to replace. */
|
|
9
|
+
const PLACEHOLDER = /^(|\s*|your[-_ ].*|<[^>]*>|xxx+|\.\.\.|todo|changeme|change[-_ ]me|replace[-_ ]?this|sk-your.*|\$\{input:.*\}|\$[A-Z_]+|%[A-Z_]+%)$/i;
|
|
10
|
+
/** Env keys that clearly hold credentials, where an empty value cannot work. */
|
|
11
|
+
const SECRET_KEY = /(token|key|secret|password|credential|auth)/i;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a command the way the OS launcher will, so "it works in my
|
|
14
|
+
* terminal" stops being a mystery. On Windows this must honour PATHEXT --
|
|
15
|
+
* `npx` is really `npx.cmd`, and GUI-launched hosts often have a different
|
|
16
|
+
* PATH than the user's shell.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveCommand(command, env = process.env) {
|
|
19
|
+
const exts = process.platform === 'win32'
|
|
20
|
+
? (env['PATHEXT'] ?? '.COM;.EXE;.BAT;.CMD').split(';').map((e) => e.toLowerCase())
|
|
21
|
+
: [''];
|
|
22
|
+
const isFile = (p) => {
|
|
23
|
+
try {
|
|
24
|
+
return statSync(p).isFile();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
if (isAbsolute(command) || command.includes('/') || command.includes('\\')) {
|
|
31
|
+
if (isFile(command))
|
|
32
|
+
return command;
|
|
33
|
+
for (const ext of exts)
|
|
34
|
+
if (ext && isFile(command + ext))
|
|
35
|
+
return command + ext;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
for (const dir of (env['PATH'] ?? '').split(delimiter)) {
|
|
39
|
+
if (!dir)
|
|
40
|
+
continue;
|
|
41
|
+
const base = join(dir, command);
|
|
42
|
+
if (process.platform !== 'win32' && isFile(base))
|
|
43
|
+
return base;
|
|
44
|
+
for (const ext of exts)
|
|
45
|
+
if (isFile(base + ext))
|
|
46
|
+
return base + ext;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function configFileOf(spec) {
|
|
51
|
+
const m = spec.sources[0]?.match(/\((.+)\)$/);
|
|
52
|
+
return m?.[1] ?? 'your MCP config file';
|
|
53
|
+
}
|
|
54
|
+
export function staticChecks(spec) {
|
|
55
|
+
const findings = [];
|
|
56
|
+
const cfg = configFileOf(spec);
|
|
57
|
+
if (spec.unlaunchable) {
|
|
58
|
+
findings.push({
|
|
59
|
+
code: 'config.interactive_input',
|
|
60
|
+
severity: 'warn',
|
|
61
|
+
message: `This entry ${spec.unlaunchable}.`,
|
|
62
|
+
fix: 'VS Code fills these in interactively. Other hosts (and mcp-wtf) cannot; replace the placeholder with the real value to test it here.',
|
|
63
|
+
});
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
if (spec.kind === 'http')
|
|
67
|
+
return findings;
|
|
68
|
+
const command = spec.command;
|
|
69
|
+
// "command": "npx -y some-server" -- the whole line pasted into `command`.
|
|
70
|
+
// The OS then looks for a binary whose *name* contains spaces.
|
|
71
|
+
if (/\s/.test(command) && !existsSync(command)) {
|
|
72
|
+
findings.push({
|
|
73
|
+
code: 'config.command_has_args',
|
|
74
|
+
severity: 'fatal',
|
|
75
|
+
message: `The command is "${command}" -- arguments are baked into the command string.`,
|
|
76
|
+
fix: `Split it: "command" should be just the binary, the rest goes into "args". In ${cfg}: {"command": "${command.split(/\s+/)[0]}", "args": ${JSON.stringify(command.split(/\s+/).slice(1).concat(spec.args ?? []))}}`,
|
|
77
|
+
});
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
const resolved = resolveCommand(command);
|
|
81
|
+
if (!resolved) {
|
|
82
|
+
const hint = command === 'npx' || command === 'node'
|
|
83
|
+
? 'Node.js is not on the PATH this process sees. GUI apps on macOS and Windows often get a much shorter PATH than your terminal -- use the absolute path to the binary (run `which npx` / `where npx` in your terminal and paste the result), or launch the host from a terminal.'
|
|
84
|
+
: command === 'uvx' || command === 'uv'
|
|
85
|
+
? 'uv is not on the PATH this process sees. Use the absolute path to uvx (run `which uvx` / `where uvx`), or install uv system-wide.'
|
|
86
|
+
: command === 'docker'
|
|
87
|
+
? 'Docker is not on the PATH this process sees, or Docker Desktop is not running.'
|
|
88
|
+
: `Nothing named "${command}" exists on PATH${isAbsolute(command) ? '' : ' and it is not a path to a file'}.`;
|
|
89
|
+
findings.push({
|
|
90
|
+
code: 'cmd.not_found',
|
|
91
|
+
severity: 'fatal',
|
|
92
|
+
message: `Command not found: "${command}". This produces the classic "spawn ${command} ENOENT" error.`,
|
|
93
|
+
fix: hint,
|
|
94
|
+
detail: `PATH has ${(process.env['PATH'] ?? '').split(delimiter).length} entries; none contains ${command}${process.platform === 'win32' ? ' (checked with PATHEXT)' : ''}.`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const [key, value] of Object.entries(spec.env ?? {})) {
|
|
98
|
+
if (PLACEHOLDER.test(value)) {
|
|
99
|
+
findings.push({
|
|
100
|
+
code: 'env.placeholder',
|
|
101
|
+
severity: 'fatal',
|
|
102
|
+
message: `env.${key} is still the placeholder "${value || '(empty)'}".`,
|
|
103
|
+
fix: `Put the real value into the "env" block of this server in ${cfg}. The server is being started with a value that cannot authenticate.`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
else if (SECRET_KEY.test(key) && value.trim() === '') {
|
|
107
|
+
findings.push({
|
|
108
|
+
code: 'env.empty_secret',
|
|
109
|
+
severity: 'fatal',
|
|
110
|
+
message: `env.${key} is empty.`,
|
|
111
|
+
fix: `Set ${key} in ${cfg}, or remove it so the server can fall back to its own lookup.`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (spec.cwd && !existsSync(spec.cwd)) {
|
|
116
|
+
findings.push({
|
|
117
|
+
code: 'config.cwd_missing',
|
|
118
|
+
severity: 'fatal',
|
|
119
|
+
message: `The configured working directory does not exist: ${spec.cwd}`,
|
|
120
|
+
fix: `Fix or remove "cwd" for this server in ${cfg}.`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return findings;
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Live checks: start the server and grade what actually happens. Every
|
|
127
|
+
// failure signature here maps to a real, frequently-reported way to die.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
/** Known stderr signatures, most specific first. */
|
|
130
|
+
const STDERR_SIGNATURES = [
|
|
131
|
+
[
|
|
132
|
+
/Cannot find module '([^']+)'|ERR_MODULE_NOT_FOUND.*?'([^']+)'/,
|
|
133
|
+
(m, cfg) => ({
|
|
134
|
+
code: 'deps.module_missing',
|
|
135
|
+
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})`,
|
|
138
|
+
}),
|
|
139
|
+
],
|
|
140
|
+
[
|
|
141
|
+
/npm error 404|E404|not found in the package registry|not found.*registry/i,
|
|
142
|
+
(_m, cfg, command) => {
|
|
143
|
+
const registry = command === 'uvx' || command === 'uv' ? 'PyPI' : command === 'npx' ? 'npmjs.com' : 'its registry';
|
|
144
|
+
return {
|
|
145
|
+
code: 'deps.package_not_found',
|
|
146
|
+
severity: 'fatal',
|
|
147
|
+
message: 'The package manager could not find the package -- the name in the config is wrong, or it is no longer published.',
|
|
148
|
+
fix: `Check the package name spelling in ${cfg} against ${registry}.`,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
[
|
|
153
|
+
/EADDRINUSE.*?(\d+)?/,
|
|
154
|
+
(m) => ({
|
|
155
|
+
code: 'net.port_in_use',
|
|
156
|
+
severity: 'fatal',
|
|
157
|
+
message: `The server tried to bind a port that is already taken${m[1] ? ` (${m[1]})` : ''}.`,
|
|
158
|
+
fix: 'Another copy is probably still running -- a previous host session that never shut it down. Kill the old process or change the port.',
|
|
159
|
+
}),
|
|
160
|
+
],
|
|
161
|
+
[
|
|
162
|
+
/(401|unauthorized|invalid[_ ]?(api[_ ]?key|token)|authentication)/i,
|
|
163
|
+
(_m, cfg) => ({
|
|
164
|
+
code: 'auth.rejected',
|
|
165
|
+
severity: 'fatal',
|
|
166
|
+
message: 'The server started but its credentials were rejected.',
|
|
167
|
+
fix: `The API key or token in the "env" block of ${cfg} is wrong, expired, or for the wrong account.`,
|
|
168
|
+
}),
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
/ENOENT.*?'([^']+)'/,
|
|
172
|
+
(m) => ({
|
|
173
|
+
code: 'fs.path_missing',
|
|
174
|
+
severity: 'fatal',
|
|
175
|
+
message: `The server references a path that does not exist: ${m[1]}.`,
|
|
176
|
+
fix: 'One of the arguments in the config points at a file or directory that is not there on this machine.',
|
|
177
|
+
}),
|
|
178
|
+
],
|
|
179
|
+
[
|
|
180
|
+
/(SyntaxError|Unexpected token|IndentationError|Traceback \(most recent call last\))/,
|
|
181
|
+
() => ({
|
|
182
|
+
code: 'crash.exception',
|
|
183
|
+
severity: 'fatal',
|
|
184
|
+
message: 'The server crashed with an unhandled exception on startup.',
|
|
185
|
+
fix: 'This is a bug in the server itself (or a version of it incompatible with your runtime). The stderr below says where.',
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
];
|
|
189
|
+
function classifyStderr(stderr, cfg, command) {
|
|
190
|
+
const text = stderr.join('\n');
|
|
191
|
+
for (const [pattern, build] of STDERR_SIGNATURES) {
|
|
192
|
+
const m = text.match(pattern);
|
|
193
|
+
if (m)
|
|
194
|
+
return build(m, cfg, command);
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
async function liveCheck(spec, options) {
|
|
199
|
+
const cfg = configFileOf(spec);
|
|
200
|
+
const findings = [];
|
|
201
|
+
const transport = spec.kind === 'http'
|
|
202
|
+
? new HttpTransport({ url: spec.url, headers: spec.headers })
|
|
203
|
+
: new StdioTransport({ command: spec.command, args: spec.args ?? [], env: spec.env, cwd: spec.cwd });
|
|
204
|
+
const client = new McpClient(transport, options.timeoutMs);
|
|
205
|
+
const stderrTail = () => transport.stderr.slice(-12).join('\n');
|
|
206
|
+
try {
|
|
207
|
+
await client.start();
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
findings.push({ code: 'spawn.failed', severity: 'fatal', message: e.message, detail: stderrTail() || undefined });
|
|
211
|
+
return { findings };
|
|
212
|
+
}
|
|
213
|
+
let handshake;
|
|
214
|
+
const t0 = Date.now();
|
|
215
|
+
try {
|
|
216
|
+
handshake = await client.initialize();
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
const err = e.message;
|
|
220
|
+
// The failure races its own evidence: the child's stderr and exit events
|
|
221
|
+
// land a beat after the write that noticed the dead pipe. Let them arrive
|
|
222
|
+
// before deciding what happened, or the classification is a coin flip.
|
|
223
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
224
|
+
// The server printed something that is not JSON before dying or hanging:
|
|
225
|
+
// that noise usually *is* the diagnosis.
|
|
226
|
+
if (transport.stdoutNoise.length > 0) {
|
|
227
|
+
findings.push({
|
|
228
|
+
code: 'stdio.pollution',
|
|
229
|
+
severity: 'fatal',
|
|
230
|
+
message: 'The server wrote non-JSON to stdout. On stdio transport, stdout IS the protocol channel -- this corrupts the stream and the host disconnects.',
|
|
231
|
+
fix: 'The server (or a library it uses) is logging to stdout. Logs belong on stderr. If it is your server: replace console.log with console.error. If not: report it to the author -- and check for an env var like LOG_LEVEL or QUIET that silences it.',
|
|
232
|
+
detail: transport.stdoutNoise.slice(0, 5).map((l) => `> ${l.slice(0, 160)}`).join('\n'),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const classified = classifyStderr(transport.stderr, cfg, spec.command ?? '');
|
|
236
|
+
if (classified) {
|
|
237
|
+
findings.push({ ...classified, detail: stderrTail() || classified.detail });
|
|
238
|
+
}
|
|
239
|
+
else if (!transport.isAlive() && transport.exitInfo()) {
|
|
240
|
+
const info = transport.exitInfo();
|
|
241
|
+
findings.push({
|
|
242
|
+
code: 'crash.on_start',
|
|
243
|
+
severity: 'fatal',
|
|
244
|
+
message: `The server exited immediately (code ${info.code ?? 'null'}) without completing the MCP handshake.`,
|
|
245
|
+
fix: transport.stderr.length
|
|
246
|
+
? 'Its own error output is below -- that is the actual reason.'
|
|
247
|
+
: 'It printed nothing at all. Run the command from your terminal by hand and watch what happens.',
|
|
248
|
+
detail: stderrTail() || undefined,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else if (findings.length === 0) {
|
|
252
|
+
findings.push({
|
|
253
|
+
code: 'handshake.timeout',
|
|
254
|
+
severity: 'fatal',
|
|
255
|
+
message: `The server started but never answered the MCP handshake (${err}).`,
|
|
256
|
+
fix: 'The process is running but not speaking MCP on stdio. Usual causes: this command starts an HTTP server (use "url" instead of "command"), the entrypoint is the wrong file, or the server waits for input it never gets.',
|
|
257
|
+
detail: stderrTail() || undefined,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
await client.close();
|
|
261
|
+
return { findings };
|
|
262
|
+
}
|
|
263
|
+
const connectMs = Date.now() - t0;
|
|
264
|
+
if (handshake.raw.error) {
|
|
265
|
+
findings.push({
|
|
266
|
+
code: 'handshake.rejected',
|
|
267
|
+
severity: 'fatal',
|
|
268
|
+
message: `The server answered the handshake with an error: ${handshake.raw.error.code} ${handshake.raw.error.message}`,
|
|
269
|
+
detail: stderrTail() || undefined,
|
|
270
|
+
});
|
|
271
|
+
await client.close();
|
|
272
|
+
return { findings };
|
|
273
|
+
}
|
|
274
|
+
client.notifyInitialized();
|
|
275
|
+
// Connected. Still worth flagging things that will bite later.
|
|
276
|
+
if (transport.stdoutNoise.length > 0) {
|
|
277
|
+
findings.push({
|
|
278
|
+
code: 'stdio.pollution',
|
|
279
|
+
severity: 'warn',
|
|
280
|
+
message: `The handshake succeeded, but the server wrote ${transport.stdoutNoise.length} non-JSON line(s) to stdout. Some hosts survive this; others disconnect at random.`,
|
|
281
|
+
fix: 'Logs belong on stderr. This is the most common cause of "works sometimes, disconnects randomly".',
|
|
282
|
+
detail: transport.stdoutNoise.slice(0, 3).map((l) => `> ${l.slice(0, 160)}`).join('\n'),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const { tools } = await client.listTools().catch(() => ({ tools: [] }));
|
|
286
|
+
if (tools.length === 0) {
|
|
287
|
+
findings.push({
|
|
288
|
+
code: 'tools.none',
|
|
289
|
+
severity: 'warn',
|
|
290
|
+
message: 'Connected fine, but the server exposes zero tools.',
|
|
291
|
+
fix: 'If you expected tools here, the server may need configuration (env vars, a workspace path) to enable them.',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (connectMs > 10_000) {
|
|
295
|
+
findings.push({
|
|
296
|
+
code: 'perf.slow_start',
|
|
297
|
+
severity: 'info',
|
|
298
|
+
message: `The handshake took ${(connectMs / 1000).toFixed(1)}s. Hosts with short startup timeouts may give up on it.`,
|
|
299
|
+
fix: spec.command === 'npx' ? 'npx downloads the package on a cold cache. Pin it locally (npm install -g, then reference the binary) for instant starts.' : undefined,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const serverInfo = handshake.serverInfo;
|
|
303
|
+
await client.close();
|
|
304
|
+
return { findings, serverInfo, toolCount: tools.length, connectMs };
|
|
305
|
+
}
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
export async function diagnoseServer(spec, options) {
|
|
308
|
+
const findings = staticChecks(spec);
|
|
309
|
+
const fatalAlready = findings.some((f) => f.severity === 'fatal');
|
|
310
|
+
// Static fatals make the live attempt pointless and its errors redundant --
|
|
311
|
+
// except cmd.not_found, where actually trying confirms the diagnosis cheaply.
|
|
312
|
+
if (!fatalAlready || findings.every((f) => f.code === 'cmd.not_found')) {
|
|
313
|
+
if (!spec.unlaunchable) {
|
|
314
|
+
const live = await liveCheck(spec, options);
|
|
315
|
+
// If the static check already named the cause, drop the vaguer spawn echo.
|
|
316
|
+
const filtered = fatalAlready ? live.findings.filter((f) => f.code !== 'spawn.failed') : live.findings;
|
|
317
|
+
findings.push(...filtered);
|
|
318
|
+
if (live.serverInfo !== undefined) {
|
|
319
|
+
const verdict = findings.some((f) => f.severity === 'fatal') ? 'broken' : findings.length > 0 ? 'warning' : 'healthy';
|
|
320
|
+
return { spec, verdict, findings, serverInfo: live.serverInfo, toolCount: live.toolCount, connectMs: live.connectMs };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const verdict = findings.some((f) => f.severity === 'fatal')
|
|
325
|
+
? 'broken'
|
|
326
|
+
: findings.length > 0
|
|
327
|
+
? 'warning'
|
|
328
|
+
: 'healthy';
|
|
329
|
+
return { spec, verdict, findings };
|
|
330
|
+
}
|
|
331
|
+
export async function diagnoseAll(specs, options) {
|
|
332
|
+
const results = new Array(specs.length);
|
|
333
|
+
let next = 0;
|
|
334
|
+
const workers = Array.from({ length: Math.max(1, Math.min(options.concurrency, specs.length)) }, async () => {
|
|
335
|
+
for (;;) {
|
|
336
|
+
const i = next++;
|
|
337
|
+
if (i >= specs.length)
|
|
338
|
+
return;
|
|
339
|
+
results[i] = await diagnoseServer(specs[i], options);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
await Promise.all(workers);
|
|
343
|
+
return results;
|
|
344
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ServerSpec } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Every place the well-known hosts keep their MCP configuration. Two shapes
|
|
4
|
+
* exist in the wild: `mcpServers` (Claude Desktop, Claude Code, Cursor,
|
|
5
|
+
* Windsurf) and `servers` (VS Code).
|
|
6
|
+
*/
|
|
7
|
+
export declare function knownConfigPaths(platform?: NodeJS.Platform, home?: string, cwd?: string): Array<{
|
|
8
|
+
path: string;
|
|
9
|
+
host: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Pull every server out of one config file. Returns [] when unreadable. */
|
|
12
|
+
export declare function readConfigFile(path: string, host: string): ServerSpec[];
|
|
13
|
+
/**
|
|
14
|
+
* Search every known location, merge duplicates (the same server configured
|
|
15
|
+
* in several hosts is one server with several sources), and report which
|
|
16
|
+
* config files were actually found -- and which exist but cannot be parsed,
|
|
17
|
+
* because a malformed config is the diagnosis, not a thing to skip.
|
|
18
|
+
*/
|
|
19
|
+
export declare function discover(explicitConfig?: string): {
|
|
20
|
+
specs: ServerSpec[];
|
|
21
|
+
configsSearched: string[];
|
|
22
|
+
configErrors: Array<{
|
|
23
|
+
path: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}>;
|
|
26
|
+
};
|