@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Transport } from './transport.js';
|
|
2
|
+
import { type JsonRpcResponse } from './jsonrpc.js';
|
|
3
|
+
export interface StdioOptions {
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
env?: Record<string, string>;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Newline-delimited JSON-RPC over a child process's stdio.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately hand-rolled rather than built on the official SDK: the SDK
|
|
13
|
+
* discards anything it cannot parse, and the unparseable bytes are exactly
|
|
14
|
+
* what we are here to find. A single stray `console.log` in a server puts a
|
|
15
|
+
* non-JSON line on stdout, which corrupts the stream for every client that
|
|
16
|
+
* connects to it -- and the server author never sees an error.
|
|
17
|
+
*/
|
|
18
|
+
export declare class StdioTransport implements Transport {
|
|
19
|
+
private opts;
|
|
20
|
+
readonly kind = "stdio";
|
|
21
|
+
readonly target: string;
|
|
22
|
+
private child;
|
|
23
|
+
private buffer;
|
|
24
|
+
private nextId;
|
|
25
|
+
private pending;
|
|
26
|
+
private exited;
|
|
27
|
+
/** stdout lines that were not valid JSON. Almost always a logging bug. */
|
|
28
|
+
readonly stdoutNoise: string[];
|
|
29
|
+
readonly stderr: string[];
|
|
30
|
+
/** Pipe faults (EPIPE and friends) seen after the child went away. */
|
|
31
|
+
readonly pipeErrors: string[];
|
|
32
|
+
/** Notifications the server pushed at us, kept for later assertions. */
|
|
33
|
+
readonly serverNotifications: JsonRpcResponse[];
|
|
34
|
+
constructor(opts: StdioOptions);
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
private onStdout;
|
|
37
|
+
private consumeLine;
|
|
38
|
+
request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
39
|
+
/** Send a request with a caller-chosen id/shape, for malformed-input probes. */
|
|
40
|
+
requestRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
41
|
+
private send;
|
|
42
|
+
notify(method: string, params?: unknown): void;
|
|
43
|
+
/**
|
|
44
|
+
* Write bytes verbatim. Used to test how the server handles garbage, and the
|
|
45
|
+
* single choke point for every unsolicited write, so a dead pipe is handled
|
|
46
|
+
* in exactly one place.
|
|
47
|
+
*/
|
|
48
|
+
writeRaw(text: string): void;
|
|
49
|
+
isAlive(): boolean;
|
|
50
|
+
exitInfo(): {
|
|
51
|
+
code: number | null;
|
|
52
|
+
signal: string | null;
|
|
53
|
+
} | null;
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { TimeoutError, TransportClosedError } from './jsonrpc.js';
|
|
3
|
+
/**
|
|
4
|
+
* Newline-delimited JSON-RPC over a child process's stdio.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately hand-rolled rather than built on the official SDK: the SDK
|
|
7
|
+
* discards anything it cannot parse, and the unparseable bytes are exactly
|
|
8
|
+
* what we are here to find. A single stray `console.log` in a server puts a
|
|
9
|
+
* non-JSON line on stdout, which corrupts the stream for every client that
|
|
10
|
+
* connects to it -- and the server author never sees an error.
|
|
11
|
+
*/
|
|
12
|
+
export class StdioTransport {
|
|
13
|
+
opts;
|
|
14
|
+
kind = 'stdio';
|
|
15
|
+
target;
|
|
16
|
+
child = null;
|
|
17
|
+
buffer = '';
|
|
18
|
+
nextId = 1;
|
|
19
|
+
pending = new Map();
|
|
20
|
+
exited = null;
|
|
21
|
+
/** stdout lines that were not valid JSON. Almost always a logging bug. */
|
|
22
|
+
stdoutNoise = [];
|
|
23
|
+
stderr = [];
|
|
24
|
+
/** Pipe faults (EPIPE and friends) seen after the child went away. */
|
|
25
|
+
pipeErrors = [];
|
|
26
|
+
/** Notifications the server pushed at us, kept for later assertions. */
|
|
27
|
+
serverNotifications = [];
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
this.target = [opts.command, ...opts.args].join(' ');
|
|
31
|
+
}
|
|
32
|
+
async start() {
|
|
33
|
+
// On Windows, a bare command name often resolves to a .cmd shim -- npx,
|
|
34
|
+
// pnpm, yarn all do -- and CreateProcess cannot execute those directly, so
|
|
35
|
+
// they need a shell. A path to a real executable must NOT go through the
|
|
36
|
+
// shell, because cmd.exe splits it on spaces and
|
|
37
|
+
// "C:\Program Files\nodejs\node.exe" becomes "C:\Program".
|
|
38
|
+
const isWin = process.platform === 'win32';
|
|
39
|
+
const hasPathSeparator = /[\\/]/.test(this.opts.command);
|
|
40
|
+
const isExe = /\.(exe|com)$/i.test(this.opts.command);
|
|
41
|
+
const useShell = isWin && !hasPathSeparator && !isExe;
|
|
42
|
+
// When the shell is in play it re-parses the whole line, so anything
|
|
43
|
+
// containing whitespace has to carry its own quotes.
|
|
44
|
+
const quote = (s) => (useShell && /[\s"^&|<>]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);
|
|
45
|
+
const child = spawn(quote(this.opts.command), this.opts.args.map(quote), {
|
|
46
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
47
|
+
env: { ...process.env, ...this.opts.env },
|
|
48
|
+
cwd: this.opts.cwd,
|
|
49
|
+
shell: useShell,
|
|
50
|
+
windowsVerbatimArguments: useShell,
|
|
51
|
+
});
|
|
52
|
+
this.child = child;
|
|
53
|
+
child.stdout.setEncoding('utf8');
|
|
54
|
+
child.stdout.on('data', (chunk) => this.onStdout(chunk));
|
|
55
|
+
child.stderr.setEncoding('utf8');
|
|
56
|
+
child.stderr.on('data', (chunk) => {
|
|
57
|
+
for (const line of chunk.split('\n'))
|
|
58
|
+
if (line.trim())
|
|
59
|
+
this.stderr.push(line);
|
|
60
|
+
});
|
|
61
|
+
// Writing to a pipe whose far end has gone emits EPIPE on the stream. With
|
|
62
|
+
// no listener Node promotes that to an uncaught exception, which would
|
|
63
|
+
// crash mcp-probe instead of reporting the dead server -- and the whole
|
|
64
|
+
// contract here is that a broken server produces a report, not a crash.
|
|
65
|
+
// Whether the write or the exit lands first is a race, so this shows up
|
|
66
|
+
// on some platforms and not others.
|
|
67
|
+
const swallow = (e) => {
|
|
68
|
+
this.pipeErrors.push(e.message);
|
|
69
|
+
};
|
|
70
|
+
child.stdin.on('error', swallow);
|
|
71
|
+
child.stdout.on('error', swallow);
|
|
72
|
+
child.stderr.on('error', swallow);
|
|
73
|
+
child.on('exit', (code, signal) => {
|
|
74
|
+
this.exited = { code, signal };
|
|
75
|
+
const err = new TransportClosedError(`Server exited (code ${code ?? 'null'}${signal ? `, signal ${signal}` : ''}) with ${this.pending.size} request(s) in flight`, code, this.stderr.slice(-20).join('\n'));
|
|
76
|
+
for (const [, p] of this.pending) {
|
|
77
|
+
clearTimeout(p.timer);
|
|
78
|
+
p.reject(err);
|
|
79
|
+
}
|
|
80
|
+
this.pending.clear();
|
|
81
|
+
});
|
|
82
|
+
await new Promise((resolve, reject) => {
|
|
83
|
+
const onError = (e) => reject(new TransportClosedError(`Failed to spawn \`${this.target}\`: ${e.message}`));
|
|
84
|
+
child.once('error', onError);
|
|
85
|
+
// Give spawn a tick to fail loudly; a server that dies later is caught
|
|
86
|
+
// by the in-flight rejection above.
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
child.off('error', onError);
|
|
89
|
+
resolve();
|
|
90
|
+
}, 50);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
onStdout(chunk) {
|
|
94
|
+
this.buffer += chunk;
|
|
95
|
+
let idx;
|
|
96
|
+
while ((idx = this.buffer.indexOf('\n')) !== -1) {
|
|
97
|
+
const line = this.buffer.slice(0, idx).replace(/\r$/, '');
|
|
98
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
99
|
+
if (!line.trim())
|
|
100
|
+
continue;
|
|
101
|
+
this.consumeLine(line);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
consumeLine(line) {
|
|
105
|
+
let msg;
|
|
106
|
+
try {
|
|
107
|
+
msg = JSON.parse(line);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not JSON. Record it and keep going -- we want the full list, not just
|
|
111
|
+
// the first one, so the report can show the author every offending line.
|
|
112
|
+
if (this.stdoutNoise.length < 50)
|
|
113
|
+
this.stdoutNoise.push(line);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (msg && typeof msg === 'object' && msg.id !== undefined && msg.id !== null) {
|
|
117
|
+
const waiter = this.pending.get(msg.id);
|
|
118
|
+
if (waiter) {
|
|
119
|
+
clearTimeout(waiter.timer);
|
|
120
|
+
this.pending.delete(msg.id);
|
|
121
|
+
waiter.resolve(msg);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// No id, or an id nobody is waiting on: a notification or a stray reply.
|
|
126
|
+
this.serverNotifications.push(msg);
|
|
127
|
+
}
|
|
128
|
+
request(method, params, timeoutMs = 10_000) {
|
|
129
|
+
const id = this.nextId++;
|
|
130
|
+
return this.send({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) }, id, method, timeoutMs);
|
|
131
|
+
}
|
|
132
|
+
/** Send a request with a caller-chosen id/shape, for malformed-input probes. */
|
|
133
|
+
requestRaw(payload, id, method, timeoutMs = 10_000) {
|
|
134
|
+
return this.send(payload, id, method, timeoutMs);
|
|
135
|
+
}
|
|
136
|
+
send(payload, id, method, timeoutMs) {
|
|
137
|
+
if (!this.child || this.exited) {
|
|
138
|
+
return Promise.reject(new TransportClosedError(`Server is not running (exit code ${this.exited?.code ?? 'unknown'})`, this.exited?.code ?? null, this.stderr.slice(-20).join('\n')));
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
this.pending.delete(id);
|
|
143
|
+
reject(new TimeoutError(method, timeoutMs));
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
146
|
+
const failWrite = (message) => {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
this.pending.delete(id);
|
|
149
|
+
reject(new TransportClosedError(`Write to stdin failed: ${message}`));
|
|
150
|
+
};
|
|
151
|
+
// write() reports asynchronously through the callback, but it can also
|
|
152
|
+
// throw synchronously once the stream is destroyed.
|
|
153
|
+
try {
|
|
154
|
+
this.child.stdin.write(JSON.stringify(payload) + '\n', (err) => {
|
|
155
|
+
if (err)
|
|
156
|
+
failWrite(err.message);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
failWrite(e.message);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
notify(method, params) {
|
|
165
|
+
this.writeRaw(JSON.stringify({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) }) + '\n');
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Write bytes verbatim. Used to test how the server handles garbage, and the
|
|
169
|
+
* single choke point for every unsolicited write, so a dead pipe is handled
|
|
170
|
+
* in exactly one place.
|
|
171
|
+
*/
|
|
172
|
+
writeRaw(text) {
|
|
173
|
+
if (!this.child || this.exited || !this.child.stdin.writable)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
this.child.stdin.write(text);
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
// The child can exit between the liveness check and the write landing.
|
|
180
|
+
this.pipeErrors.push(e.message);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
isAlive() {
|
|
184
|
+
return this.child !== null && this.exited === null;
|
|
185
|
+
}
|
|
186
|
+
exitInfo() {
|
|
187
|
+
return this.exited;
|
|
188
|
+
}
|
|
189
|
+
async close() {
|
|
190
|
+
for (const [, p] of this.pending)
|
|
191
|
+
clearTimeout(p.timer);
|
|
192
|
+
this.pending.clear();
|
|
193
|
+
const child = this.child;
|
|
194
|
+
if (!child || this.exited)
|
|
195
|
+
return;
|
|
196
|
+
try {
|
|
197
|
+
child.stdin.end();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* Pipe already torn down; there is nothing left to close. */
|
|
201
|
+
}
|
|
202
|
+
await new Promise((resolve) => {
|
|
203
|
+
const t = setTimeout(() => {
|
|
204
|
+
child.kill('SIGKILL');
|
|
205
|
+
resolve();
|
|
206
|
+
}, 1500);
|
|
207
|
+
child.once('exit', () => {
|
|
208
|
+
clearTimeout(t);
|
|
209
|
+
resolve();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { JsonRpcResponse } from './jsonrpc.js';
|
|
2
|
+
/** The surface the checks are written against, so they work over stdio or HTTP. */
|
|
3
|
+
export interface Transport {
|
|
4
|
+
readonly kind: 'stdio' | 'http';
|
|
5
|
+
readonly target: string;
|
|
6
|
+
/** stdout lines that were not valid JSON (stdio only; empty for HTTP). */
|
|
7
|
+
readonly stdoutNoise: string[];
|
|
8
|
+
readonly stderr: string[];
|
|
9
|
+
readonly serverNotifications: JsonRpcResponse[];
|
|
10
|
+
start(): Promise<void>;
|
|
11
|
+
request(method: string, params?: unknown, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
12
|
+
requestRaw(payload: Record<string, unknown>, id: number | string, method: string, timeoutMs?: number): Promise<JsonRpcResponse>;
|
|
13
|
+
notify(method: string, params?: unknown): void;
|
|
14
|
+
writeRaw(text: string): void;
|
|
15
|
+
isAlive(): boolean;
|
|
16
|
+
exitInfo(): {
|
|
17
|
+
code: number | null;
|
|
18
|
+
signal: string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { run, exitCodeFor, DEFAULT_OPTIONS, type TargetSpec } from './run.js';
|
|
2
|
+
export { renderTerminal } from './report/terminal.js';
|
|
3
|
+
export { renderJUnit } from './report/junit.js';
|
|
4
|
+
export { allChecks, selectChecks, protocolChecks, schemaChecks, robustnessChecks, hygieneChecks } from './checks/index.js';
|
|
5
|
+
export { McpClient, StdioTransport, HttpTransport, SUPPORTED_PROTOCOL_VERSIONS } from './client/index.js';
|
|
6
|
+
export type { Transport } from './client/transport.js';
|
|
7
|
+
export type { Check, CheckContext, CheckResult, JsonSchema, RunOptions, RunReport, Severity, Status, ToolDef, } from './types.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { run, exitCodeFor, DEFAULT_OPTIONS } from './run.js';
|
|
2
|
+
export { renderTerminal } from './report/terminal.js';
|
|
3
|
+
export { renderJUnit } from './report/junit.js';
|
|
4
|
+
export { allChecks, selectChecks, protocolChecks, schemaChecks, robustnessChecks, hygieneChecks } from './checks/index.js';
|
|
5
|
+
export { McpClient, StdioTransport, HttpTransport, SUPPORTED_PROTOCOL_VERSIONS } from './client/index.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RunReport } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* JUnit XML, which every CI provider can render as a test report.
|
|
4
|
+
* Warnings are emitted as passing cases carrying a `system-out` note so they
|
|
5
|
+
* are visible without turning the build red.
|
|
6
|
+
*/
|
|
7
|
+
export declare function renderJUnit(report: RunReport): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function esc(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
// Control characters are illegal in XML 1.0 and make parsers reject the
|
|
8
|
+
// whole file -- and server output is exactly where they show up.
|
|
9
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* JUnit XML, which every CI provider can render as a test report.
|
|
13
|
+
* Warnings are emitted as passing cases carrying a `system-out` note so they
|
|
14
|
+
* are visible without turning the build red.
|
|
15
|
+
*/
|
|
16
|
+
export function renderJUnit(report) {
|
|
17
|
+
const { results, summary, server } = report;
|
|
18
|
+
const name = server.name ? `mcp-probe/${server.name}` : 'mcp-probe';
|
|
19
|
+
const lines = [];
|
|
20
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
21
|
+
lines.push(`<testsuites name="${esc(name)}" tests="${results.length}" failures="${summary.fail}" skipped="${summary.skip}" time="${(report.durationMs / 1000).toFixed(3)}">`);
|
|
22
|
+
lines.push(` <testsuite name="${esc(name)}" tests="${results.length}" failures="${summary.fail}" skipped="${summary.skip}" time="${(report.durationMs / 1000).toFixed(3)}">`);
|
|
23
|
+
lines.push(` <properties>`);
|
|
24
|
+
lines.push(` <property name="target" value="${esc(server.target)}"/>`);
|
|
25
|
+
if (server.protocolVersion)
|
|
26
|
+
lines.push(` <property name="protocolVersion" value="${esc(server.protocolVersion)}"/>`);
|
|
27
|
+
if (server.version)
|
|
28
|
+
lines.push(` <property name="serverVersion" value="${esc(server.version)}"/>`);
|
|
29
|
+
lines.push(` </properties>`);
|
|
30
|
+
for (const r of results) {
|
|
31
|
+
const cls = r.id.split('.')[0] ?? 'mcp-probe';
|
|
32
|
+
const caseName = r.target ? `${r.title} (${r.target})` : r.title;
|
|
33
|
+
const attrs = `classname="${esc(cls)}" name="${esc(caseName)}"${r.ms !== undefined ? ` time="${(r.ms / 1000).toFixed(3)}"` : ''}`;
|
|
34
|
+
const body = [r.message, r.detail].filter(Boolean).join('\n\n');
|
|
35
|
+
if (r.status === 'fail') {
|
|
36
|
+
lines.push(` <testcase ${attrs}>`);
|
|
37
|
+
lines.push(` <failure message="${esc(r.message ?? r.title)}" type="${esc(r.id)}">${esc(body)}</failure>`);
|
|
38
|
+
lines.push(` </testcase>`);
|
|
39
|
+
}
|
|
40
|
+
else if (r.status === 'skip') {
|
|
41
|
+
lines.push(` <testcase ${attrs}>`);
|
|
42
|
+
lines.push(` <skipped message="${esc(r.message ?? '')}"/>`);
|
|
43
|
+
lines.push(` </testcase>`);
|
|
44
|
+
}
|
|
45
|
+
else if (r.status === 'warn') {
|
|
46
|
+
lines.push(` <testcase ${attrs}>`);
|
|
47
|
+
lines.push(` <system-out>${esc(`[warning] ${body}`)}</system-out>`);
|
|
48
|
+
lines.push(` </testcase>`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
lines.push(` <testcase ${attrs}/>`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (report.stdoutNoise.length > 0) {
|
|
55
|
+
lines.push(` <system-err>${esc(`Non-JSON stdout:\n${report.stdoutNoise.join('\n')}`)}</system-err>`);
|
|
56
|
+
}
|
|
57
|
+
lines.push(' </testsuite>');
|
|
58
|
+
lines.push('</testsuites>');
|
|
59
|
+
return lines.join('\n') + '\n';
|
|
60
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
dim: useColor ? '\x1b[2m' : '',
|
|
5
|
+
bold: useColor ? '\x1b[1m' : '',
|
|
6
|
+
red: useColor ? '\x1b[31m' : '',
|
|
7
|
+
green: useColor ? '\x1b[32m' : '',
|
|
8
|
+
yellow: useColor ? '\x1b[33m' : '',
|
|
9
|
+
blue: useColor ? '\x1b[34m' : '',
|
|
10
|
+
gray: useColor ? '\x1b[90m' : '',
|
|
11
|
+
};
|
|
12
|
+
const MARK = {
|
|
13
|
+
pass: `${c.green}PASS${c.reset}`,
|
|
14
|
+
fail: `${c.red}FAIL${c.reset}`,
|
|
15
|
+
warn: `${c.yellow}WARN${c.reset}`,
|
|
16
|
+
skip: `${c.gray}SKIP${c.reset}`,
|
|
17
|
+
};
|
|
18
|
+
/** Visible width, so ANSI codes do not throw the column maths off. */
|
|
19
|
+
function width(s) {
|
|
20
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
21
|
+
}
|
|
22
|
+
function pad(s, to) {
|
|
23
|
+
const w = width(s);
|
|
24
|
+
return w >= to ? s : s + ' '.repeat(to - w);
|
|
25
|
+
}
|
|
26
|
+
function groupOf(id) {
|
|
27
|
+
return id.split('.')[0] ?? id;
|
|
28
|
+
}
|
|
29
|
+
const GROUP_TITLES = {
|
|
30
|
+
connect: 'connection',
|
|
31
|
+
protocol: 'protocol conformance',
|
|
32
|
+
schema: 'tool schemas',
|
|
33
|
+
robustness: 'robustness',
|
|
34
|
+
hygiene: 'transport hygiene',
|
|
35
|
+
};
|
|
36
|
+
export function renderTerminal(report, opts = {}) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
const { server, results, summary } = report;
|
|
39
|
+
const title = server.name ? `${server.name}${server.version ? ` v${server.version}` : ''}` : server.target;
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(` ${c.bold}mcp-probe${c.reset} ${title}`);
|
|
42
|
+
lines.push(` ${c.gray}${server.target}${server.protocolVersion ? ` ${c.dim}|${c.reset}${c.gray} protocol ${server.protocolVersion}` : ''}${c.reset}`);
|
|
43
|
+
lines.push('');
|
|
44
|
+
const groups = new Map();
|
|
45
|
+
for (const r of results) {
|
|
46
|
+
const g = groupOf(r.id);
|
|
47
|
+
if (!groups.has(g))
|
|
48
|
+
groups.set(g, []);
|
|
49
|
+
groups.get(g).push(r);
|
|
50
|
+
}
|
|
51
|
+
for (const [group, items] of groups) {
|
|
52
|
+
lines.push(` ${c.bold}${GROUP_TITLES[group] ?? group}${c.reset}`);
|
|
53
|
+
for (const r of items) {
|
|
54
|
+
const label = r.target ? `${r.title} ${c.gray}(${r.target})${c.reset}` : r.title;
|
|
55
|
+
const timing = r.ms !== undefined ? `${c.gray}${r.ms}ms${c.reset}` : '';
|
|
56
|
+
// Some checks report their timing as the message; do not print it twice.
|
|
57
|
+
const showNote = r.status === 'pass' && r.message && r.message !== `${r.ms}ms`;
|
|
58
|
+
const note = showNote ? `${c.gray}${r.message}${c.reset}` : '';
|
|
59
|
+
const right = [note, timing].filter(Boolean).join(' ');
|
|
60
|
+
lines.push(` ${MARK[r.status]} ${pad(label, 58)}${right}`);
|
|
61
|
+
if (r.status !== 'pass' && r.message) {
|
|
62
|
+
lines.push(` ${c.gray}${r.message}${c.reset}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
// Failures always get a detail block. Warnings stay collapsed unless asked
|
|
68
|
+
// for, so a run with many small nits does not bury the things that broke.
|
|
69
|
+
const hiddenWarnings = opts.verbose ? 0 : results.filter((r) => r.status === 'warn' && r.detail).length;
|
|
70
|
+
const withDetail = results.filter((r) => r.detail && (r.status === 'fail' || (opts.verbose && r.status === 'warn')));
|
|
71
|
+
if (withDetail.length > 0) {
|
|
72
|
+
lines.push(` ${c.bold}details${c.reset}`);
|
|
73
|
+
lines.push('');
|
|
74
|
+
for (const r of withDetail) {
|
|
75
|
+
const colour = r.status === 'fail' ? c.red : c.yellow;
|
|
76
|
+
lines.push(` ${colour}${r.status === 'fail' ? 'FAIL' : 'WARN'}${c.reset} ${c.bold}${r.title}${r.target ? ` ${c.reset}${c.gray}(${r.target})` : ''}${c.reset}`);
|
|
77
|
+
lines.push(` ${c.gray}${r.id}${r.spec ? ` ${r.spec}` : ''}${c.reset}`);
|
|
78
|
+
if (r.message)
|
|
79
|
+
lines.push(` ${r.message}`);
|
|
80
|
+
for (const dl of (r.detail ?? '').split('\n'))
|
|
81
|
+
lines.push(` ${c.gray}${dl}${c.reset}`);
|
|
82
|
+
lines.push('');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const parts = [];
|
|
86
|
+
if (summary.fail)
|
|
87
|
+
parts.push(`${c.red}${summary.fail} failed${c.reset}`);
|
|
88
|
+
if (summary.warn)
|
|
89
|
+
parts.push(`${c.yellow}${summary.warn} warning${summary.warn === 1 ? '' : 's'}${c.reset}`);
|
|
90
|
+
if (summary.pass)
|
|
91
|
+
parts.push(`${c.green}${summary.pass} passed${c.reset}`);
|
|
92
|
+
if (summary.skip)
|
|
93
|
+
parts.push(`${c.gray}${summary.skip} skipped${c.reset}`);
|
|
94
|
+
lines.push(` ${c.gray}${'-'.repeat(64)}${c.reset}`);
|
|
95
|
+
lines.push(` ${parts.join(`${c.gray} | ${c.reset}`)} ${c.gray}${(report.durationMs / 1000).toFixed(2)}s${c.reset}`);
|
|
96
|
+
if (hiddenWarnings > 0) {
|
|
97
|
+
lines.push(` ${c.gray}${hiddenWarnings} warning${hiddenWarnings === 1 ? '' : 's'} not expanded. Run with --verbose for the full explanation.${c.reset}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push('');
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CheckResult, RunOptions, RunReport } from './types.js';
|
|
2
|
+
export interface TargetSpec {
|
|
3
|
+
kind: 'stdio' | 'http';
|
|
4
|
+
/** stdio */
|
|
5
|
+
command?: string;
|
|
6
|
+
args?: string[];
|
|
7
|
+
env?: Record<string, string>;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
/** http */
|
|
10
|
+
url?: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
export declare const DEFAULT_OPTIONS: RunOptions;
|
|
14
|
+
/**
|
|
15
|
+
* Run the suite against one server and return everything that happened.
|
|
16
|
+
* Never throws for a server-side fault -- a server that cannot even handshake
|
|
17
|
+
* is a *result*, not an exception, or CI could not report on it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function run(target: TargetSpec, options?: Partial<RunOptions>, hooks?: {
|
|
20
|
+
onResult?: (r: CheckResult) => void;
|
|
21
|
+
}): Promise<RunReport>;
|
|
22
|
+
/** Exit code contract: 0 clean, 1 findings, 2 could not run. */
|
|
23
|
+
export declare function exitCodeFor(report: RunReport, strict: boolean): number;
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { McpClient, StdioTransport, HttpTransport } from './client/index.js';
|
|
2
|
+
import { allChecks, selectChecks } from './checks/index.js';
|
|
3
|
+
import { result } from './checks/util.js';
|
|
4
|
+
export const DEFAULT_OPTIONS = {
|
|
5
|
+
timeoutMs: 10_000,
|
|
6
|
+
callTools: false,
|
|
7
|
+
safeTools: [],
|
|
8
|
+
strict: false,
|
|
9
|
+
};
|
|
10
|
+
function buildTransport(target) {
|
|
11
|
+
if (target.kind === 'http') {
|
|
12
|
+
if (!target.url)
|
|
13
|
+
throw new Error('An http target needs a url');
|
|
14
|
+
return new HttpTransport({ url: target.url, headers: target.headers });
|
|
15
|
+
}
|
|
16
|
+
if (!target.command)
|
|
17
|
+
throw new Error('A stdio target needs a command');
|
|
18
|
+
return new StdioTransport({
|
|
19
|
+
command: target.command,
|
|
20
|
+
args: target.args ?? [],
|
|
21
|
+
env: target.env,
|
|
22
|
+
cwd: target.cwd,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function summarise(results) {
|
|
26
|
+
const summary = { pass: 0, fail: 0, warn: 0, skip: 0 };
|
|
27
|
+
for (const r of results)
|
|
28
|
+
summary[r.status]++;
|
|
29
|
+
return summary;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Run the suite against one server and return everything that happened.
|
|
33
|
+
* Never throws for a server-side fault -- a server that cannot even handshake
|
|
34
|
+
* is a *result*, not an exception, or CI could not report on it.
|
|
35
|
+
*/
|
|
36
|
+
export async function run(target, options = {}, hooks = {}) {
|
|
37
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
38
|
+
const started = Date.now();
|
|
39
|
+
const transport = buildTransport(target);
|
|
40
|
+
const client = new McpClient(transport, opts.timeoutMs);
|
|
41
|
+
const results = [];
|
|
42
|
+
// Two tools that share a name produce results indistinguishable to the
|
|
43
|
+
// reader, so collapse exact repeats rather than printing the same line twice.
|
|
44
|
+
const seenResults = new Set();
|
|
45
|
+
const emit = (r) => {
|
|
46
|
+
const key = `${r.id}|${r.target ?? ''}|${r.message ?? ''}`;
|
|
47
|
+
if (seenResults.has(key))
|
|
48
|
+
return;
|
|
49
|
+
seenResults.add(key);
|
|
50
|
+
results.push(r);
|
|
51
|
+
hooks.onResult?.(r);
|
|
52
|
+
};
|
|
53
|
+
const finish = () => ({
|
|
54
|
+
server: {
|
|
55
|
+
name: ctx?.serverInfo?.name,
|
|
56
|
+
version: ctx?.serverInfo?.version,
|
|
57
|
+
protocolVersion: ctx?.protocolVersion ?? null,
|
|
58
|
+
target: transport.target,
|
|
59
|
+
},
|
|
60
|
+
results,
|
|
61
|
+
summary: summarise(results),
|
|
62
|
+
durationMs: Date.now() - started,
|
|
63
|
+
stdoutNoise: [...transport.stdoutNoise],
|
|
64
|
+
stderr: [...transport.stderr],
|
|
65
|
+
});
|
|
66
|
+
let ctx = null;
|
|
67
|
+
try {
|
|
68
|
+
await client.start();
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
emit(result('connect', 'Server starts', 'fail', 'error', {
|
|
72
|
+
message: e.message,
|
|
73
|
+
detail: transport.stderr.slice(-12).join('\n') || undefined,
|
|
74
|
+
}));
|
|
75
|
+
return finish();
|
|
76
|
+
}
|
|
77
|
+
// The handshake gates everything: without it there is nothing to check.
|
|
78
|
+
let handshake;
|
|
79
|
+
try {
|
|
80
|
+
handshake = await client.initialize();
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
emit(result('connect', 'Server completes the initialize handshake', 'fail', 'error', {
|
|
84
|
+
message: e.message,
|
|
85
|
+
detail: [
|
|
86
|
+
transport.stdoutNoise.length > 0
|
|
87
|
+
? `Non-JSON output appeared on stdout before any reply:\n${transport.stdoutNoise.slice(0, 5).map((l) => ` > ${l}`).join('\n')}\n\nThis alone will prevent every client from connecting.`
|
|
88
|
+
: '',
|
|
89
|
+
transport.stderr.slice(-12).join('\n'),
|
|
90
|
+
]
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join('\n\n') || undefined,
|
|
93
|
+
}));
|
|
94
|
+
await client.close();
|
|
95
|
+
return finish();
|
|
96
|
+
}
|
|
97
|
+
if (handshake.raw.error) {
|
|
98
|
+
emit(result('connect', 'Server completes the initialize handshake', 'fail', 'error', {
|
|
99
|
+
message: `initialize returned error ${handshake.raw.error.code}: ${handshake.raw.error.message}`,
|
|
100
|
+
ms: handshake.ms,
|
|
101
|
+
}));
|
|
102
|
+
await client.close();
|
|
103
|
+
return finish();
|
|
104
|
+
}
|
|
105
|
+
emit(result('connect', 'Server completes the initialize handshake', 'pass', 'error', { ms: handshake.ms }));
|
|
106
|
+
client.notifyInitialized();
|
|
107
|
+
// Gather once so individual checks do not each re-list.
|
|
108
|
+
let tools = [];
|
|
109
|
+
const { tools: listed, error: listError } = await client.listTools().catch(() => ({ tools: [], error: undefined }));
|
|
110
|
+
if (listError) {
|
|
111
|
+
emit(result('connect.tools_list', 'tools/list responds', 'fail', 'error', {
|
|
112
|
+
message: `tools/list returned error ${listError.error?.code}: ${listError.error?.message}`,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
tools = listed;
|
|
117
|
+
}
|
|
118
|
+
const resources = await client.listAll('resources/list', 'resources').then((r) => r.items).catch(() => []);
|
|
119
|
+
const prompts = await client.listAll('prompts/list', 'prompts').then((r) => r.items).catch(() => []);
|
|
120
|
+
ctx = {
|
|
121
|
+
client,
|
|
122
|
+
tools,
|
|
123
|
+
resources,
|
|
124
|
+
prompts,
|
|
125
|
+
serverInfo: handshake.serverInfo,
|
|
126
|
+
capabilities: handshake.capabilities,
|
|
127
|
+
protocolVersion: handshake.protocolVersion,
|
|
128
|
+
options: opts,
|
|
129
|
+
};
|
|
130
|
+
for (const check of selectChecks(allChecks, opts.only, opts.skip)) {
|
|
131
|
+
try {
|
|
132
|
+
for (const r of await check.run(ctx))
|
|
133
|
+
emit(r);
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
// A check that throws is a bug in mcp-probe, not in the server under
|
|
137
|
+
// test. Say which one, and keep going -- one broken check must not
|
|
138
|
+
// invalidate the rest of the report.
|
|
139
|
+
emit(result(check.id, check.title, 'skip', 'info', {
|
|
140
|
+
message: `Check errored internally: ${e.message}`,
|
|
141
|
+
detail: 'This is a bug in mcp-probe. Please report it with the server that triggered it.',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await client.close();
|
|
146
|
+
return finish();
|
|
147
|
+
}
|
|
148
|
+
/** Exit code contract: 0 clean, 1 findings, 2 could not run. */
|
|
149
|
+
export function exitCodeFor(report, strict) {
|
|
150
|
+
if (report.summary.fail > 0)
|
|
151
|
+
return 1;
|
|
152
|
+
if (strict && report.summary.warn > 0)
|
|
153
|
+
return 1;
|
|
154
|
+
return 0;
|
|
155
|
+
}
|