Files
apex-mcp-server/packages/builder/test/setup.ts
david cc6e0ce83c feat(builder)!: remove deprecated SSE transport - Streamable HTTP only (2.0.0)
BREAKING CHANGE: GET /sse and POST /messages now answer 404. Every client
connects over Streamable HTTP at /mcp. The sse remote is removed from
server.json.

- Add unauthenticated GET /health ({status, version}); no session, no
  rate-limit touch.
- Add any-origin CORS across the whole HTTP surface, incl. OPTIONS
  preflight and error responses; mcp-session-id exposed to browsers.
- Unify every version string at 2.0.0; both McpServer identities now read
  the version from package.json at build time instead of hardcoding it.
- Raise @modelcontextprotocol/sdk floor to ^1.30.0 and sync the local
  install with the lockfile.
- Tests: retire the SSE-shaped auth-gating suite (its unique
  process-liveness test is ported to streamable-http-gating), move the
  shared harness and rate-limit suite to StreamableHTTPClientTransport,
  and add http-surface.test.ts for CORS//health//sse-404 coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:11:18 +02:00

131 lines
5.2 KiB
TypeScript

import { spawn, ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
const PROJECT_ROOT = resolve(import.meta.dirname!, '..');
export interface ServerContext {
client: Client;
process: ChildProcess;
port: number;
}
export function getMnemonic(): string {
const mnemonic = readFileSync(resolve(PROJECT_ROOT, 'mnemonic.txt'), 'utf-8').trim();
const wordCount = mnemonic.split(/\s+/).length;
const validLengths = [12, 15, 18, 21, 24];
if (!validLengths.includes(wordCount)) {
throw new Error(`mnemonic.txt has ${wordCount} words, expected one of: ${validLengths.join(', ')}`);
}
return mnemonic;
}
export async function startServer(): Promise<ServerContext> {
const port = 3100 + Math.floor(Math.random() * 900);
const child = spawn('node', ['build/index.js'], {
cwd: PROJECT_ROOT,
env: { ...process.env, PORT: String(port) },
stdio: ['ignore', 'ignore', 'pipe'],
});
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Server start timeout (60s)')), 60_000);
child.stderr!.on('data', (chunk: Buffer) => {
if (chunk.toString().includes('listening on port')) {
clearTimeout(timeout);
resolve();
}
});
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
child.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}`)); });
});
const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
const client = new Client({ name: 'integration-test', version: '1.0.0' });
await client.connect(transport);
return { client, process: child, port };
}
export async function stopServer(ctx: ServerContext): Promise<void> {
try { await ctx.client.close(); } catch {}
ctx.process.kill('SIGTERM');
}
export interface RawServerHandle {
process: ChildProcess;
port: number;
/** Snapshot of everything the server has written to stderr so far. */
stderr(): string;
}
/**
* Spawn the built server directly, without connecting an SDK client.
*
* startServer() above always connects a default Client with no Authorization
* header, which is exactly right for the pre-existing suites (auth is never
* configured there) but wrong for auth-gating/rate-limit tests: those need
* MCP_AUTH_TOKENS set, several distinct Authorization headers driven by hand
* (including deliberately-wrong ones), and a captured stderr stream to prove
* no token leaks into it. A bare "add an env param" to startServer() can't
* serve those needs without either connecting a client that immediately 401s
* (auth on, no header) or changing ServerContext's shape for every existing
* caller - so this is a separate, additive function instead.
*/
export async function spawnServer(envOverrides: Record<string, string> = {}): Promise<RawServerHandle> {
// Different port range than startServer()'s 3100-3999, and retried on
// failure: this suite spawns more servers per run than the pre-existing
// ones, so a single random draw is more likely to collide under CI
// parallelism (multiple test files spawning concurrently).
const maxAttempts = 5;
let lastErr: Error | undefined;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const port = 4100 + Math.floor(Math.random() * 1800);
const child = spawn('node', ['build/index.js'], {
cwd: PROJECT_ROOT,
env: { ...process.env, PORT: String(port), ...envOverrides },
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderrBuf = '';
child.stderr!.on('data', (chunk: Buffer) => { stderrBuf += chunk.toString(); });
try {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Server start timeout (60s)')), 60_000);
const onData = () => {
if (stderrBuf.includes('listening on port')) {
clearTimeout(timeout);
child.stderr!.off('data', onData);
resolve();
}
};
child.stderr!.on('data', onData);
child.on('error', (err) => { clearTimeout(timeout); reject(err); });
child.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}. stderr so far: ${stderrBuf}`)); });
});
return { process: child, port, stderr: () => stderrBuf };
} catch (err) {
lastErr = err as Error;
try { child.kill('SIGKILL'); } catch {}
}
}
throw lastErr ?? new Error('spawnServer: exhausted retries');
}
export async function stopRawServer(handle: RawServerHandle): Promise<void> {
handle.process.kill('SIGTERM');
}
export async function callTool(client: Client, name: string, args: Record<string, unknown> = {}): Promise<string> {
const result = await client.callTool({ name, arguments: args });
const content = result.content as Array<{ type: string; text?: string }>;
return content?.[0]?.text ?? '';
}
export function wait(seconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, seconds * 1000));
}