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/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
|
+
[](https://github.com/Beeeeen/mcp-wtf/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.npmjs.com/package/mcp-wtf)
|
|
7
|
+
[](./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
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
|
+
}
|