morphit/apps/mcp-server/scripts/mcp-http-transport-smoke.ts

363 lines
13 KiB
TypeScript

#!/usr/bin/env tsx
/**
* mcp-http-transport-smoke — boots morphit-mcp in HTTP transport mode
* on an ephemeral loopback port and verifies the wire surface AND every
* hardening control end-to-end (beta16 §45). The behavioral counterpart
* to the static mcp-webpush-install-defaults-smoke.
*
* Why it exists: cp251 shipped a persistent morphit-mcp.service whose
* unit assumed an HTTP server on 127.0.0.1:8124, but the server was
* stdio-only — so the daemon read EOF on its empty stdin and exited 0 in
* under a second, leaving nothing listening (status: inactive (dead),
* Duration 873ms). This smoke proves the HTTP transport actually serves
* the protocol and that the defenses fire.
*
* Scenarios:
* 1. GET /health → 200 {status:ok,transport:http}
* 2. initialize → 200, serverInfo.name === morphit-mcp
* 3. tools/list (stateless, no session header) → 200 with all 5 tools
* 4. DNS-rebinding: Host: evil → 403
* 5. method: GET / → 405
* 6. path: POST /nope → 404
* 7. Origin: evil (no allowlist) → 403
* 8. Content-Type not JSON → 415
* 9. body over the cap → 413 (and the server stays up)
* 10. rate limit: burst over the per-client limit → 429
* 11. fail-closed: a non-loopback bind without the override exits 1
*
* Raw node:http is used (not fetch) because undici treats Host/Origin as
* forbidden header names and silently drops them, which would make the
* DNS-rebinding checks pass vacuously. Pure-tsx, sandbox-runnable
* (loopback only; no external network).
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import process from 'node:process';
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const RESET = '\x1b[0m';
const here = dirname(fileURLToPath(import.meta.url)); // apps/mcp-server/scripts
const mcpRoot = join(here, '..'); // apps/mcp-server
const repoRoot = join(mcpRoot, '..', '..'); // repo root
const tsxBin = join(repoRoot, 'node_modules', '.bin', 'tsx');
const mainTs = join(mcpRoot, 'src', 'main.ts');
interface Result {
name: string;
passed: boolean;
detail?: string;
}
const results: Result[] = [];
const pass = (name: string): void => void results.push({ name, passed: true });
const fail = (name: string, detail = ''): void => void results.push({ name, passed: false, detail });
// Derive ports from PID to avoid collisions with parallel smokes.
const base = 20000 + (process.pid % 10000);
const PORT_A = base; // behavioral + security
const PORT_B = base + 1; // rate limit
const PORT_C = base + 2; // non-loopback refusal
const ACCEPT = 'application/json, text/event-stream';
function spawnHttp(port: number, extraEnv: Record<string, string>): ChildProcess {
return spawn(tsxBin, [mainTs], {
env: {
...process.env,
MORPHIT_MCP_TRANSPORT: 'http',
MORPHIT_MCP_HTTP_HOST: '127.0.0.1',
MORPHIT_MCP_HTTP_PORT: String(port),
MORPHIT_MCP_INSTANCE_URL: 'https://morphit.io',
...extraEnv
},
stdio: ['ignore', 'ignore', 'pipe']
});
}
function killTree(cp: ChildProcess | null): void {
if (!cp || cp.killed) return;
try {
cp.kill('SIGTERM');
} catch {
/* ignore */
}
setTimeout(() => {
try {
cp.kill('SIGKILL');
} catch {
/* ignore */
}
}, 3000).unref();
}
interface Reply {
status: number;
body: string;
}
function req(opts: {
port: number;
method?: string;
path?: string;
headers?: Record<string, string>;
body?: string;
}): Promise<Reply> {
return new Promise((resolve, reject) => {
const r = httpRequest(
{
host: '127.0.0.1',
port: opts.port,
method: opts.method ?? 'GET',
path: opts.path ?? '/',
headers: opts.headers ?? {}
},
(res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
}
);
r.on('error', reject);
if (opts.body !== undefined) r.write(opts.body);
r.end();
});
}
function jsonPost(port: number, body: unknown, extraHeaders: Record<string, string> = {}): Promise<Reply> {
return req({
port,
method: 'POST',
path: '/',
headers: { 'content-type': 'application/json', accept: ACCEPT, ...extraHeaders },
body: typeof body === 'string' ? body : JSON.stringify(body)
});
}
async function waitForHealth(port: number, timeoutMs = 15000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const r = await req({ port, path: '/health' });
if (r.status === 200) return true;
} catch {
/* not up yet */
}
await new Promise((r) => setTimeout(r, 250));
}
return false;
}
async function main(): Promise<void> {
let a: ChildProcess | null = null;
let b: ChildProcess | null = null;
try {
// ── Server A: behavioral + security (low body cap, ample rate) ──
a = spawnHttp(PORT_A, {
MORPHIT_MCP_RATE_LIMIT_PER_MIN: '100',
MORPHIT_MCP_MAX_BODY_BYTES: '1000'
});
if (!(await waitForHealth(PORT_A))) {
fail('server A reaches /health', 'did not become healthy within 15s');
} else {
// 1. health body
const h = await req({ port: PORT_A, path: '/health' });
let hj: { status?: string; transport?: string } | null = null;
try {
hj = JSON.parse(h.body);
} catch {
/* leave null */
}
if (h.status === 200 && hj?.status === 'ok' && hj?.transport === 'http')
pass('GET /health → 200 ok/http');
else fail('GET /health → 200 ok/http', `status=${h.status} body=${h.body.slice(0, 80)}`);
// 2. initialize
const initRes = await jsonPost(PORT_A, {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'smoke', version: '0.0.0' }
}
});
let sName: string | undefined;
try {
sName = JSON.parse(initRes.body)?.result?.serverInfo?.name;
} catch {
/* leave undefined */
}
if (initRes.status === 200 && sName === 'morphit-mcp')
pass('initialize → 200, serverInfo morphit-mcp');
else fail('initialize → 200, serverInfo morphit-mcp', `status=${initRes.status} name=${sName}`);
// 3. tools/list — stateless (no session header)
const tlRes = await jsonPost(PORT_A, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
let names: string[] = [];
try {
names = (JSON.parse(tlRes.body)?.result?.tools ?? []).map((t: { name: string }) => t.name);
} catch {
/* leave empty */
}
const want = [
'morphit_search_orders',
'morphit_list_instances',
'morphit_list_payment_methods',
'morphit_get_listing',
'morphit_describe'
];
if (tlRes.status === 200 && names.length === 5 && want.every((n) => names.includes(n)))
pass('tools/list (stateless, no session) → all 5 tools');
else fail('tools/list (stateless, no session) → all 5 tools', `status=${tlRes.status} names=${names.join(',')}`);
// 4. DNS-rebinding: Host evil → 403
const hostRes = await jsonPost(PORT_A, {}, { Host: 'evil.example' });
hostRes.status === 403 ? pass('Host: evil → 403') : fail('Host: evil → 403', `status=${hostRes.status}`);
// 5. method GET / → 405
const getRes = await req({ port: PORT_A, method: 'GET', path: '/' });
getRes.status === 405 ? pass('GET / → 405') : fail('GET / → 405', `status=${getRes.status}`);
// 6. bad path → 404
const pathRes = await req({
port: PORT_A,
method: 'POST',
path: '/nope',
headers: { 'content-type': 'application/json' },
body: '{}'
});
pathRes.status === 404 ? pass('POST /nope → 404') : fail('POST /nope → 404', `status=${pathRes.status}`);
// 7. Origin evil → 403
const origRes = await jsonPost(PORT_A, {}, { Origin: 'https://evil.com' });
origRes.status === 403 ? pass('Origin: evil → 403') : fail('Origin: evil → 403', `status=${origRes.status}`);
// 8. wrong Content-Type → 415
const ctRes = await req({
port: PORT_A,
method: 'POST',
path: '/',
headers: { 'content-type': 'text/plain', accept: ACCEPT },
body: 'hi'
});
ctRes.status === 415 ? pass('wrong Content-Type → 415') : fail('wrong Content-Type → 415', `status=${ctRes.status}`);
// 9. body over cap → 413, and the server stays up
const capRes = await jsonPost(PORT_A, 'x'.repeat(4000));
const stillUp = (await req({ port: PORT_A, path: '/health' }).catch(() => ({ status: 0 }))).status === 200;
if (capRes.status === 413 && stillUp) pass('body over cap → 413 (server survives)');
else fail('body over cap → 413 (server survives)', `status=${capRes.status} stillUp=${stillUp}`);
}
// ── Server B: rate limit (limit 3) ──
b = spawnHttp(PORT_B, { MORPHIT_MCP_RATE_LIMIT_PER_MIN: '3' });
if (!(await waitForHealth(PORT_B))) {
fail('server B reaches /health', 'did not become healthy within 15s');
} else {
const codes: number[] = [];
for (let i = 0; i < 5; i++) {
// GET / → 405 but consumes a token; after the bucket drains → 429
const r = await req({ port: PORT_B, method: 'GET', path: '/' }).catch(() => ({ status: 0 }));
codes.push(r.status);
}
const ok = codes.slice(0, 3).every((c) => c === 405) && codes.slice(3).every((c) => c === 429);
ok
? pass(`rate limit → 429 after burst (${codes.join(',')})`)
: fail('rate limit → 429 after burst', codes.join(','));
}
// ── 11. bind guard: refuse all-interfaces, allow private/bridge ──
// Spawn DETACHED (own process group) and tear down with a GROUP
// kill, so the tsx wrapper AND the node server it launches are
// both reaped. A plain SIGKILL on the wrapper orphans the server
// whenever the bind SUCCEEDS — which it does on a dockerized CI
// runner where 172.18.0.1 is a real bridge-gateway interface (in
// the sandbox the bind fails EADDRNOTAVAIL and the server self-
// exits, hiding this). The orphan's inherited stderr pipe then
// kept THIS smoke alive until the 240s runner kill. Resolution is
// bind-outcome independent: `close` (the refusal path — gives the
// exit code) | a "listening on" line (bind-OK path — the server
// will NOT self-close, so resolve and reap it) | a timeout.
const groupKill = (c: ChildProcess, sig: NodeJS.Signals): void => {
try {
if (typeof c.pid === 'number') process.kill(-c.pid, sig);
} catch {
/* group already gone */
}
};
const spawnGuard = (host: string, port: number): Promise<{ code: number | null; err: string }> =>
new Promise((resolve) => {
const c = spawn(tsxBin, [mainTs], {
env: {
...process.env,
MORPHIT_MCP_TRANSPORT: 'http',
MORPHIT_MCP_HTTP_HOST: host,
MORPHIT_MCP_HTTP_PORT: String(port)
},
stdio: ['ignore', 'ignore', 'pipe'],
detached: true
});
let err = '';
let done = false;
let t: ReturnType<typeof setTimeout>;
const finish = (code: number | null): void => {
if (done) return;
done = true;
clearTimeout(t);
groupKill(c, 'SIGKILL'); // reap wrapper + server (no-op if already exited)
resolve({ code, err });
};
c.stderr?.on('data', (d) => {
err += d.toString();
// Bind succeeded: server logged "listening on …" and will
// not exit on its own — the guard allowed it, which is all
// this case asserts, so resolve and reap.
if (/listening on /.test(err)) finish(null);
});
c.on('close', (code) => finish(code));
t = setTimeout(() => finish(null), 10000);
t.unref();
});
// 0.0.0.0 binds all interfaces → must refuse (exit 1).
const zero = await spawnGuard('0.0.0.0', PORT_C);
if (zero.code === 1 && /refusing to bind all interfaces/.test(zero.err))
pass('0.0.0.0 bind refused (exit 1)');
else fail('0.0.0.0 bind refused (exit 1)', `code=${zero.code} err=${zero.err.slice(0, 140)}`);
// 172.18.0.1 (Docker bridge / private) must NOT trip the guard — it
// proceeds to bind (only failing EADDRNOTAVAIL here because the
// sandbox has no such interface; on a real dockerized host it binds).
const bridge = await spawnGuard('172.18.0.1', PORT_C);
if (!/refusing to bind/.test(bridge.err)) pass('172.18.0.1 (bridge) bind allowed by guard');
else fail('172.18.0.1 (bridge) bind allowed by guard', bridge.err.slice(0, 140));
} finally {
killTree(a);
killTree(b);
}
// ── Report ──
console.log('');
for (const r of results) {
const mark = r.passed ? `${GREEN}${RESET}` : `${RED}${RESET}`;
console.log(` ${mark} ${r.name}`);
if (!r.passed && r.detail) console.log(` ${r.detail}`);
}
const failed = results.filter((r) => !r.passed).length;
console.log('');
if (failed === 0) {
console.log(`✓ all ${results.length} mcp-http-transport-smoke scenarios passed`);
} else {
console.log(`${failed}/${results.length} mcp-http-transport-smoke scenarios failed`);
process.exit(1);
}
}
main().catch((err) => {
console.error('mcp-http-transport-smoke fatal:', err);
process.exit(1);
});