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 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 and VS Code, actually launches each one the way the host would, keeps everything the host discards, and tells you exactly what is wrong with the fix:
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
- const VERSION = '0.1.0';
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 streamable-HTTP server
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. mcp-wtf never invokes your tools, and never
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
- const report = {
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`);
@@ -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: '0.1.0' },
29
+ clientInfo: { name: 'mcp-wtf', version: VERSION },
29
30
  }, this.timeoutMs);
30
31
  const ms = Date.now() - t0;
31
32
  const result = (raw.result ?? {});
@@ -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, HttpTransport } from './client/index.js';
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
- function classifyStderr(stderr, cfg, command) {
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 = 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 });
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
- 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 };
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
- const verdict = findings.some((f) => f.severity === 'fatal')
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);
@@ -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. Two shapes
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) and `servers` (VS Code).
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
  /**