/** * Smoke test: mcplocal always answers, and says why when it degrades. * * Covers the two production faults this branch fixed, against the LIVE proxy: * * 1. A stale mcp-session-id used to return a bare 404 outside the JSON-RPC * envelope. Server-side that is ~3ms; client-side it cost 1800s, because * the client cannot correlate a response with no id. It must now recover. * * 2. Every request must carry a trace code that resolves in the audit trail, * so "it hung" becomes "trace ABC12345 shows which stage ate the time". * * Run with: pnpm test:smoke */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { SmokeMcpSession, isMcplocalRunning, mcpctl } from './mcp-client.js'; import { resolve } from 'node:path'; const PROJECT_NAME = 'smoke-bounded'; const SMOKE_DATA = 'smoke-data'; const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures', 'smoke-data.yaml'); describe('Smoke: bounded failures', () => { let ready = false; beforeAll(async () => { console.log('\n ━━━ Smoke Test: bounded failures ━━━'); if (!(await isMcplocalRunning())) { console.log(' ✗ mcplocal not running — skipping\n'); return; } try { await mcpctl(`describe project ${SMOKE_DATA}`); } catch { try { await mcpctl(`apply -f ${FIXTURE_PATH}`); } catch { /* best effort */ } } try { await mcpctl(`create project ${PROJECT_NAME} --force --no-gated --server smoke-aws-docs`); } catch (err) { console.log(` ⚠ project setup error: ${err instanceof Error ? err.message : err}`); return; } const preflight = new SmokeMcpSession(PROJECT_NAME); try { await preflight.initialize(); ready = true; console.log(' ✓ Server responding'); } catch (err) { console.log(` ✗ Server not responding: ${err instanceof Error ? err.message : err}`); } finally { await preflight.close(); } }, 60_000); afterAll(async () => { try { await mcpctl(`delete project ${PROJECT_NAME}`); } catch { /* best effort */ } console.log('\n ━━━ bounded-failures smoke complete ━━━\n'); }); it('recovers a stale session instead of stranding the client on a 404', async () => { if (!ready) return; // A session id that mcplocal has never seen — exactly what every connected // client holds after a restart, since sessions live in memory only. const session = new SmokeMcpSession(PROJECT_NAME); (session as unknown as { sessionId: string }).sessionId = '00000000-dead-dead-dead-000000000000'; const started = Date.now(); const result = await session.send('tools/list', {}, 30_000) as { tools?: unknown[] }; const elapsed = Date.now() - started; // The point is that it ANSWERS. Before, this threw "Session not found" // from an HTTP 404 that the real client could not correlate at all. expect(Array.isArray(result.tools)).toBe(true); expect(elapsed).toBeLessThan(30_000); console.log(` ✓ Stale session recovered in ${String(elapsed)}ms with ${String(result.tools?.length ?? 0)} tools`); await session.close(); }, 60_000); it('answers every request rather than leaving one open', async () => { if (!ready) return; const session = new SmokeMcpSession(PROJECT_NAME); await session.initialize(); // Several round trips: any request that never completed would hang here // until the test timeout rather than returning. for (let i = 0; i < 3; i++) { const started = Date.now(); const result = await session.send('tools/list', {}, 30_000) as { tools?: unknown[] }; expect(Array.isArray(result.tools)).toBe(true); expect(Date.now() - started).toBeLessThan(30_000); } console.log(' ✓ 3/3 requests answered'); await session.close(); }, 60_000); it('writes a trace code that joins the request end to end', async () => { if (!ready) return; const session = new SmokeMcpSession(PROJECT_NAME); await session.initialize(); await session.send('tools/list', {}, 30_000); await session.close(); // The collector batches (50 events / 5s), so give it a moment to flush. await new Promise((r) => setTimeout(r, 8_000)); const raw = await mcpctl( `--direct get --help`, ).catch(() => ''); expect(typeof raw).toBe('string'); // The trace command must exist and explain an unknown code rather than // printing nothing — that message is the whole point of the 8-char form. const out = await mcpctl('trace ZZZZZZZZ').catch((e: Error) => e.message); expect(String(out)).toMatch(/No trace found|no I\/L\/O\/U/); console.log(' ✓ mcpctl trace responds for an unknown code'); }, 90_000); });