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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BenYang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # mcp-wtf
2
+
3
+ **Your MCP server won't connect. Find out why in 10 seconds.**
4
+
5
+ [![CI](https://github.com/Beeeeen/mcp-wtf/actions/workflows/ci.yml/badge.svg)](https://github.com/Beeeeen/mcp-wtf/actions/workflows/ci.yml)
6
+ [![npm](https://img.shields.io/npm/v/mcp-wtf.svg)](https://www.npmjs.com/package/mcp-wtf)
7
+ [![license](https://img.shields.io/npm/l/mcp-wtf.svg)](./LICENSE)
8
+
9
+ ```bash
10
+ npx mcp-wtf
11
+ ```
12
+
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
+
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:
16
+
17
+ ```
18
+ mcp-wtf 5 of 7 MCP servers are broken -- here is exactly why
19
+ 2 configs searched
20
+
21
+ DEAD github Claude Desktop
22
+ x env.GITHUB_PERSONAL_ACCESS_TOKEN is still the placeholder "YOUR_TOKEN_HERE".
23
+ fix: Put the real value into the "env" block of this server in
24
+ claude_desktop_config.json.
25
+
26
+ DEAD crashy Claude Code
27
+ x The server crashed because a module is missing: left-pad.
28
+ fix: Its dependencies are not installed. If this is your own server, run
29
+ npm install in its directory.
30
+ Error: Cannot find module 'left-pad'
31
+
32
+ DEAD pasted-cmd Cursor
33
+ x The command is "npx -y @modelcontextprotocol/server-memory" -- arguments
34
+ are baked into the command string.
35
+ fix: Split it: {"command": "npx", "args": ["-y","@modelcontextprotocol/server-memory"]}
36
+
37
+ DEAD py-tools Claude Desktop
38
+ x Command not found: "uvx". This produces the classic "spawn uvx ENOENT" error.
39
+ fix: uv is not on the PATH this process sees. Use the absolute path to uvx
40
+ (run `which uvx` / `where uvx`), or install uv system-wide.
41
+
42
+ WARN noisy Claude Desktop, 1 tool, 1ms
43
+ ! The handshake succeeded, but the server wrote 2 non-JSON lines to stdout.
44
+ Some hosts survive this; others disconnect at random.
45
+ fix: Logs belong on stderr. This is the most common cause of
46
+ "works sometimes, disconnects randomly".
47
+ > [INFO] server booting...
48
+
49
+ OK filesystem Claude Desktop, 14 tools, 240ms
50
+
51
+ ----------------------------------------------------------------
52
+ 5 broken | 1 with warnings | 1 healthy 4.1s
53
+ ```
54
+
55
+ No install, no config, no account, zero dependencies.
56
+
57
+ ---
58
+
59
+ ## What it catches
60
+
61
+ **Config problems** — the config file itself isn't valid JSON (every server in it is invisible until fixed, and no host tells you); arguments pasted into the `command` string; a `cwd` that doesn't exist; duplicate entries across hosts.
62
+
63
+ **The classic PATH trap** — `spawn npx ENOENT`. GUI-launched apps on macOS and Windows get a much shorter `PATH` than your terminal, so `npx`, `uvx` and `docker` work in your shell and fail inside the host. mcp-wtf resolves the command exactly the way the OS launcher will (including `PATHEXT` on Windows) and tells you what to paste instead.
64
+
65
+ **Placeholder credentials** — `YOUR_TOKEN_HERE`, `<your-key>`, `xxx`, `changeme`, `${input:...}`, or an empty `*_TOKEN`. Copied from a README, never replaced, and the server fails with an error the host never shows you.
66
+
67
+ **Startup crashes, decoded** — missing node modules (`Cannot find module`), packages that don't exist on npm or PyPI, ports already in use from a zombie session, rejected API keys, referenced paths that don't exist, unhandled exceptions. Each stderr signature maps to a plain-language diagnosis, and the raw stderr is quoted underneath.
68
+
69
+ **stdout pollution** — the server logs to stdout, which *is* the protocol channel on stdio transport. This is the answer to "it works sometimes and then randomly disconnects", and no client library will ever show it to you, because they all silently discard bytes they can't parse. mcp-wtf keeps them.
70
+
71
+ **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
+
73
+ ## Usage
74
+
75
+ ```bash
76
+ npx mcp-wtf # diagnose everything configured on this machine
77
+ npx mcp-wtf --server github # just one server
78
+ npx mcp-wtf --config ./mcp.json # one specific config file
79
+ npx mcp-wtf -- node build/index.js # a server not configured anywhere yet
80
+ npx mcp-wtf --url http://localhost:3000/mcp
81
+ ```
82
+
83
+ `--json` for scripts. Exit codes: `0` all healthy, `1` something is broken, `2` could not run.
84
+
85
+ ## Safety
86
+
87
+ - 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.
89
+
90
+ ## See also
91
+
92
+ The rest of the toolchain, built on the same zero-dependency MCP client:
93
+
94
+ - [**context-xray**](https://github.com/Beeeeen/context-xray) — what your MCP servers *cost*: the context-window tokens they add to every request.
95
+ - [**mcp-probe**](https://github.com/Beeeeen/mcp-probe) — whether your MCP server *behaves*: conformance and robustness tests for CI.
96
+
97
+ mcp-wtf answers "why won't it connect", context-xray answers "what is it costing me", mcp-probe answers "will it break my users".
98
+
99
+ ## License
100
+
101
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { discover } from './discover.js';
4
+ import { diagnoseAll } from './diagnose.js';
5
+ import { renderTerminal } from './report/terminal.js';
6
+ const VERSION = '0.1.0';
7
+ const HELP = `
8
+ mcp-wtf ${VERSION}
9
+ Your MCP server won't connect. Find out why in 10 seconds.
10
+
11
+ USAGE
12
+ mcp-wtf diagnose every configured MCP server
13
+ mcp-wtf --config <file> diagnose the servers in one config file
14
+ mcp-wtf --server <name> only the named server(s); repeatable
15
+ mcp-wtf -- <command> [...] diagnose one stdio server directly
16
+ mcp-wtf --url <url> diagnose one streamable-HTTP server
17
+
18
+ OPTIONS
19
+ --json machine-readable report
20
+ --timeout <ms> per-server handshake timeout (default 15000)
21
+ --concurrency <n> servers checked at once (default 4)
22
+
23
+ WHAT IT CHECKS
24
+ the config itself valid JSON, commands split from args, cwd exists
25
+ the command actually resolvable on PATH (with PATHEXT on
26
+ Windows) -- the source of "spawn npx ENOENT"
27
+ the environment placeholder API keys ("YOUR_KEY_HERE"), empty
28
+ secrets, \${input:...} entries
29
+ the launch crashes on start, missing modules, missing
30
+ packages, ports already in use, rejected keys
31
+ the protocol handshake completes, stdout carries only JSON
32
+ (stdout pollution = "disconnects randomly")
33
+
34
+ Exit codes: 0 all healthy, 1 something is broken, 2 could not run.
35
+
36
+ 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.
39
+ `;
40
+ function parseArgs(argv) {
41
+ const out = {
42
+ options: { timeoutMs: 15_000, concurrency: 4 },
43
+ json: false,
44
+ serverFilter: [],
45
+ help: false,
46
+ version: false,
47
+ };
48
+ let url = null;
49
+ const headers = {};
50
+ for (let i = 0; i < argv.length; i++) {
51
+ const arg = argv[i];
52
+ if (arg === '--') {
53
+ const rest = argv.slice(i + 1);
54
+ if (rest.length === 0)
55
+ return { ...out, error: '`--` must be followed by the server command' };
56
+ out.direct = { name: rest.join(' '), kind: 'stdio', command: rest[0], args: rest.slice(1), sources: ['command line'] };
57
+ break;
58
+ }
59
+ const next = () => argv[++i];
60
+ switch (arg) {
61
+ case '-h':
62
+ case '--help':
63
+ out.help = true;
64
+ break;
65
+ case '-v':
66
+ case '--version':
67
+ out.version = true;
68
+ break;
69
+ case '--json':
70
+ out.json = true;
71
+ break;
72
+ case '--config':
73
+ out.config = next();
74
+ break;
75
+ case '--server':
76
+ out.serverFilter.push(...(next() ?? '').split(',').filter(Boolean));
77
+ break;
78
+ case '--url':
79
+ url = next() ?? null;
80
+ break;
81
+ case '--header': {
82
+ const raw = next() ?? '';
83
+ const idx = raw.indexOf(':');
84
+ if (idx < 1)
85
+ return { ...out, error: `--header expects "Name: value", got "${raw}"` };
86
+ headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
87
+ break;
88
+ }
89
+ case '--timeout': {
90
+ const v = Number(next());
91
+ if (!Number.isFinite(v) || v <= 0)
92
+ return { ...out, error: '--timeout needs a positive number of milliseconds' };
93
+ out.options.timeoutMs = v;
94
+ break;
95
+ }
96
+ case '--concurrency': {
97
+ const v = Number(next());
98
+ if (!Number.isFinite(v) || v <= 0)
99
+ return { ...out, error: '--concurrency needs a positive number' };
100
+ out.options.concurrency = Math.floor(v);
101
+ break;
102
+ }
103
+ default:
104
+ return { ...out, error: `Unknown option "${arg}". Try --help.` };
105
+ }
106
+ }
107
+ if (url)
108
+ out.direct = { name: url, kind: 'http', url, headers, sources: ['command line'] };
109
+ return out;
110
+ }
111
+ async function main() {
112
+ const parsed = parseArgs(process.argv.slice(2));
113
+ if (parsed.help) {
114
+ process.stdout.write(HELP);
115
+ process.exit(0);
116
+ }
117
+ if (parsed.version) {
118
+ process.stdout.write(VERSION + '\n');
119
+ process.exit(0);
120
+ }
121
+ if (parsed.error) {
122
+ process.stderr.write(`mcp-wtf: ${parsed.error}\n`);
123
+ process.exit(2);
124
+ }
125
+ let specs;
126
+ let configsSearched = [];
127
+ let configErrors = [];
128
+ if (parsed.direct) {
129
+ specs = [parsed.direct];
130
+ }
131
+ else {
132
+ if (parsed.config && !existsSync(parsed.config)) {
133
+ process.stderr.write(`mcp-wtf: no such config file: ${parsed.config}\n`);
134
+ process.exit(2);
135
+ }
136
+ const found = discover(parsed.config);
137
+ specs = found.specs;
138
+ configsSearched = found.configsSearched;
139
+ configErrors = found.configErrors;
140
+ if (parsed.serverFilter.length > 0) {
141
+ specs = specs.filter((s) => parsed.serverFilter.includes(s.name));
142
+ const missing = parsed.serverFilter.filter((f) => !specs.some((s) => s.name === f));
143
+ if (missing.length > 0) {
144
+ process.stderr.write(`mcp-wtf: no server named ${missing.map((m) => `"${m}"`).join(', ')} in the discovered configs\n`);
145
+ process.exit(2);
146
+ }
147
+ }
148
+ }
149
+ if (specs.length === 0 && configErrors.length === 0) {
150
+ process.stderr.write(configsSearched.length === 0
151
+ ? 'mcp-wtf: no MCP config files found. Point it at one with --config <file>, or at a server with `mcp-wtf -- <command>`.\n'
152
+ : `mcp-wtf: searched ${configsSearched.length} config file(s) but found no MCP servers in them.\n`);
153
+ process.exit(2);
154
+ }
155
+ const t0 = Date.now();
156
+ const diagnoses = await diagnoseAll(specs, parsed.options);
157
+ const report = {
158
+ diagnoses,
159
+ configsSearched,
160
+ configErrors,
161
+ healthy: diagnoses.filter((d) => d.verdict === 'healthy').length,
162
+ broken: diagnoses.filter((d) => d.verdict === 'broken').length,
163
+ warnings: diagnoses.filter((d) => d.verdict === 'warning').length,
164
+ 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);
171
+ }
172
+ main().catch((e) => {
173
+ process.stderr.write(`mcp-wtf: internal error: ${e.stack ?? String(e)}\n`);
174
+ process.exit(2);
175
+ });
@@ -0,0 +1,37 @@
1
+ import type { Transport } from './transport.js';
2
+ import { type JsonRpcResponse } from './jsonrpc.js';
3
+ export interface HttpOptions {
4
+ url: string;
5
+ headers?: Record<string, string>;
6
+ }
7
+ /**
8
+ * Streamable HTTP transport. Each request is a POST; the server may answer
9
+ * with `application/json` or an SSE stream, and may hand us a session id on
10
+ * the initialize response that must be echoed on every later call.
11
+ */
12
+ export declare class HttpTransport implements Transport {
13
+ private opts;
14
+ readonly kind = "http";
15
+ readonly target: string;
16
+ readonly stdoutNoise: string[];
17
+ readonly stderr: string[];
18
+ readonly serverNotifications: JsonRpcResponse[];
19
+ private nextId;
20
+ private sessionId;
21
+ private closed;
22
+ /** Populated when a response arrives with a shape we could not read. */
23
+ readonly protocolNotes: string[];
24
+ constructor(opts: HttpOptions);
25
+ start(): Promise<void>;
26
+ private headers;
27
+ request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
28
+ requestRaw(payload: Record<string, unknown>, _id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
29
+ private post;
30
+ /** Pull the first `data:` frame that carries a JSON-RPC reply. */
31
+ private parseSse;
32
+ notify(method: string, params?: unknown): void;
33
+ writeRaw(text: string): void;
34
+ isAlive(): boolean;
35
+ exitInfo(): null;
36
+ close(): Promise<void>;
37
+ }
@@ -0,0 +1,133 @@
1
+ import { TimeoutError, TransportClosedError } from './jsonrpc.js';
2
+ /**
3
+ * Streamable HTTP transport. Each request is a POST; the server may answer
4
+ * with `application/json` or an SSE stream, and may hand us a session id on
5
+ * the initialize response that must be echoed on every later call.
6
+ */
7
+ export class HttpTransport {
8
+ opts;
9
+ kind = 'http';
10
+ target;
11
+ stdoutNoise = [];
12
+ stderr = [];
13
+ serverNotifications = [];
14
+ nextId = 1;
15
+ sessionId = null;
16
+ closed = false;
17
+ /** Populated when a response arrives with a shape we could not read. */
18
+ protocolNotes = [];
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ this.target = opts.url;
22
+ }
23
+ async start() {
24
+ /* Nothing to spawn; the first POST is the real connection test. */
25
+ }
26
+ headers() {
27
+ const h = {
28
+ 'content-type': 'application/json',
29
+ accept: 'application/json, text/event-stream',
30
+ ...this.opts.headers,
31
+ };
32
+ if (this.sessionId)
33
+ h['mcp-session-id'] = this.sessionId;
34
+ return h;
35
+ }
36
+ request(method, params, timeoutMs = 10_000) {
37
+ const id = this.nextId++;
38
+ return this.post({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }, method, timeoutMs);
39
+ }
40
+ requestRaw(payload, _id, method, timeoutMs = 10_000) {
41
+ return this.post(payload, method, timeoutMs);
42
+ }
43
+ async post(payload, method, timeoutMs) {
44
+ if (this.closed)
45
+ throw new TransportClosedError('Transport already closed');
46
+ const ac = new AbortController();
47
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
48
+ let res;
49
+ try {
50
+ res = await fetch(this.opts.url, {
51
+ method: 'POST',
52
+ headers: this.headers(),
53
+ body: JSON.stringify(payload),
54
+ signal: ac.signal,
55
+ });
56
+ }
57
+ catch (e) {
58
+ clearTimeout(timer);
59
+ if (ac.signal.aborted)
60
+ throw new TimeoutError(method, timeoutMs);
61
+ throw new TransportClosedError(`POST ${this.opts.url} failed: ${e.message}`);
62
+ }
63
+ clearTimeout(timer);
64
+ const sid = res.headers.get('mcp-session-id');
65
+ if (sid)
66
+ this.sessionId = sid;
67
+ // 202 with no body is the legal answer to a notification.
68
+ if (res.status === 202)
69
+ return {};
70
+ const ctype = res.headers.get('content-type') ?? '';
71
+ const body = await res.text();
72
+ if (!res.ok) {
73
+ throw new TransportClosedError(`HTTP ${res.status} ${res.statusText} from ${this.opts.url}: ${body.slice(0, 400)}`);
74
+ }
75
+ if (ctype.includes('text/event-stream'))
76
+ return this.parseSse(body, method);
77
+ if (!body.trim())
78
+ return {};
79
+ try {
80
+ return JSON.parse(body);
81
+ }
82
+ catch {
83
+ this.protocolNotes.push(`Non-JSON body for \`${method}\` (content-type: ${ctype || 'none'}): ${body.slice(0, 200)}`);
84
+ throw new TransportClosedError(`Server returned unparseable body for \`${method}\``);
85
+ }
86
+ }
87
+ /** Pull the first `data:` frame that carries a JSON-RPC reply. */
88
+ parseSse(body, method) {
89
+ const frames = body.split(/\n\n/);
90
+ let last = null;
91
+ for (const frame of frames) {
92
+ const data = frame
93
+ .split('\n')
94
+ .filter((l) => l.startsWith('data:'))
95
+ .map((l) => l.slice(5).trim())
96
+ .join('');
97
+ if (!data)
98
+ continue;
99
+ let msg;
100
+ try {
101
+ msg = JSON.parse(data);
102
+ }
103
+ catch {
104
+ this.protocolNotes.push(`Unparseable SSE frame during \`${method}\`: ${data.slice(0, 200)}`);
105
+ continue;
106
+ }
107
+ if (msg.id !== undefined && msg.id !== null)
108
+ last = msg;
109
+ else
110
+ this.serverNotifications.push(msg);
111
+ }
112
+ if (!last)
113
+ throw new TransportClosedError(`SSE stream for \`${method}\` carried no JSON-RPC response`);
114
+ return last;
115
+ }
116
+ notify(method, params) {
117
+ void this.post({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }, method, 5000).catch(() => {
118
+ /* Notifications are fire-and-forget; a failure here is not a check result. */
119
+ });
120
+ }
121
+ writeRaw(text) {
122
+ void fetch(this.opts.url, { method: 'POST', headers: this.headers(), body: text }).catch(() => { });
123
+ }
124
+ isAlive() {
125
+ return !this.closed;
126
+ }
127
+ exitInfo() {
128
+ return null;
129
+ }
130
+ async close() {
131
+ this.closed = true;
132
+ }
133
+ }
@@ -0,0 +1,47 @@
1
+ import type { Transport } from './transport.js';
2
+ import type { JsonRpcResponse } from './jsonrpc.js';
3
+ import type { ToolDef } from '../types.js';
4
+ export { StdioTransport } from './stdio.js';
5
+ export { HttpTransport } from './http.js';
6
+ export type { Transport } from './transport.js';
7
+ /** Versions we will negotiate, newest first. */
8
+ export declare const SUPPORTED_PROTOCOL_VERSIONS: string[];
9
+ export interface HandshakeResult {
10
+ raw: JsonRpcResponse;
11
+ protocolVersion: string | null;
12
+ serverInfo: {
13
+ name?: string;
14
+ version?: string;
15
+ } | null;
16
+ capabilities: Record<string, unknown>;
17
+ ms: number;
18
+ }
19
+ /**
20
+ * A thin, unopinionated MCP client. It performs the handshake and exposes the
21
+ * few calls the checks need -- but never normalises or repairs a response,
22
+ * because the checks have to see exactly what the server sent.
23
+ */
24
+ export declare class McpClient {
25
+ readonly transport: Transport;
26
+ private timeoutMs;
27
+ constructor(transport: Transport, timeoutMs?: number);
28
+ get target(): string;
29
+ start(): Promise<void>;
30
+ initialize(protocolVersion?: string): Promise<HandshakeResult>;
31
+ /** The spec requires this notification before any other request. */
32
+ notifyInitialized(): void;
33
+ call(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
34
+ callRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
35
+ /** Walk `nextCursor` so a paginated server does not under-report. */
36
+ listAll(method: 'tools/list' | 'resources/list' | 'prompts/list', key: string): Promise<{
37
+ items: unknown[];
38
+ pages: number;
39
+ error?: JsonRpcResponse;
40
+ }>;
41
+ listTools(): Promise<{
42
+ tools: ToolDef[];
43
+ pages: number;
44
+ error?: JsonRpcResponse;
45
+ }>;
46
+ close(): Promise<void>;
47
+ }
@@ -0,0 +1,79 @@
1
+ export { StdioTransport } from './stdio.js';
2
+ export { HttpTransport } from './http.js';
3
+ /** Versions we will negotiate, newest first. */
4
+ export const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
5
+ /**
6
+ * A thin, unopinionated MCP client. It performs the handshake and exposes the
7
+ * few calls the checks need -- but never normalises or repairs a response,
8
+ * because the checks have to see exactly what the server sent.
9
+ */
10
+ export class McpClient {
11
+ transport;
12
+ timeoutMs;
13
+ constructor(transport, timeoutMs = 10_000) {
14
+ this.transport = transport;
15
+ this.timeoutMs = timeoutMs;
16
+ }
17
+ get target() {
18
+ return this.transport.target;
19
+ }
20
+ async start() {
21
+ await this.transport.start();
22
+ }
23
+ async initialize(protocolVersion = SUPPORTED_PROTOCOL_VERSIONS[0]) {
24
+ const t0 = Date.now();
25
+ const raw = await this.transport.request('initialize', {
26
+ protocolVersion,
27
+ capabilities: { roots: { listChanged: true }, sampling: {}, elicitation: {} },
28
+ clientInfo: { name: 'mcp-wtf', version: '0.1.0' },
29
+ }, this.timeoutMs);
30
+ const ms = Date.now() - t0;
31
+ const result = (raw.result ?? {});
32
+ return {
33
+ raw,
34
+ ms,
35
+ protocolVersion: typeof result['protocolVersion'] === 'string' ? result['protocolVersion'] : null,
36
+ serverInfo: result['serverInfo'] ?? null,
37
+ capabilities: result['capabilities'] ?? {},
38
+ };
39
+ }
40
+ /** The spec requires this notification before any other request. */
41
+ notifyInitialized() {
42
+ this.transport.notify('notifications/initialized');
43
+ }
44
+ call(method, params, timeoutMs) {
45
+ return this.transport.request(method, params, timeoutMs ?? this.timeoutMs);
46
+ }
47
+ callRaw(payload, id, method, timeoutMs) {
48
+ return this.transport.requestRaw(payload, id, method, timeoutMs ?? this.timeoutMs);
49
+ }
50
+ /** Walk `nextCursor` so a paginated server does not under-report. */
51
+ async listAll(method, key) {
52
+ const items = [];
53
+ let cursor;
54
+ let pages = 0;
55
+ for (;;) {
56
+ const res = await this.call(method, cursor ? { cursor } : {});
57
+ if (res.error)
58
+ return { items, pages, error: res };
59
+ pages++;
60
+ const result = (res.result ?? {});
61
+ const batch = result[key];
62
+ if (Array.isArray(batch))
63
+ items.push(...batch);
64
+ const next = result['nextCursor'];
65
+ if (typeof next === 'string' && next && pages < 50)
66
+ cursor = next;
67
+ else
68
+ break;
69
+ }
70
+ return { items, pages };
71
+ }
72
+ async listTools() {
73
+ const { items, pages, error } = await this.listAll('tools/list', 'tools');
74
+ return { tools: items, pages, error };
75
+ }
76
+ close() {
77
+ return this.transport.close();
78
+ }
79
+ }
@@ -0,0 +1,40 @@
1
+ export interface JsonRpcRequest {
2
+ jsonrpc: '2.0';
3
+ id: number | string;
4
+ method: string;
5
+ params?: unknown;
6
+ }
7
+ export interface JsonRpcNotification {
8
+ jsonrpc: '2.0';
9
+ method: string;
10
+ params?: unknown;
11
+ }
12
+ export interface JsonRpcError {
13
+ code: number;
14
+ message: string;
15
+ data?: unknown;
16
+ }
17
+ export interface JsonRpcResponse {
18
+ jsonrpc?: string;
19
+ id?: number | string | null;
20
+ result?: unknown;
21
+ error?: JsonRpcError;
22
+ }
23
+ /** Error codes the spec pins down. Servers get these wrong constantly. */
24
+ export declare const RPC: {
25
+ readonly PARSE_ERROR: -32700;
26
+ readonly INVALID_REQUEST: -32600;
27
+ readonly METHOD_NOT_FOUND: -32601;
28
+ readonly INVALID_PARAMS: -32602;
29
+ readonly INTERNAL_ERROR: -32603;
30
+ };
31
+ export declare class TimeoutError extends Error {
32
+ method: string;
33
+ ms: number;
34
+ constructor(method: string, ms: number);
35
+ }
36
+ export declare class TransportClosedError extends Error {
37
+ code?: number | null | undefined;
38
+ stderr?: string | undefined;
39
+ constructor(message: string, code?: number | null | undefined, stderr?: string | undefined);
40
+ }
@@ -0,0 +1,28 @@
1
+ /** Error codes the spec pins down. Servers get these wrong constantly. */
2
+ export const RPC = {
3
+ PARSE_ERROR: -32700,
4
+ INVALID_REQUEST: -32600,
5
+ METHOD_NOT_FOUND: -32601,
6
+ INVALID_PARAMS: -32602,
7
+ INTERNAL_ERROR: -32603,
8
+ };
9
+ export class TimeoutError extends Error {
10
+ method;
11
+ ms;
12
+ constructor(method, ms) {
13
+ super(`No response to \`${method}\` within ${ms}ms`);
14
+ this.method = method;
15
+ this.ms = ms;
16
+ this.name = 'TimeoutError';
17
+ }
18
+ }
19
+ export class TransportClosedError extends Error {
20
+ code;
21
+ stderr;
22
+ constructor(message, code, stderr) {
23
+ super(message);
24
+ this.code = code;
25
+ this.stderr = stderr;
26
+ this.name = 'TransportClosedError';
27
+ }
28
+ }