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
package/dist/discover.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Every place the well-known hosts keep their MCP configuration. Two shapes
|
|
6
|
+
* exist in the wild: `mcpServers` (Claude Desktop, Claude Code, Cursor,
|
|
7
|
+
* Windsurf) and `servers` (VS Code).
|
|
8
|
+
*/
|
|
9
|
+
export function knownConfigPaths(platform = process.platform, home = homedir(), cwd = process.cwd()) {
|
|
10
|
+
const paths = [];
|
|
11
|
+
const push = (path, host) => paths.push({ path, host });
|
|
12
|
+
if (platform === 'win32') {
|
|
13
|
+
const appdata = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming');
|
|
14
|
+
push(join(appdata, 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
|
|
15
|
+
push(join(appdata, 'Code', 'User', 'mcp.json'), 'VS Code');
|
|
16
|
+
}
|
|
17
|
+
else if (platform === 'darwin') {
|
|
18
|
+
push(join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
|
|
19
|
+
push(join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'), 'VS Code');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
push(join(home, '.config', 'Claude', 'claude_desktop_config.json'), 'Claude Desktop');
|
|
23
|
+
push(join(home, '.config', 'Code', 'User', 'mcp.json'), 'VS Code');
|
|
24
|
+
}
|
|
25
|
+
push(join(home, '.claude.json'), 'Claude Code');
|
|
26
|
+
push(join(cwd, '.mcp.json'), 'Claude Code (project)');
|
|
27
|
+
push(join(home, '.cursor', 'mcp.json'), 'Cursor');
|
|
28
|
+
push(join(cwd, '.cursor', 'mcp.json'), 'Cursor (project)');
|
|
29
|
+
push(join(home, '.codeium', 'windsurf', 'mcp_config.json'), 'Windsurf');
|
|
30
|
+
push(join(cwd, '.vscode', 'mcp.json'), 'VS Code (workspace)');
|
|
31
|
+
return paths;
|
|
32
|
+
}
|
|
33
|
+
function toSpec(name, raw, source) {
|
|
34
|
+
if (raw.disabled === true)
|
|
35
|
+
return null;
|
|
36
|
+
const sources = [source];
|
|
37
|
+
const url = typeof raw.url === 'string' ? raw.url : typeof raw.serverUrl === 'string' ? raw.serverUrl : null;
|
|
38
|
+
if (url) {
|
|
39
|
+
return { name, kind: 'http', url, headers: raw.headers ?? {}, sources };
|
|
40
|
+
}
|
|
41
|
+
if (typeof raw.command !== 'string' || !raw.command)
|
|
42
|
+
return null;
|
|
43
|
+
const args = Array.isArray(raw.args) ? raw.args.filter((a) => typeof a === 'string') : [];
|
|
44
|
+
const spec = {
|
|
45
|
+
name,
|
|
46
|
+
kind: 'stdio',
|
|
47
|
+
command: raw.command,
|
|
48
|
+
args,
|
|
49
|
+
env: raw.env ?? {},
|
|
50
|
+
cwd: typeof raw.cwd === 'string' ? raw.cwd : undefined,
|
|
51
|
+
sources,
|
|
52
|
+
};
|
|
53
|
+
// VS Code configs can reference interactive inputs (${input:apiKey}); those
|
|
54
|
+
// servers cannot be launched non-interactively, but they should still show
|
|
55
|
+
// up in the report rather than silently vanish.
|
|
56
|
+
const joined = [raw.command, ...args, JSON.stringify(spec.env)].join(' ');
|
|
57
|
+
if (joined.includes('${input:')) {
|
|
58
|
+
spec.unlaunchable = 'uses ${input:...} placeholders that need interactive values';
|
|
59
|
+
}
|
|
60
|
+
return spec;
|
|
61
|
+
}
|
|
62
|
+
/** Pull every server out of one config file. Returns [] when unreadable. */
|
|
63
|
+
export function readConfigFile(path, host) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
const out = [];
|
|
72
|
+
const source = `${host} (${path})`;
|
|
73
|
+
const collect = (block) => {
|
|
74
|
+
if (!block || typeof block !== 'object' || Array.isArray(block))
|
|
75
|
+
return;
|
|
76
|
+
for (const [name, entry] of Object.entries(block)) {
|
|
77
|
+
const spec = toSpec(name, entry ?? {}, source);
|
|
78
|
+
if (spec)
|
|
79
|
+
out.push(spec);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
collect(parsed['mcpServers']);
|
|
83
|
+
collect(parsed['servers']);
|
|
84
|
+
// Claude Code also nests per-project servers under `projects`.
|
|
85
|
+
const projects = parsed['projects'];
|
|
86
|
+
if (projects && typeof projects === 'object' && !Array.isArray(projects)) {
|
|
87
|
+
for (const proj of Object.values(projects)) {
|
|
88
|
+
collect(proj?.['mcpServers']);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Search every known location, merge duplicates (the same server configured
|
|
95
|
+
* in several hosts is one server with several sources), and report which
|
|
96
|
+
* config files were actually found -- and which exist but cannot be parsed,
|
|
97
|
+
* because a malformed config is the diagnosis, not a thing to skip.
|
|
98
|
+
*/
|
|
99
|
+
export function discover(explicitConfig) {
|
|
100
|
+
const candidates = explicitConfig
|
|
101
|
+
? [{ path: explicitConfig, host: 'config' }]
|
|
102
|
+
: knownConfigPaths();
|
|
103
|
+
const configsSearched = [];
|
|
104
|
+
const configErrors = [];
|
|
105
|
+
const byIdentity = new Map();
|
|
106
|
+
for (const { path, host } of candidates) {
|
|
107
|
+
if (!existsSync(path))
|
|
108
|
+
continue;
|
|
109
|
+
configsSearched.push(path);
|
|
110
|
+
try {
|
|
111
|
+
JSON.parse(readFileSync(path, 'utf8'));
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
configErrors.push({ path, error: e.message });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const spec of readConfigFile(path, host)) {
|
|
118
|
+
// Identity is what would actually run, not the display name -- two hosts
|
|
119
|
+
// pointing at the same command are one server. The environment is part
|
|
120
|
+
// of what runs: the same command with a different env (one with a real
|
|
121
|
+
// token, one with a placeholder) is a different server, and merging
|
|
122
|
+
// them would silently drop one from the report.
|
|
123
|
+
const envKey = JSON.stringify(Object.entries(spec.env ?? {}).sort());
|
|
124
|
+
const identity = spec.kind === 'http'
|
|
125
|
+
? `http|${spec.url}`
|
|
126
|
+
: `stdio|${spec.command}|${(spec.args ?? []).join(' ')}|${envKey}|${spec.cwd ?? ''}`;
|
|
127
|
+
const existing = byIdentity.get(identity);
|
|
128
|
+
if (existing)
|
|
129
|
+
existing.sources.push(...spec.sources);
|
|
130
|
+
else
|
|
131
|
+
byIdentity.set(identity, spec);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { specs: [...byIdentity.values()], configsSearched, configErrors };
|
|
135
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { discover, readConfigFile, knownConfigPaths } from './discover.js';
|
|
2
|
+
export { diagnoseServer, diagnoseAll, staticChecks, resolveCommand } from './diagnose.js';
|
|
3
|
+
export { renderTerminal } from './report/terminal.js';
|
|
4
|
+
export { McpClient, StdioTransport, HttpTransport } from './client/index.js';
|
|
5
|
+
export type { Diagnosis, Finding, ServerSpec, Verdict, WtfOptions, WtfReport } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { discover, readConfigFile, knownConfigPaths } from './discover.js';
|
|
2
|
+
export { diagnoseServer, diagnoseAll, staticChecks, resolveCommand } from './diagnose.js';
|
|
3
|
+
export { renderTerminal } from './report/terminal.js';
|
|
4
|
+
export { McpClient, StdioTransport, HttpTransport } from './client/index.js';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const useColor = !process.env['NO_COLOR'] && (process.env['FORCE_COLOR'] === '1' || process.stdout.isTTY === true);
|
|
2
|
+
const c = {
|
|
3
|
+
reset: useColor ? '\x1b[0m' : '',
|
|
4
|
+
bold: useColor ? '\x1b[1m' : '',
|
|
5
|
+
red: useColor ? '\x1b[31m' : '',
|
|
6
|
+
green: useColor ? '\x1b[32m' : '',
|
|
7
|
+
yellow: useColor ? '\x1b[33m' : '',
|
|
8
|
+
gray: useColor ? '\x1b[90m' : '',
|
|
9
|
+
};
|
|
10
|
+
const BADGE = {
|
|
11
|
+
healthy: `${c.green} OK ${c.reset}`,
|
|
12
|
+
warning: `${c.yellow}WARN${c.reset}`,
|
|
13
|
+
broken: `${c.red}DEAD${c.reset}`,
|
|
14
|
+
};
|
|
15
|
+
function hostsOf(d) {
|
|
16
|
+
return [...new Set(d.spec.sources.map((s) => s.split(' (')[0] ?? s))].join(', ');
|
|
17
|
+
}
|
|
18
|
+
export function renderTerminal(report) {
|
|
19
|
+
const lines = [];
|
|
20
|
+
const { diagnoses } = report;
|
|
21
|
+
lines.push('');
|
|
22
|
+
const dead = report.broken;
|
|
23
|
+
const headline = dead === 0
|
|
24
|
+
? `all ${diagnoses.length} MCP server${diagnoses.length === 1 ? '' : 's'} are alive`
|
|
25
|
+
: `${dead} of ${diagnoses.length} MCP server${diagnoses.length === 1 ? '' : 's'} ${dead === 1 ? 'is' : 'are'} broken -- here is exactly why`;
|
|
26
|
+
lines.push(` ${c.bold}mcp-wtf${c.reset} ${headline}`);
|
|
27
|
+
lines.push(` ${c.gray}${report.configsSearched.length} config${report.configsSearched.length === 1 ? '' : 's'} searched${report.configErrors.length ? `, ${report.configErrors.length} unreadable` : ''}${c.reset}`);
|
|
28
|
+
lines.push('');
|
|
29
|
+
for (const err of report.configErrors) {
|
|
30
|
+
lines.push(` ${c.red}DEAD${c.reset} ${c.bold}${err.path}${c.reset}`);
|
|
31
|
+
lines.push(` ${c.red}The config file itself is not valid JSON:${c.reset} ${err.error}`);
|
|
32
|
+
lines.push(` ${c.gray}Every server in this file is invisible to its host until this is fixed.${c.reset}`);
|
|
33
|
+
lines.push('');
|
|
34
|
+
}
|
|
35
|
+
// Broken first -- they are what the user came for.
|
|
36
|
+
const order = ['broken', 'warning', 'healthy'];
|
|
37
|
+
for (const verdict of order) {
|
|
38
|
+
for (const d of diagnoses.filter((x) => x.verdict === verdict)) {
|
|
39
|
+
const title = d.serverInfo?.name && d.serverInfo.name !== d.spec.name
|
|
40
|
+
? `${d.spec.name} ${c.gray}(${d.serverInfo.name}${d.serverInfo.version ? ` v${d.serverInfo.version}` : ''})${c.reset}`
|
|
41
|
+
: d.spec.name;
|
|
42
|
+
const meta = [hostsOf(d)];
|
|
43
|
+
if (d.toolCount !== undefined)
|
|
44
|
+
meta.push(`${d.toolCount} tools`);
|
|
45
|
+
if (d.connectMs !== undefined)
|
|
46
|
+
meta.push(`${d.connectMs}ms`);
|
|
47
|
+
lines.push(` ${BADGE[d.verdict]} ${c.bold}${title}${c.reset} ${c.gray}${meta.join(', ')}${c.reset}`);
|
|
48
|
+
for (const f of d.findings) {
|
|
49
|
+
const mark = f.severity === 'fatal' ? `${c.red}x${c.reset}` : f.severity === 'warn' ? `${c.yellow}!${c.reset}` : `${c.gray}i${c.reset}`;
|
|
50
|
+
lines.push(` ${mark} ${f.message}`);
|
|
51
|
+
if (f.fix) {
|
|
52
|
+
wrap(f.fix, 90).forEach((fl, i) => {
|
|
53
|
+
lines.push(i === 0 ? ` ${c.green}fix:${c.reset} ${fl}` : ` ${fl}`);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (f.detail) {
|
|
57
|
+
for (const dl of f.detail.split('\n').slice(0, 8))
|
|
58
|
+
lines.push(` ${c.gray}${dl}${c.reset}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
lines.push('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (report.broken)
|
|
66
|
+
parts.push(`${c.red}${report.broken} broken${c.reset}`);
|
|
67
|
+
if (report.warnings)
|
|
68
|
+
parts.push(`${c.yellow}${report.warnings} with warnings${c.reset}`);
|
|
69
|
+
if (report.healthy)
|
|
70
|
+
parts.push(`${c.green}${report.healthy} healthy${c.reset}`);
|
|
71
|
+
lines.push(` ${c.gray}${'-'.repeat(64)}${c.reset}`);
|
|
72
|
+
lines.push(` ${parts.join(`${c.gray} | ${c.reset}`)} ${c.gray}${(report.durationMs / 1000).toFixed(1)}s${c.reset}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
function wrap(text, width) {
|
|
77
|
+
const words = text.split(' ');
|
|
78
|
+
const out = [];
|
|
79
|
+
let line = '';
|
|
80
|
+
for (const w of words) {
|
|
81
|
+
if (line && line.length + w.length + 1 > width) {
|
|
82
|
+
out.push(line);
|
|
83
|
+
line = w;
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
line = line ? `${line} ${w}` : w;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (line)
|
|
90
|
+
out.push(line);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ToolDef {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
inputSchema?: JsonSchema;
|
|
5
|
+
}
|
|
6
|
+
export interface JsonSchema {
|
|
7
|
+
type?: string | string[];
|
|
8
|
+
properties?: Record<string, JsonSchema>;
|
|
9
|
+
required?: string[];
|
|
10
|
+
[k: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
/** One MCP server as found in a host config file (or given on the CLI). */
|
|
13
|
+
export interface ServerSpec {
|
|
14
|
+
name: string;
|
|
15
|
+
kind: 'stdio' | 'http';
|
|
16
|
+
command?: string;
|
|
17
|
+
args?: string[];
|
|
18
|
+
env?: Record<string, string>;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
sources: string[];
|
|
23
|
+
unlaunchable?: string;
|
|
24
|
+
}
|
|
25
|
+
export type Verdict = 'healthy' | 'broken' | 'warning';
|
|
26
|
+
export interface Finding {
|
|
27
|
+
/** Stable dotted id, e.g. `cmd.not_found`. */
|
|
28
|
+
code: string;
|
|
29
|
+
severity: 'fatal' | 'warn' | 'info';
|
|
30
|
+
/** What is wrong, in one plain-language sentence. */
|
|
31
|
+
message: string;
|
|
32
|
+
/** What to do about it, concretely, naming the file to edit when known. */
|
|
33
|
+
fix?: string;
|
|
34
|
+
/** Raw evidence: the stderr tail, the offending line, the resolved path. */
|
|
35
|
+
detail?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface Diagnosis {
|
|
38
|
+
spec: ServerSpec;
|
|
39
|
+
verdict: Verdict;
|
|
40
|
+
findings: Finding[];
|
|
41
|
+
/** Populated when the handshake succeeded. */
|
|
42
|
+
serverInfo?: {
|
|
43
|
+
name?: string;
|
|
44
|
+
version?: string;
|
|
45
|
+
} | null;
|
|
46
|
+
toolCount?: number;
|
|
47
|
+
connectMs?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface WtfOptions {
|
|
50
|
+
timeoutMs: number;
|
|
51
|
+
concurrency: number;
|
|
52
|
+
}
|
|
53
|
+
export interface WtfReport {
|
|
54
|
+
diagnoses: Diagnosis[];
|
|
55
|
+
configsSearched: string[];
|
|
56
|
+
/** Config files that exist but could not be parsed, with the parse error. */
|
|
57
|
+
configErrors: Array<{
|
|
58
|
+
path: string;
|
|
59
|
+
error: string;
|
|
60
|
+
}>;
|
|
61
|
+
healthy: number;
|
|
62
|
+
broken: number;
|
|
63
|
+
warnings: number;
|
|
64
|
+
durationMs: number;
|
|
65
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-wtf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your MCP server won't connect. Find out why in 10 seconds. Diagnoses every server in your Claude Desktop, Claude Code, Cursor and VS Code configs: missing commands, placeholder API keys, stdout pollution, silent crashes - with the exact fix for each. Zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"mcp-server",
|
|
9
|
+
"troubleshooting",
|
|
10
|
+
"doctor",
|
|
11
|
+
"diagnostics",
|
|
12
|
+
"failed-to-connect",
|
|
13
|
+
"disconnected",
|
|
14
|
+
"debug",
|
|
15
|
+
"claude",
|
|
16
|
+
"claude-desktop",
|
|
17
|
+
"claude-code",
|
|
18
|
+
"cursor",
|
|
19
|
+
"cli",
|
|
20
|
+
"fix"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"mcp-wtf": "dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"types": "dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"test": "npm run build && node --test",
|
|
46
|
+
"prepublishOnly": "npm run build"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^22.10.0",
|
|
50
|
+
"typescript": "^5.7.0"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Beeeeen/mcp-wtf.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/Beeeeen/mcp-wtf#readme",
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/Beeeeen/mcp-wtf/issues"
|
|
59
|
+
},
|
|
60
|
+
"author": "Ben Yang <ben@yangjiawei.com>"
|
|
61
|
+
}
|