@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.
@@ -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 {};
@@ -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, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
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,6 @@
1
+ import type { RunReport } from '../types.js';
2
+ export interface TerminalOptions {
3
+ /** Show the detail block for warnings too, not just failures. */
4
+ verbose?: boolean;
5
+ }
6
+ export declare function renderTerminal(report: RunReport, opts?: TerminalOptions): string;
@@ -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
+ }