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/dist/discover.js CHANGED
@@ -1,10 +1,19 @@
1
1
  import { readFileSync, existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ /** VS Code's per-user directory -- the root the extensions hang their state off. */
5
+ function vscodeUserDir(platform, home) {
6
+ if (platform === 'win32')
7
+ return join(process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming'), 'Code', 'User');
8
+ if (platform === 'darwin')
9
+ return join(home, 'Library', 'Application Support', 'Code', 'User');
10
+ return join(home, '.config', 'Code', 'User');
11
+ }
4
12
  /**
5
- * Every place the well-known hosts keep their MCP configuration. Two shapes
13
+ * Every place the well-known hosts keep their MCP configuration. Three shapes
6
14
  * exist in the wild: `mcpServers` (Claude Desktop, Claude Code, Cursor,
7
- * Windsurf) and `servers` (VS Code).
15
+ * Windsurf, Gemini CLI, Cline, Roo Code), `servers` (VS Code) and
16
+ * `context_servers` (Zed).
8
17
  */
9
18
  export function knownConfigPaths(platform = process.platform, home = homedir(), cwd = process.cwd()) {
10
19
  const paths = [];
@@ -13,47 +22,144 @@ export function knownConfigPaths(platform = process.platform, home = homedir(),
13
22
  const appdata = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming');
14
23
  push(join(appdata, 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
15
24
  push(join(appdata, 'Code', 'User', 'mcp.json'), 'VS Code');
25
+ push(join(appdata, 'Zed', 'settings.json'), 'Zed');
16
26
  }
17
27
  else if (platform === 'darwin') {
18
28
  push(join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
19
29
  push(join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'), 'VS Code');
30
+ push(join(home, '.config', 'zed', 'settings.json'), 'Zed');
20
31
  }
21
32
  else {
22
33
  push(join(home, '.config', 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
23
34
  push(join(home, '.config', 'Code', 'User', 'mcp.json'), 'VS Code');
35
+ push(join(home, '.config', 'zed', 'settings.json'), 'Zed');
24
36
  }
37
+ // The VS Code extensions keep their servers in globalStorage, not in the
38
+ // editor's own config -- which is why "I configured it in Cline and nothing
39
+ // else can see it" is not a bug.
40
+ const vscode = vscodeUserDir(platform, home);
41
+ push(join(vscode, 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'), 'Cline');
42
+ push(join(vscode, 'globalStorage', 'rooveterinaryinc.roo-cline', 'settings', 'mcp_settings.json'), 'Roo Code');
25
43
  push(join(home, '.claude.json'), 'Claude Code');
26
44
  push(join(cwd, '.mcp.json'), 'Claude Code (project)');
27
45
  push(join(home, '.cursor', 'mcp.json'), 'Cursor');
28
46
  push(join(cwd, '.cursor', 'mcp.json'), 'Cursor (project)');
29
47
  push(join(home, '.codeium', 'windsurf', 'mcp_config.json'), 'Windsurf');
48
+ push(join(home, '.gemini', 'settings.json'), 'Gemini CLI');
49
+ push(join(cwd, '.gemini', 'settings.json'), 'Gemini CLI (project)');
30
50
  push(join(cwd, '.vscode', 'mcp.json'), 'VS Code (workspace)');
31
51
  return paths;
32
52
  }
53
+ /**
54
+ * JSON.parse, but tolerant of what these files actually contain. VS Code's
55
+ * mcp.json and Zed's settings.json are JSONC -- Zed ships a default settings
56
+ * file that is nothing but comments -- and calling those "not valid JSON"
57
+ * would be a confident, wrong diagnosis. Comments and trailing commas are
58
+ * stripped; anything still broken is genuinely broken.
59
+ */
60
+ export function parseJsonc(text) {
61
+ const out = [];
62
+ let inString = false;
63
+ let escaped = false;
64
+ // Only ever drop a comma that a closing bracket makes illegal, and only
65
+ // outside a string -- a blind regex would corrupt values like "a, }b".
66
+ const dropTrailingComma = () => {
67
+ let i = out.length - 1;
68
+ while (i >= 0 && /\s/.test(out[i]))
69
+ i--;
70
+ if (i >= 0 && out[i] === ',')
71
+ out.splice(i, 1);
72
+ };
73
+ for (let i = 0; i < text.length; i++) {
74
+ const ch = text[i];
75
+ if (inString) {
76
+ out.push(ch);
77
+ if (escaped)
78
+ escaped = false;
79
+ else if (ch === '\\')
80
+ escaped = true;
81
+ else if (ch === '"')
82
+ inString = false;
83
+ continue;
84
+ }
85
+ if (ch === '"') {
86
+ inString = true;
87
+ out.push(ch);
88
+ continue;
89
+ }
90
+ if (ch === '/' && text[i + 1] === '/') {
91
+ while (i < text.length && text[i] !== '\n')
92
+ i++;
93
+ out.push('\n');
94
+ continue;
95
+ }
96
+ if (ch === '/' && text[i + 1] === '*') {
97
+ const end = text.indexOf('*/', i + 2);
98
+ i = end === -1 ? text.length : end + 1;
99
+ continue;
100
+ }
101
+ if (ch === '}' || ch === ']')
102
+ dropTrailingComma();
103
+ out.push(ch);
104
+ }
105
+ return JSON.parse(out.join(''));
106
+ }
107
+ /**
108
+ * Zed nests the launch under `command`: {"path": "npx", "args": [], "env": {}}.
109
+ * Everyone else puts a bare string there. Both mean the same thing.
110
+ */
111
+ function flatten(raw) {
112
+ if (raw.command && typeof raw.command === 'object' && !Array.isArray(raw.command)) {
113
+ const nested = raw.command;
114
+ return {
115
+ command: typeof nested.path === 'string' ? nested.path : null,
116
+ args: nested.args ?? raw.args,
117
+ env: nested.env ?? raw.env,
118
+ };
119
+ }
120
+ return { command: typeof raw.command === 'string' ? raw.command : null, args: raw.args, env: raw.env };
121
+ }
33
122
  function toSpec(name, raw, source) {
34
- if (raw.disabled === true)
123
+ if (raw.disabled === true || raw.enabled === false)
35
124
  return null;
36
125
  const sources = [source];
37
- const url = typeof raw.url === 'string' ? raw.url : typeof raw.serverUrl === 'string' ? raw.serverUrl : null;
126
+ const url = typeof raw.url === 'string'
127
+ ? raw.url
128
+ : typeof raw.serverUrl === 'string'
129
+ ? raw.serverUrl
130
+ : typeof raw.httpUrl === 'string' // Gemini CLI's name for streamable HTTP
131
+ ? raw.httpUrl
132
+ : null;
38
133
  if (url) {
39
134
  return { name, kind: 'http', url, headers: raw.headers ?? {}, sources };
40
135
  }
41
- if (typeof raw.command !== 'string' || !raw.command)
136
+ const flat = flatten(raw);
137
+ if (!flat.command)
42
138
  return null;
43
- const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string') : [];
139
+ const args = Array.isArray(flat.args) ? flat.args.filter((a) => typeof a === 'string') : [];
140
+ // Hand-edited configs contain numbers and booleans where the launcher only
141
+ // ever passes strings; keeping them would make later checks throw on a file
142
+ // that is merely odd.
143
+ const env = {};
144
+ if (flat.env && typeof flat.env === 'object' && !Array.isArray(flat.env)) {
145
+ for (const [key, value] of Object.entries(flat.env)) {
146
+ if (typeof value === 'string')
147
+ env[key] = value;
148
+ }
149
+ }
44
150
  const spec = {
45
151
  name,
46
152
  kind: 'stdio',
47
- command: raw.command,
153
+ command: flat.command,
48
154
  args,
49
- env: raw.env ?? {},
155
+ env,
50
156
  cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
51
157
  sources,
52
158
  };
53
159
  // VS Code configs can reference interactive inputs (${input:apiKey}); those
54
160
  // servers cannot be launched non-interactively, but they should still show
55
161
  // up in the report rather than silently vanish.
56
- const joined = [raw.command, ...args, JSON.stringify(spec.env)].join(' ');
162
+ const joined = [flat.command, ...args, JSON.stringify(spec.env)].join(' ');
57
163
  if (joined.includes('${input:')) {
58
164
  spec.unlaunchable = 'uses ${input:...} placeholders that need interactive values';
59
165
  }
@@ -63,7 +169,7 @@ function toSpec(name, raw, source) {
63
169
  export function readConfigFile(path, host) {
64
170
  let parsed;
65
171
  try {
66
- parsed = JSON.parse(readFileSync(path, 'utf8'));
172
+ parsed = parseJsonc(readFileSync(path, 'utf8'));
67
173
  }
68
174
  catch {
69
175
  return [];
@@ -81,6 +187,7 @@ export function readConfigFile(path, host) {
81
187
  };
82
188
  collect(parsed['mcpServers']);
83
189
  collect(parsed['servers']);
190
+ collect(parsed['context_servers']); // Zed
84
191
  // Claude Code also nests per-project servers under `projects`.
85
192
  const projects = parsed['projects'];
86
193
  if (projects && typeof projects === 'object' && !Array.isArray(projects)) {
@@ -108,7 +215,7 @@ export function discover(explicitConfig) {
108
215
  continue;
109
216
  configsSearched.push(path);
110
217
  try {
111
- JSON.parse(readFileSync(path, 'utf8'));
218
+ parseJsonc(readFileSync(path, 'utf8'));
112
219
  }
113
220
  catch (e) {
114
221
  configErrors.push({ path, error: e.message });
@@ -121,8 +228,9 @@ export function discover(explicitConfig) {
121
228
  // token, one with a placeholder) is a different server, and merging
122
229
  // them would silently drop one from the report.
123
230
  const envKey = JSON.stringify(Object.entries(spec.env ?? {}).sort());
231
+ const headerKey = JSON.stringify(Object.entries(spec.headers ?? {}).sort());
124
232
  const identity = spec.kind === 'http'
125
- ? `http|${spec.url}`
233
+ ? `http|${spec.url}|${headerKey}`
126
234
  : `stdio|${spec.command}|${(spec.args ?? []).join(' ')}|${envKey}|${spec.cwd ?? ''}`;
127
235
  const existing = byIdentity.get(identity);
128
236
  if (existing)
package/dist/index.d.ts CHANGED
@@ -1,5 +1,10 @@
1
- export { discover, readConfigFile, knownConfigPaths } from './discover.js';
2
- export { diagnoseServer, diagnoseAll, staticChecks, resolveCommand } from './diagnose.js';
1
+ export { discover, readConfigFile, knownConfigPaths, parseJsonc } from './discover.js';
2
+ export { diagnoseServer, diagnoseAll, staticChecks, resolveCommand, classifyStderr } from './diagnose.js';
3
+ export { probeRemote, classifyHttpProbe, classifyNetworkError, oauthMetadataUrl, readRpcBody } from './remote.js';
4
+ export type { HttpProbe, RemoteResult } from './remote.js';
5
+ export { diagnoseLogs, analyzeLogFile, analyzeLogText, classifyLogLine, discoverLogFiles, knownLogDirs, findLogFiles, serverNameFromLogPath, tail, } from './logs.js';
6
+ export { redactSecrets, quoteLine, redactSpecSecrets } from './redact.js';
3
7
  export { renderTerminal } from './report/terminal.js';
4
8
  export { McpClient, StdioTransport, HttpTransport } from './client/index.js';
9
+ export { VERSION } from './version.js';
5
10
  export type { Diagnosis, Finding, ServerSpec, Verdict, WtfOptions, WtfReport } from './types.js';
package/dist/index.js CHANGED
@@ -1,4 +1,8 @@
1
- export { discover, readConfigFile, knownConfigPaths } from './discover.js';
2
- export { diagnoseServer, diagnoseAll, staticChecks, resolveCommand } from './diagnose.js';
1
+ export { discover, readConfigFile, knownConfigPaths, parseJsonc } from './discover.js';
2
+ export { diagnoseServer, diagnoseAll, staticChecks, resolveCommand, classifyStderr } from './diagnose.js';
3
+ export { probeRemote, classifyHttpProbe, classifyNetworkError, oauthMetadataUrl, readRpcBody } from './remote.js';
4
+ export { diagnoseLogs, analyzeLogFile, analyzeLogText, classifyLogLine, discoverLogFiles, knownLogDirs, findLogFiles, serverNameFromLogPath, tail, } from './logs.js';
5
+ export { redactSecrets, quoteLine, redactSpecSecrets } from './redact.js';
3
6
  export { renderTerminal } from './report/terminal.js';
4
7
  export { McpClient, StdioTransport, HttpTransport } from './client/index.js';
8
+ export { VERSION } from './version.js';
package/dist/logs.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { Diagnosis, Finding } from './types.js';
2
+ /**
3
+ * Log-file mode.
4
+ *
5
+ * Relaunching a server answers "why won't it connect" for the server that is
6
+ * broken now. It cannot answer "why did it drop out at 4pm yesterday", and it
7
+ * cannot help at all when the failure only happens inside the host -- a
8
+ * different PATH, a different working directory, a token the GUI has and the
9
+ * terminal does not. The host wrote all of that down and then never showed it
10
+ * to anyone. This reads those files and runs the same classifier over them.
11
+ */
12
+ /** How much of each file is recent enough to be worth reading. */
13
+ export declare const TAIL_LINES = 200;
14
+ /** Where the hosts keep MCP logs, per OS. */
15
+ export declare function knownLogDirs(platform?: NodeJS.Platform, home?: string): Array<{
16
+ dir: string;
17
+ host: string;
18
+ }>;
19
+ /**
20
+ * `mcp-server-github.log` -> `github`; the shared `mcp.log` has no one server.
21
+ * Rotated files (`mcp-server-github1.log`, `mcp.log.2`) belong to the same
22
+ * server as the file they were rotated out of.
23
+ */
24
+ export declare function serverNameFromLogPath(path: string): string;
25
+ /** Every MCP log in a directory, newest first. Missing directories are silent. */
26
+ export declare function findLogFiles(dir: string): string[];
27
+ export declare function discoverLogFiles(platform?: NodeJS.Platform, home?: string): Array<{
28
+ path: string;
29
+ host: string;
30
+ }>;
31
+ export declare function tail(text: string, lines?: number): string[];
32
+ export declare function readTail(path: string, maxBytes?: number): string;
33
+ /**
34
+ * Classify one line. Log-specific signatures first, then the same stderr
35
+ * signatures the live checks use -- a server's stderr is copied into these
36
+ * files verbatim, so everything mcp-wtf already knows how to read applies.
37
+ */
38
+ export declare function classifyLogLine(line: string): Finding | null;
39
+ /**
40
+ * Which server a line is about. Hosts bracket the server name into every line
41
+ * they write -- `[github] [info] ...` in a per-server file, `[info] [github]
42
+ * ...` in the shared one -- which is what makes the shared mcp.log usable.
43
+ */
44
+ export declare function serverTagOf(line: string): string | null;
45
+ /** Findings for one log file's recent tail, grouped by the server they name. */
46
+ export declare function scanLogText(text: string, lines?: number, max?: number): Map<string | null, Finding[]>;
47
+ /** The flat form: every finding in a file, in the order they were logged. */
48
+ export declare function analyzeLogText(text: string, lines?: number, max?: number): Finding[];
49
+ /**
50
+ * One diagnosis per server named in the file. Usually that is one server --
51
+ * but the shared mcp.log carries every server interleaved, and attributing its
52
+ * lines is the difference between a useful report and a pile of log excerpts.
53
+ */
54
+ export declare function analyzeLogFile(path: string, host: string, lines?: number): Diagnosis[];
55
+ /**
56
+ * Diagnose a set of log files: explicit paths, or every one we can find. One
57
+ * server can own several files (rotation) and appear in the shared log too, so
58
+ * the results are merged back into one entry per server.
59
+ */
60
+ export declare function diagnoseLogs(explicit?: string[], lines?: number): {
61
+ diagnoses: Diagnosis[];
62
+ scanned: string[];
63
+ };
package/dist/logs.js ADDED
@@ -0,0 +1,315 @@
1
+ import { closeSync, existsSync, fstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, win32 } from 'node:path';
4
+ import { classifyStderr } from './diagnose.js';
5
+ import { quoteLine } from './redact.js';
6
+ /**
7
+ * Log-file mode.
8
+ *
9
+ * Relaunching a server answers "why won't it connect" for the server that is
10
+ * broken now. It cannot answer "why did it drop out at 4pm yesterday", and it
11
+ * cannot help at all when the failure only happens inside the host -- a
12
+ * different PATH, a different working directory, a token the GUI has and the
13
+ * terminal does not. The host wrote all of that down and then never showed it
14
+ * to anyone. This reads those files and runs the same classifier over them.
15
+ */
16
+ /** How much of each file is recent enough to be worth reading. */
17
+ export const TAIL_LINES = 200;
18
+ /** Where the hosts keep MCP logs, per OS. */
19
+ export function knownLogDirs(platform = process.platform, home = homedir()) {
20
+ if (platform === 'win32') {
21
+ const appdata = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming');
22
+ return [{ dir: join(appdata, 'Claude', 'logs'), host: 'Claude Desktop log' }];
23
+ }
24
+ if (platform === 'darwin') {
25
+ return [{ dir: join(home, 'Library', 'Logs', 'Claude'), host: 'Claude Desktop log' }];
26
+ }
27
+ return [{ dir: join(home, '.config', 'Claude', 'logs'), host: 'Claude Desktop log' }];
28
+ }
29
+ /**
30
+ * `mcp-server-github.log` -> `github`; the shared `mcp.log` has no one server.
31
+ * Rotated files (`mcp-server-github1.log`, `mcp.log.2`) belong to the same
32
+ * server as the file they were rotated out of.
33
+ */
34
+ export function serverNameFromLogPath(path) {
35
+ // win32.basename splits on both separators, so a Windows path pasted into a
36
+ // report parses the same on every OS; posix basename would keep `C:\...`.
37
+ const file = win32.basename(path);
38
+ const named = file.match(/^mcp-server-(.+?)\d*\.log(?:\.\d+)?$/i);
39
+ if (named?.[1])
40
+ return named[1];
41
+ return /^mcp\d*\.log/i.test(file) ? '(host log)' : file.replace(/\.log(\.\d+)?$/i, '');
42
+ }
43
+ /** Every MCP log in a directory, newest first. Missing directories are silent. */
44
+ export function findLogFiles(dir) {
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(dir);
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ return entries
53
+ .filter((f) => /^mcp(-server-.+?)?\d*\.log(\.\d+)?$/i.test(f))
54
+ .map((f) => join(dir, f))
55
+ .filter((p) => {
56
+ try {
57
+ return statSync(p).isFile();
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ })
63
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
64
+ }
65
+ export function discoverLogFiles(platform = process.platform, home = homedir()) {
66
+ const out = [];
67
+ for (const { dir, host } of knownLogDirs(platform, home)) {
68
+ if (!existsSync(dir))
69
+ continue;
70
+ for (const path of findLogFiles(dir))
71
+ out.push({ path, host });
72
+ }
73
+ return out;
74
+ }
75
+ export function tail(text, lines = TAIL_LINES) {
76
+ const all = text.split(/\r?\n/).filter((l) => l.trim());
77
+ return all.slice(-lines);
78
+ }
79
+ /**
80
+ * Enough of the end of a file to hold TAIL_LINES lines. These files reach tens
81
+ * of megabytes -- a host that has been running for months writes every
82
+ * JSON-RPC message it sees into them -- and reading all of that to look at the
83
+ * last two hundred lines would make a tool that promises ten seconds slow.
84
+ */
85
+ const TAIL_BYTES = 256 * 1024;
86
+ export function readTail(path, maxBytes = TAIL_BYTES) {
87
+ const fd = openSync(path, 'r');
88
+ try {
89
+ const size = fstatSync(fd).size;
90
+ const start = Math.max(0, size - maxBytes);
91
+ const buffer = Buffer.allocUnsafe(size - start);
92
+ if (buffer.length > 0)
93
+ readSync(fd, buffer, 0, buffer.length, start);
94
+ const text = buffer.toString('utf8');
95
+ // A byte offset lands mid-line, and mid-character; that first fragment is
96
+ // not a line and must not be classified as one.
97
+ return start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
98
+ }
99
+ finally {
100
+ closeSync(fd);
101
+ }
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Signatures that only ever appear in a host's log, never in a server's own
105
+ // stderr. They run first, because the host's phrasing for a failure is not the
106
+ // server's -- "Unexpected token ... is not valid JSON" in a host log is the
107
+ // host choking on polluted stdout, not the server throwing a SyntaxError.
108
+ // ---------------------------------------------------------------------------
109
+ const LOG_SIGNATURES = [
110
+ [
111
+ /docker: error during connect|Cannot connect to the Docker daemon|Is the docker daemon running/i,
112
+ () => ({
113
+ code: 'docker.not_running',
114
+ severity: 'fatal',
115
+ message: 'The server is launched through Docker, and the Docker daemon was not running.',
116
+ fix: 'Start Docker Desktop before the host, or the server dies on every launch. A host that starts with the machine will always lose this race -- consider a non-Docker build of the server.',
117
+ }),
118
+ ],
119
+ [
120
+ /spawn (\S+) ENOENT/i,
121
+ (m) => ({
122
+ code: 'cmd.not_found',
123
+ severity: 'fatal',
124
+ message: `The host could not start "${m[1]}" -- it is not on the PATH the host runs with.`,
125
+ fix: 'Classic GUI-versus-terminal PATH split: it works in your shell and does not exist as far as the host is concerned. Put the absolute path to the binary in the config (`which npx` / `where npx`).',
126
+ }),
127
+ ],
128
+ [
129
+ /(?:Unexpected token .{0,40}is not valid JSON|Unexpected non-whitespace character after JSON|Expected property name or '\}' in JSON|Unexpected end of JSON input)/i,
130
+ () => ({
131
+ code: 'stdio.pollution',
132
+ severity: 'fatal',
133
+ message: 'The host failed to parse what the server sent on stdout -- the server is printing non-JSON onto the protocol channel.',
134
+ fix: 'Something in the server logs to stdout. On stdio transport stdout IS the protocol, so those bytes corrupt the stream and the host disconnects. Logs belong on stderr; look for a LOG_LEVEL or QUIET env var if it is not your server.',
135
+ }),
136
+ ],
137
+ [
138
+ /Server (?:process )?exited with code (\d+)/i,
139
+ (m) => ({
140
+ code: 'crash.on_start',
141
+ severity: 'fatal',
142
+ message: `The host recorded the server exiting with code ${m[1]} instead of staying up.`,
143
+ fix: 'Run the exact command from the config in a terminal and watch what it prints. Whatever kills it there is what killed it here.',
144
+ }),
145
+ ],
146
+ [
147
+ /MCP error -32000|transport closed unexpectedly|process exiting early/i,
148
+ () => ({
149
+ code: 'transport.closed_unexpectedly',
150
+ severity: 'fatal',
151
+ message: 'The server process went away without shutting down -- the host logged the transport closing unexpectedly.',
152
+ fix: 'Something killed it: a crash, an out-of-memory kill, or stdout pollution that corrupted the stream. The lines just above this one in the same file are where the reason is, if the server printed one at all.',
153
+ }),
154
+ ],
155
+ [
156
+ /Server disconnected|Server transport closed|Client transport closed/i,
157
+ () => ({
158
+ code: 'transport.closed',
159
+ severity: 'warn',
160
+ message: 'The host recorded this server disconnecting.',
161
+ fix: 'On its own this proves nothing: the same line is written every time you quit the app. It only matters when it lands seconds after startup -- check the timestamp against when you last used the host.',
162
+ }),
163
+ ],
164
+ [
165
+ /MCP error -32001|Request timed out|initialization timed out|Timed out waiting for/i,
166
+ () => ({
167
+ code: 'handshake.timeout',
168
+ severity: 'fatal',
169
+ message: 'The host gave up waiting for the server to answer.',
170
+ fix: 'The process starts but never completes the handshake. Usual causes: it is an HTTP server being launched as a stdio one, the entrypoint is the wrong file, or a cold `npx` download outruns the host\'s startup timeout.',
171
+ }),
172
+ ],
173
+ ];
174
+ /**
175
+ * Hosts mirror every JSON-RPC message into the log. Those lines carry tool
176
+ * arguments and tool results -- arbitrary text that will happily contain the
177
+ * word "unauthorized" or someone's `Cannot find module` stack trace. Reading
178
+ * them as evidence is how a diagnostic tool starts inventing failures.
179
+ */
180
+ const MIRRORED_TRAFFIC = /Message from (?:client|server): *[{[]/i;
181
+ /**
182
+ * Classify one line. Log-specific signatures first, then the same stderr
183
+ * signatures the live checks use -- a server's stderr is copied into these
184
+ * files verbatim, so everything mcp-wtf already knows how to read applies.
185
+ */
186
+ export function classifyLogLine(line) {
187
+ if (MIRRORED_TRAFFIC.test(line))
188
+ return null;
189
+ for (const [pattern, build] of LOG_SIGNATURES) {
190
+ const m = line.match(pattern);
191
+ if (m)
192
+ return build(m);
193
+ }
194
+ return classifyStderr([line], 'your MCP config file', '');
195
+ }
196
+ /**
197
+ * Symptoms rather than causes. Every failure ends with the transport closing,
198
+ * so reporting that next to the reason it closed is noise -- and reporting it
199
+ * alone is close to useless, which is why it is only a warning.
200
+ */
201
+ const SYMPTOM_ONLY = new Set(['transport.closed', 'transport.closed_unexpectedly']);
202
+ const LOG_LEVEL = /^(info|error|warn|warning|debug|trace)$/i;
203
+ /**
204
+ * Which server a line is about. Hosts bracket the server name into every line
205
+ * they write -- `[github] [info] ...` in a per-server file, `[info] [github]
206
+ * ...` in the shared one -- which is what makes the shared mcp.log usable.
207
+ */
208
+ export function serverTagOf(line) {
209
+ for (const m of line.matchAll(/\[([^\]]{1,64})\]/g)) {
210
+ const tag = m[1].trim();
211
+ if (tag && !LOG_LEVEL.test(tag))
212
+ return tag;
213
+ }
214
+ return null;
215
+ }
216
+ /** Findings for one log file's recent tail, grouped by the server they name. */
217
+ export function scanLogText(text, lines = TAIL_LINES, max = 6) {
218
+ const byServer = new Map();
219
+ for (const line of tail(text, lines)) {
220
+ const finding = classifyLogLine(line);
221
+ if (!finding)
222
+ continue;
223
+ const server = serverTagOf(line);
224
+ let seen = byServer.get(server);
225
+ if (!seen)
226
+ byServer.set(server, (seen = new Map()));
227
+ // The same failure is logged on every reconnect attempt; one is enough,
228
+ // and the first occurrence is the one with the useful context around it.
229
+ const key = `${finding.code}|${finding.message}`;
230
+ if (seen.has(key) || seen.size >= max)
231
+ continue;
232
+ seen.set(key, { ...finding, detail: `> ${quoteLine(line)}` });
233
+ }
234
+ const out = new Map();
235
+ for (const [server, seen] of byServer)
236
+ out.set(server, preferCauses([...seen.values()]));
237
+ return out;
238
+ }
239
+ /** Drop "and then it disconnected" once something explains why it disconnected. */
240
+ function preferCauses(findings) {
241
+ const causes = findings.filter((f) => !SYMPTOM_ONLY.has(f.code));
242
+ return causes.length > 0 ? causes : findings;
243
+ }
244
+ /** The flat form: every finding in a file, in the order they were logged. */
245
+ export function analyzeLogText(text, lines = TAIL_LINES, max = 6) {
246
+ return [...scanLogText(text, lines, max).values()].flat();
247
+ }
248
+ function verdictOf(findings) {
249
+ if (findings.some((f) => f.severity === 'fatal'))
250
+ return 'broken';
251
+ return findings.some((f) => f.severity === 'warn') ? 'warning' : 'healthy';
252
+ }
253
+ /**
254
+ * One diagnosis per server named in the file. Usually that is one server --
255
+ * but the shared mcp.log carries every server interleaved, and attributing its
256
+ * lines is the difference between a useful report and a pile of log excerpts.
257
+ */
258
+ export function analyzeLogFile(path, host, lines = TAIL_LINES) {
259
+ const source = `${host} (${path})`;
260
+ const fileServer = serverNameFromLogPath(path);
261
+ const shared = fileServer === '(host log)';
262
+ let text;
263
+ try {
264
+ text = readTail(path);
265
+ }
266
+ catch (e) {
267
+ const finding = { code: 'log.unreadable', severity: 'warn', message: `Could not read ${path}: ${e.message}` };
268
+ return [{ spec: { name: fileServer, kind: 'log', sources: [source] }, verdict: 'warning', findings: [finding] }];
269
+ }
270
+ const grouped = new Map();
271
+ for (const [tag, findings] of scanLogText(text, lines)) {
272
+ // Only the shared log gets to rename its findings; a per-server file is
273
+ // authoritative about whose it is, whatever the lines inside claim.
274
+ const name = shared ? (tag ?? fileServer) : fileServer;
275
+ grouped.set(name, [...(grouped.get(name) ?? []), ...findings]);
276
+ }
277
+ if (grouped.size === 0)
278
+ grouped.set(fileServer, []);
279
+ return [...grouped].map(([name, findings]) => ({
280
+ spec: { name, kind: 'log', sources: [source] },
281
+ verdict: verdictOf(findings),
282
+ findings,
283
+ }));
284
+ }
285
+ /**
286
+ * Diagnose a set of log files: explicit paths, or every one we can find. One
287
+ * server can own several files (rotation) and appear in the shared log too, so
288
+ * the results are merged back into one entry per server.
289
+ */
290
+ export function diagnoseLogs(explicit = [], lines = TAIL_LINES) {
291
+ const files = explicit.length > 0 ? explicit.map((path) => ({ path, host: 'log file' })) : discoverLogFiles();
292
+ const byName = new Map();
293
+ for (const { path, host } of files) {
294
+ for (const found of analyzeLogFile(path, host, lines)) {
295
+ const existing = byName.get(found.spec.name);
296
+ if (!existing) {
297
+ byName.set(found.spec.name, found);
298
+ continue;
299
+ }
300
+ existing.spec.sources.push(...found.spec.sources);
301
+ for (const finding of found.findings) {
302
+ const duplicate = existing.findings.some((f) => f.code === finding.code && f.message === finding.message);
303
+ if (!duplicate)
304
+ existing.findings.push(finding);
305
+ }
306
+ }
307
+ }
308
+ // One file's symptom can be another file's explained failure, so the causes
309
+ // only win once everything about a server is in one place.
310
+ for (const diagnosis of byName.values()) {
311
+ diagnosis.findings = preferCauses(diagnosis.findings);
312
+ diagnosis.verdict = verdictOf(diagnosis.findings);
313
+ }
314
+ return { diagnoses: [...byName.values()], scanned: files.map((f) => f.path) };
315
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Everything quoted back from a log file or an HTTP response passes through
3
+ * here first.
4
+ *
5
+ * Config env values are easy: mcp-wtf names the key and never the value. Logs
6
+ * and HTTP responses are not, because they were written by someone else --
7
+ * hosts cheerfully log `Authorization: Bearer ...`, and a 401 body often
8
+ * echoes the token that failed. The promise is that a report can be pasted
9
+ * into a GitHub issue without reading it first, so this is applied to every
10
+ * borrowed string, not only the ones that look risky.
11
+ */
12
+ export declare function redactSecrets(text: string): string;
13
+ /** Redact, collapse whitespace and clip -- the form used for quoted evidence. */
14
+ export declare function quoteLine(line: string, max?: number): string;
15
+ export declare function redactSpecSecrets<T extends {
16
+ spec: {
17
+ env?: Record<string, string>;
18
+ headers?: Record<string, string>;
19
+ };
20
+ }>(diagnosis: T): T;