@beeeeen/mcp-probe 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 +234 -0
- package/dist/checks/hygiene.d.ts +17 -0
- package/dist/checks/hygiene.js +104 -0
- package/dist/checks/index.d.ts +16 -0
- package/dist/checks/index.js +24 -0
- package/dist/checks/protocol.d.ts +15 -0
- package/dist/checks/protocol.js +225 -0
- package/dist/checks/robustness.d.ts +17 -0
- package/dist/checks/robustness.js +240 -0
- package/dist/checks/schema.d.ts +12 -0
- package/dist/checks/schema.js +224 -0
- package/dist/checks/util.d.ts +7 -0
- package/dist/checks/util.js +23 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +292 -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/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/report/junit.d.ts +7 -0
- package/dist/report/junit.js +60 -0
- package/dist/report/terminal.d.ts +6 -0
- package/dist/report/terminal.js +101 -0
- package/dist/run.d.ts +23 -0
- package/dist/run.js +155 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +1 -0
- package/package.json +65 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { run, exitCodeFor } from './run.js';
|
|
4
|
+
import { renderTerminal } from './report/terminal.js';
|
|
5
|
+
import { renderJUnit } from './report/junit.js';
|
|
6
|
+
const VERSION = '0.1.0';
|
|
7
|
+
const HELP = `
|
|
8
|
+
mcp-probe ${VERSION}
|
|
9
|
+
Conformance and robustness tests for MCP servers. Built for CI.
|
|
10
|
+
|
|
11
|
+
USAGE
|
|
12
|
+
mcp-probe <command> [args...] run a stdio server and probe it
|
|
13
|
+
mcp-probe --url <url> probe a streamable-HTTP server
|
|
14
|
+
mcp-probe --config <file> --server <name>
|
|
15
|
+
|
|
16
|
+
EXAMPLES
|
|
17
|
+
mcp-probe node build/index.js
|
|
18
|
+
mcp-probe -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
|
19
|
+
mcp-probe --url http://localhost:3000/mcp
|
|
20
|
+
mcp-probe --config ~/.claude.json --server github
|
|
21
|
+
mcp-probe --junit results.xml --strict -- node server.js
|
|
22
|
+
|
|
23
|
+
OUTPUT
|
|
24
|
+
--json machine-readable report on stdout
|
|
25
|
+
--json-out <file> write the JSON report to a file, keeping the
|
|
26
|
+
human-readable report on stdout
|
|
27
|
+
--junit <file> write JUnit XML (GitHub, GitLab, Jenkins, ...)
|
|
28
|
+
--quiet suppress the terminal report
|
|
29
|
+
--verbose expand the explanation for warnings too
|
|
30
|
+
|
|
31
|
+
SELECTION
|
|
32
|
+
--only <id> run only these checks; repeatable, prefix match
|
|
33
|
+
--skip <id> skip these checks; repeatable, prefix match
|
|
34
|
+
groups: protocol, schema, robustness, hygiene
|
|
35
|
+
|
|
36
|
+
BEHAVIOUR
|
|
37
|
+
--timeout <ms> per-request timeout (default 10000)
|
|
38
|
+
--strict treat warnings as failures for the exit code
|
|
39
|
+
--call-tools invoke every tool with empty arguments.
|
|
40
|
+
OFF by default: mcp-probe never triggers a side
|
|
41
|
+
effect you did not ask for.
|
|
42
|
+
--safe-tool <name> invoke just this tool; repeatable
|
|
43
|
+
--header <k:v> extra HTTP header; repeatable (http only)
|
|
44
|
+
--env <k=v> extra environment variable; repeatable (stdio only)
|
|
45
|
+
|
|
46
|
+
EXIT CODES
|
|
47
|
+
0 no failures
|
|
48
|
+
1 at least one failure (or a warning under --strict)
|
|
49
|
+
2 could not run at all
|
|
50
|
+
|
|
51
|
+
mcp-probe's own flags come first. Everything from the first non-flag argument
|
|
52
|
+
(or from a bare "--") onward is the server's command line, passed through
|
|
53
|
+
untouched, so a server flag can never collide with one of ours. Use "--"
|
|
54
|
+
whenever the command itself starts with a dash.
|
|
55
|
+
`;
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const out = {
|
|
58
|
+
target: null,
|
|
59
|
+
options: {},
|
|
60
|
+
json: false,
|
|
61
|
+
jsonOut: null,
|
|
62
|
+
junit: null,
|
|
63
|
+
quiet: false,
|
|
64
|
+
verbose: false,
|
|
65
|
+
help: false,
|
|
66
|
+
version: false,
|
|
67
|
+
};
|
|
68
|
+
const only = [];
|
|
69
|
+
const skip = [];
|
|
70
|
+
const safeTools = [];
|
|
71
|
+
const headers = {};
|
|
72
|
+
const env = {};
|
|
73
|
+
let url = null;
|
|
74
|
+
let configPath = null;
|
|
75
|
+
let serverName = null;
|
|
76
|
+
let command = null;
|
|
77
|
+
let commandArgs = [];
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const arg = argv[i];
|
|
80
|
+
// Once a command is known, the rest belongs to the server, untouched.
|
|
81
|
+
if (command !== null) {
|
|
82
|
+
commandArgs.push(arg);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// `--` ends our own flags: the next token is the command, whatever it
|
|
86
|
+
// looks like. Needed when the command itself begins with a dash.
|
|
87
|
+
if (arg === '--') {
|
|
88
|
+
const rest = argv.slice(i + 1);
|
|
89
|
+
if (rest.length === 0)
|
|
90
|
+
return { ...out, error: '`--` must be followed by the server command' };
|
|
91
|
+
command = rest[0];
|
|
92
|
+
commandArgs = rest.slice(1);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const next = () => argv[++i];
|
|
96
|
+
switch (arg) {
|
|
97
|
+
case '-h':
|
|
98
|
+
case '--help':
|
|
99
|
+
out.help = true;
|
|
100
|
+
break;
|
|
101
|
+
case '-v':
|
|
102
|
+
case '--version':
|
|
103
|
+
out.version = true;
|
|
104
|
+
break;
|
|
105
|
+
case '--json':
|
|
106
|
+
out.json = true;
|
|
107
|
+
break;
|
|
108
|
+
case '--quiet':
|
|
109
|
+
out.quiet = true;
|
|
110
|
+
break;
|
|
111
|
+
case '--verbose':
|
|
112
|
+
out.verbose = true;
|
|
113
|
+
break;
|
|
114
|
+
case '--strict':
|
|
115
|
+
out.options.strict = true;
|
|
116
|
+
break;
|
|
117
|
+
case '--call-tools':
|
|
118
|
+
out.options.callTools = true;
|
|
119
|
+
break;
|
|
120
|
+
case '--junit':
|
|
121
|
+
out.junit = next() ?? null;
|
|
122
|
+
break;
|
|
123
|
+
case '--json-out':
|
|
124
|
+
out.jsonOut = next() ?? null;
|
|
125
|
+
break;
|
|
126
|
+
case '--url':
|
|
127
|
+
url = next() ?? null;
|
|
128
|
+
break;
|
|
129
|
+
case '--config':
|
|
130
|
+
configPath = next() ?? null;
|
|
131
|
+
break;
|
|
132
|
+
case '--server':
|
|
133
|
+
serverName = next() ?? null;
|
|
134
|
+
break;
|
|
135
|
+
case '--timeout': {
|
|
136
|
+
const v = Number(next());
|
|
137
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
138
|
+
return { ...out, error: '--timeout needs a positive number of milliseconds' };
|
|
139
|
+
out.options.timeoutMs = v;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case '--only':
|
|
143
|
+
only.push(...(next() ?? '').split(',').filter(Boolean));
|
|
144
|
+
break;
|
|
145
|
+
case '--skip':
|
|
146
|
+
skip.push(...(next() ?? '').split(',').filter(Boolean));
|
|
147
|
+
break;
|
|
148
|
+
case '--safe-tool':
|
|
149
|
+
safeTools.push(...(next() ?? '').split(',').filter(Boolean));
|
|
150
|
+
break;
|
|
151
|
+
case '--header': {
|
|
152
|
+
const raw = next() ?? '';
|
|
153
|
+
const idx = raw.indexOf(':');
|
|
154
|
+
if (idx < 1)
|
|
155
|
+
return { ...out, error: `--header expects "Name: value", got "${raw}"` };
|
|
156
|
+
headers[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case '--env': {
|
|
160
|
+
const raw = next() ?? '';
|
|
161
|
+
const idx = raw.indexOf('=');
|
|
162
|
+
if (idx < 1)
|
|
163
|
+
return { ...out, error: `--env expects "KEY=value", got "${raw}"` };
|
|
164
|
+
env[raw.slice(0, idx)] = raw.slice(idx + 1);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
if (arg.startsWith('-'))
|
|
169
|
+
return { ...out, error: `Unknown option "${arg}". Try --help.` };
|
|
170
|
+
command = arg;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (only.length)
|
|
174
|
+
out.options.only = only;
|
|
175
|
+
if (skip.length)
|
|
176
|
+
out.options.skip = skip;
|
|
177
|
+
if (safeTools.length)
|
|
178
|
+
out.options.safeTools = safeTools;
|
|
179
|
+
if (configPath) {
|
|
180
|
+
const resolved = resolveFromConfig(configPath, serverName);
|
|
181
|
+
if ('error' in resolved)
|
|
182
|
+
return { ...out, error: resolved.error };
|
|
183
|
+
out.target = resolved.target;
|
|
184
|
+
if (Object.keys(env).length)
|
|
185
|
+
out.target.env = { ...out.target.env, ...env };
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
if (url) {
|
|
189
|
+
out.target = { kind: 'http', url, headers };
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
if (command) {
|
|
193
|
+
out.target = { kind: 'stdio', command, args: commandArgs, env };
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Read a server definition out of an existing MCP config file, so a user can
|
|
200
|
+
* point mcp-probe at a server they already run without retyping its command.
|
|
201
|
+
* Handles both the `mcpServers` shape (Claude Desktop, Cursor) and the
|
|
202
|
+
* `servers` shape (VS Code).
|
|
203
|
+
*/
|
|
204
|
+
function resolveFromConfig(path, name) {
|
|
205
|
+
let parsed;
|
|
206
|
+
try {
|
|
207
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
return { error: `Could not read ${path}: ${e.message}` };
|
|
211
|
+
}
|
|
212
|
+
const block = (parsed['mcpServers'] ?? parsed['servers']);
|
|
213
|
+
if (!block || typeof block !== 'object') {
|
|
214
|
+
return { error: `${path} has no "mcpServers" or "servers" object.` };
|
|
215
|
+
}
|
|
216
|
+
const names = Object.keys(block);
|
|
217
|
+
if (names.length === 0)
|
|
218
|
+
return { error: `${path} defines no servers.` };
|
|
219
|
+
const chosen = name ?? (names.length === 1 ? names[0] : null);
|
|
220
|
+
if (!chosen) {
|
|
221
|
+
return { error: `${path} defines ${names.length} servers. Pick one with --server <name>: ${names.join(', ')}` };
|
|
222
|
+
}
|
|
223
|
+
const entry = block[chosen];
|
|
224
|
+
if (!entry)
|
|
225
|
+
return { error: `No server named "${chosen}" in ${path}. Available: ${names.join(', ')}` };
|
|
226
|
+
if (typeof entry['url'] === 'string') {
|
|
227
|
+
return { target: { kind: 'http', url: entry['url'], headers: entry['headers'] ?? {} } };
|
|
228
|
+
}
|
|
229
|
+
if (typeof entry['command'] === 'string') {
|
|
230
|
+
return {
|
|
231
|
+
target: {
|
|
232
|
+
kind: 'stdio',
|
|
233
|
+
command: entry['command'],
|
|
234
|
+
args: Array.isArray(entry['args']) ? entry['args'] : [],
|
|
235
|
+
env: entry['env'] ?? {},
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { error: `Server "${chosen}" in ${path} has neither "command" nor "url".` };
|
|
240
|
+
}
|
|
241
|
+
async function main() {
|
|
242
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
243
|
+
if (parsed.help) {
|
|
244
|
+
process.stdout.write(HELP);
|
|
245
|
+
process.exit(0);
|
|
246
|
+
}
|
|
247
|
+
if (parsed.version) {
|
|
248
|
+
process.stdout.write(VERSION + '\n');
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
if (parsed.error) {
|
|
252
|
+
process.stderr.write(`mcp-probe: ${parsed.error}\n`);
|
|
253
|
+
process.exit(2);
|
|
254
|
+
}
|
|
255
|
+
if (!parsed.target) {
|
|
256
|
+
process.stderr.write(HELP);
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
const report = await run(parsed.target, parsed.options);
|
|
260
|
+
if (parsed.junit) {
|
|
261
|
+
try {
|
|
262
|
+
writeFileSync(parsed.junit, renderJUnit(report), 'utf8');
|
|
263
|
+
}
|
|
264
|
+
catch (e) {
|
|
265
|
+
process.stderr.write(`mcp-probe: could not write ${parsed.junit}: ${e.message}\n`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (parsed.jsonOut) {
|
|
269
|
+
try {
|
|
270
|
+
writeFileSync(parsed.jsonOut, JSON.stringify(report, null, 2), 'utf8');
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
process.stderr.write(`mcp-probe: could not write ${parsed.jsonOut}: ${e.message}
|
|
274
|
+
`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (parsed.json) {
|
|
278
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
279
|
+
}
|
|
280
|
+
else if (!parsed.quiet) {
|
|
281
|
+
process.stdout.write(renderTerminal(report, { verbose: parsed.verbose }));
|
|
282
|
+
}
|
|
283
|
+
// Nothing ran at all: distinguish "server is broken" from "could not start".
|
|
284
|
+
if (report.results.length === 1 && report.results[0]?.id === 'connect' && report.results[0].status === 'fail') {
|
|
285
|
+
process.exit(2);
|
|
286
|
+
}
|
|
287
|
+
process.exit(exitCodeFor(report, parsed.options.strict ?? false));
|
|
288
|
+
}
|
|
289
|
+
main().catch((e) => {
|
|
290
|
+
process.stderr.write(`mcp-probe: internal error: ${e.stack ?? String(e)}\n`);
|
|
291
|
+
process.exit(2);
|
|
292
|
+
});
|
|
@@ -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-probe', 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
|
+
}
|