#!/usr/bin/env tsx
/**
* Build-and-e2e-run every story under `examples/` over every transport × era
* leg it supports. Each story's `client.ts` is a self-verifying top-level-await
* script: a `check.*` failure throws, Node prints the error and exits 1; on
* success `client.close()` releases the last handle and Node exits 0. The
* harness reports PASS/FAIL from the child's exit code (a timeout is a FAIL
* with "hung — possible unclosed handle").
*
* - **stdio** (default for dual-transport stories): run `client.ts` with no
* transport flag; it spawns the sibling server binary itself and speaks
* MCP over the pipe.
* - **HTTP**: start `server.ts --http --port
`, poll until ready, run
* `client.ts --http http://127.0.0.1:
/`, kill the server.
* - **modern** (default): the client negotiates the 2026-07-28 era
* (`versionNegotiation: { mode: 'auto' }`).
* - **legacy**: pass `--legacy` to the client so it uses the 2025
* `initialize` handshake (`versionNegotiation: { mode: 'legacy' }`).
*
* Per-story configuration lives in the story's `package.json` under the
* `"example"` field — most stories have none. `excluded` stories are listed
* (with their reason) but not run. Stories without a `client.ts` are skipped.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { connect } from 'node:net';
import { join, resolve } from 'node:path';
type Era = 'modern' | 'legacy';
type Transport = 'stdio' | 'http';
interface ExampleConfig {
/** Transports to run (default: `['stdio', 'http']`). */
transports?: Transport[];
/** `'dual'` (modern + legacy; the default), `'modern'`, or `'legacy'`. */
era?: 'dual' | Era;
/** HTTP port (default: a per-story port assigned below). */
port?: number;
/** Endpoint path (default: `'/mcp'`). */
path?: string;
/** Extra environment for the server process. */
env?: Record;
/** Per-leg timeout in milliseconds (default: 30000). */
timeoutMs?: number;
/** Optional substring the client's stdout must contain. */
expects?: { stdout?: string };
/** When present, the story is skipped (with this reason printed). */
excluded?: string;
}
const ROOT = resolve(import.meta.dirname, '../..');
const EXAMPLES = join(ROOT, 'examples');
const TSX = join(ROOT, 'node_modules', '.bin', 'tsx');
/** Directories that are never stories. */
const NON_STORY = new Set(['shared', 'guides', 'server-quickstart', 'client-quickstart', 'node_modules']);
/** Distinct per-story HTTP ports so the servers never collide. */
let nextPort = 8530;
const portFor = new Map();
function assignPort(story: string, config: ExampleConfig): number {
if (config.port) return config.port;
if (!portFor.has(story)) portFor.set(story, nextPort++);
return portFor.get(story)!;
}
function readConfig(dir: string): ExampleConfig {
const file = join(dir, 'package.json');
if (!existsSync(file)) return {};
const pkg = JSON.parse(readFileSync(file, 'utf8')) as { example?: ExampleConfig };
return pkg.example ?? {};
}
function run(
cmd: string,
args: string[],
opts: { cwd: string; env?: Record; timeoutMs: number }
): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
return new Promise(resolvePromise => {
const child = spawn(cmd, args, { cwd: opts.cwd, env: { ...process.env, ...opts.env } });
let stdout = '';
let stderr = '';
child.stdout.on('data', d => (stdout += String(d)));
child.stderr.on('data', d => (stderr += String(d)));
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolvePromise({ code: 124, stdout, stderr: stderr + '\n[harness] timed out', timedOut: true });
}, opts.timeoutMs);
child.on('close', code => {
clearTimeout(timer);
resolvePromise({ code: code ?? 1, stdout, stderr, timedOut: false });
});
child.on('error', err => {
clearTimeout(timer);
resolvePromise({ code: 1, stdout, stderr: stderr + `\n[harness] spawn error: ${err.message}`, timedOut: false });
});
});
}
/**
* Story `client.ts` files are top-level-await scripts: a thrown `check.*`
* propagates as an unhandled rejection (Node prints + exits 1); a clean run
* exits 0 once `client.close()` releases the last handle. The harness prints
* PASS/FAIL itself from the child's exit code — there is no in-band OK/FAIL
* line. A timeout means the process never exited on its own and is reported as
* a hang (possible unclosed handle).
*/
function toLegResult(
story: string,
leg: string,
result: { code: number; stdout: string; stderr: string; timedOut: boolean },
config: ExampleConfig,
serverLog?: string
): LegResult {
if (result.timedOut) {
return { story, leg, ok: false, detail: `(hung — possible unclosed handle)\n${result.stderr || result.stdout}${serverLog ?? ''}` };
}
const ok = result.code === 0 && (!config.expects?.stdout || result.stdout.includes(config.expects.stdout));
return {
story,
leg,
ok,
detail: ok
? (result.stdout.trim().split('\n').pop() ?? '')
: `exit ${result.code}\n${result.stderr || result.stdout}${serverLog ?? ''}`
};
}
async function waitForPort(port: number, timeoutMs: number): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ok = await new Promise(resolvePromise => {
const sock = connect({ port, host: '127.0.0.1' }, () => {
sock.destroy();
resolvePromise(true);
});
sock.on('error', () => resolvePromise(false));
});
if (ok) return true;
await new Promise(r => setTimeout(r, 200));
}
return false;
}
interface LegResult {
story: string;
leg: string;
ok: boolean;
detail: string;
}
const eraArgs = (era: Era): string[] => (era === 'legacy' ? ['--legacy'] : []);
async function runStdioLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise {
const timeoutMs = config.timeoutMs ?? 30_000;
const result = await run(TSX, [join(dir, 'client.ts'), ...eraArgs(era)], { cwd: ROOT, timeoutMs });
return toLegResult(story, `stdio/${era}`, result, config);
}
async function runHttpLeg(story: string, dir: string, config: ExampleConfig, era: Era): Promise {
const timeoutMs = config.timeoutMs ?? 30_000;
const port = assignPort(story, config);
const path = config.path ?? '/mcp';
const url = `http://127.0.0.1:${port}${path}`;
let serverStderr = '';
const server: ChildProcess = spawn(TSX, [join(dir, 'server.ts'), '--http', '--port', String(port)], {
cwd: ROOT,
env: { ...process.env, PORT: String(port), ...config.env }
});
server.stderr?.on('data', d => (serverStderr += String(d)));
server.stdout?.on('data', d => (serverStderr += String(d)));
try {
const ready = await waitForPort(port, 15_000);
if (!ready) {
return { story, leg: `http/${era}`, ok: false, detail: `server never bound :${port}\n--- server log ---\n${serverStderr}` };
}
const result = await run(TSX, [join(dir, 'client.ts'), '--http', url, ...eraArgs(era)], { cwd: ROOT, timeoutMs });
return toLegResult(story, `http/${era}`, result, config, `\n--- server log ---\n${serverStderr}`);
} finally {
server.kill('SIGTERM');
await new Promise(r => setTimeout(r, 100));
// `.killed` flips true the moment kill() is called, so it can't gate
// the backstop; check whether the process actually exited instead.
if (server.exitCode === null && server.signalCode === null) server.kill('SIGKILL');
}
}
async function main(): Promise {
const stories = readdirSync(EXAMPLES, { withFileTypes: true })
.filter(d => d.isDirectory() && !NON_STORY.has(d.name))
.map(d => d.name)
.filter(name => existsSync(join(EXAMPLES, name, 'client.ts')))
.sort();
const results: LegResult[] = [];
const excluded: Array<{ story: string; reason: string }> = [];
for (const story of stories) {
const dir = join(EXAMPLES, story);
const config = readConfig(dir);
if (config.excluded) {
excluded.push({ story, reason: config.excluded });
console.log(`\n::group::example ${story}\nSKIPPED: ${config.excluded}\n::endgroup::`);
continue;
}
const transports: Transport[] = config.transports ?? ['stdio', 'http'];
const era = config.era ?? 'dual';
const eras: Era[] = era === 'dual' ? ['modern', 'legacy'] : [era];
console.log(`\n::group::example ${story} (${transports.join('+')} × ${era})`);
for (const t of transports) {
for (const e of eras) {
const r = t === 'stdio' ? await runStdioLeg(story, dir, config, e) : await runHttpLeg(story, dir, config, e);
results.push(r);
console.log(`[${r.leg}] ${r.ok ? 'PASS' : 'FAIL'}: ${r.detail.split('\n')[0]}`);
if (!r.ok) console.log(r.detail);
}
}
console.log('::endgroup::');
}
const passed = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok);
console.log('\n=== examples e2e summary ===');
console.log(`stories: ${stories.length - excluded.length} run / ${excluded.length} excluded`);
console.log(`legs: ${passed} passed / ${failed.length} failed`);
for (const r of failed) console.log(` FAIL ${r.story} [${r.leg}]`);
for (const e of excluded) console.log(` SKIP ${e.story}: ${e.reason}`);
process.exit(failed.length === 0 ? 0 : 1);
}
void main();