import { INestApplication } from "@nestjs/common"; import request from "supertest"; import { buildTestApp } from "./app.harness"; /** * MCP controller integration tests. * * The MCP endpoint speaks the Model Context Protocol (streamable HTTP * transport). We test: * - that the endpoint is reachable and returns a recognised MCP response * - that tool invocations for read-only, non-browser tools work end-to-end * - that tools with bad inputs return error payloads (not HTTP 5xx) * * Browser-dependent tools (open_url, exec_code) require a live Playwright * session and are not covered here. */ describe("McpController", () => { let app: INestApplication; beforeAll(async () => { app = await buildTestApp(); }); afterAll(async () => { await app.close(); }); /** Parse the JSON-RPC payload from an SSE response body. */ function parseSse(text: string): Record { const match = text.match(/^data:\s*(.+)$/m); if (!match) throw new Error(`No SSE data line found in: ${text}`); return JSON.parse(match[1]) as Record; } /** Send a single MCP tool call and return the parsed response body. */ async function mcpCall(toolName: string, args: Record = {}) { const res = await request(app.getHttpServer()) .post("/mcp") .set("Content-Type", "application/json") .set("Accept", "application/json, text/event-stream") .send({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args }, }); return { status: res.status, rpc: parseSse(res.text) }; } // ── Connectivity ─────────────────────────────────────────────────────────── describe("POST /mcp — connectivity", () => { it("is reachable and returns a non-5xx status", async () => { const res = await request(app.getHttpServer()) .post("/mcp") .set("Content-Type", "application/json") .set("Accept", "application/json, text/event-stream") .send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test", version: "0" }, }, }); expect(res.status).toBe(200); }); }); // ── list_keys tool (removed) ────────────────────────────────────────────── describe("list_keys", () => { it("returns MCP error for removed tool", async () => { const { status, rpc } = await mcpCall("list_keys"); expect(status).toBe(200); const result = rpc.result as { isError: boolean; content?: { text: string }[]; }; expect(result.isError).toBe(true); }); }); // ── list_sessions tool ───────────────────────────────────────────────────── describe("list_sessions", () => { it("returns a paginated result with a data array", async () => { const { status, rpc } = await mcpCall("list_sessions"); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const body = JSON.parse(result.content[0].text) as { data: unknown[]; total: number; }; expect(Array.isArray(body.data)).toBe(true); expect(typeof body.total).toBe("number"); }); }); // ── list_environments tool ───────────────────────────────────────────────── describe("list_environments", () => { it("returns a paginated result with a data array", async () => { const { status, rpc } = await mcpCall("list_environments"); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const body = JSON.parse(result.content[0].text) as { data: unknown[]; total: number; }; expect(Array.isArray(body.data)).toBe(true); expect(typeof body.total).toBe("number"); }); }); // ── create_environment tool ──────────────────────────────────────────────── describe("create_environment", () => { it("creates an environment via MCP", async () => { const { status, rpc } = await mcpCall("create_environment", { name: "mcp-test-env", data: { id: "https://id.example.com" }, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const created = JSON.parse(result.content[0].text) as { name: string }; expect(created.name).toBe("mcp-test-env"); }); }); // ── delete_session tool with unknown id ──────────────────────────────────── describe("delete_session", () => { it("returns an MCP error result for a non-existent session id", async () => { const { status, rpc } = await mcpCall("delete_session", { id: 999999 }); expect(status).toBe(200); // MCP wraps service errors as isError:true content, not HTTP errors const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); // ── create_scenario / update_scenario timeoutSeconds ────────────────────── describe("create_scenario with timeoutSeconds", () => { it("saves timeoutSeconds on the created scenario", async () => { const { status, rpc } = await mcpCall("create_scenario", { name: "mcp-timeout-sc", timeoutSeconds: 300, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const created = JSON.parse(result.content[0].text) as { name: string; timeoutSeconds: number | null; }; expect(created.name).toBe("mcp-timeout-sc"); expect(created.timeoutSeconds).toBe(300); }); it("stores null timeoutSeconds when not provided", async () => { const { rpc } = await mcpCall("create_scenario", { name: "mcp-no-timeout", }); const result = rpc.result as { content: { text: string }[] }; const created = JSON.parse(result.content[0].text) as { timeoutSeconds: number | null; }; expect(created.timeoutSeconds).toBeNull(); }); }); describe("update_scenario timeoutSeconds", () => { it("updates and clears timeoutSeconds", async () => { // create const createRpc = ( await mcpCall("create_scenario", { name: "mcp-upd-timeout" }) ).rpc; const created = JSON.parse( (createRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; // set const setRpc = ( await mcpCall("update_scenario", { id: created.id, timeoutSeconds: 120, }) ).rpc; const updated = JSON.parse( (setRpc.result as { content: { text: string }[] }).content[0].text, ) as { timeoutSeconds: number | null }; expect(updated.timeoutSeconds).toBe(120); // clear const clearRpc = ( await mcpCall("update_scenario", { id: created.id, timeoutSeconds: null, }) ).rpc; const cleared = JSON.parse( (clearRpc.result as { content: { text: string }[] }).content[0].text, ) as { timeoutSeconds: number | null }; expect(cleared.timeoutSeconds).toBeNull(); }); }); // ── create_scenario_step / update_scenario_step timeoutSeconds ──────────── describe("create_scenario_step with timeoutSeconds", () => { it("saves timeoutSeconds on the created step", async () => { // create a scenario first const scRpc = ( await mcpCall("create_scenario", { name: "mcp-step-timeout-parent" }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const { status, rpc } = await mcpCall("create_scenario_step", { scenarioId: sc.id, order: 0, type: "exec", execCode: "return 1;", timeoutSeconds: 45, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const step = JSON.parse(result.content[0].text) as { timeoutSeconds: number | null; }; expect(step.timeoutSeconds).toBe(45); }); }); describe("update_scenario_step timeoutSeconds", () => { it("updates and clears step timeoutSeconds", async () => { // create scenario + step const scRpc = ( await mcpCall("create_scenario", { name: "mcp-step-upd-parent" }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const stepRpc = ( await mcpCall("create_scenario_step", { scenarioId: sc.id, order: 0, type: "exec", }) ).rpc; const step = JSON.parse( (stepRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; // set const setRpc = ( await mcpCall("update_scenario_step", { scenarioId: sc.id, stepId: step.id, timeoutSeconds: 90, }) ).rpc; const updated = JSON.parse( (setRpc.result as { content: { text: string }[] }).content[0].text, ) as { timeoutSeconds: number | null }; expect(updated.timeoutSeconds).toBe(90); // clear const clearRpc = ( await mcpCall("update_scenario_step", { scenarioId: sc.id, stepId: step.id, timeoutSeconds: null, }) ).rpc; const cleared = JSON.parse( (clearRpc.result as { content: { text: string }[] }).content[0].text, ) as { timeoutSeconds: number | null }; expect(cleared.timeoutSeconds).toBeNull(); }); }); });