import { INestApplication } from "@nestjs/common"; import { getRepositoryToken } from "@nestjs/typeorm"; import request from "supertest"; import * as http from "http"; import { Repository } from "typeorm"; import { buildTestApp } from "./app.harness"; import { ScenarioEntity } from "../src/scenario/scenario.entity"; import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity"; import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity"; import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity"; import { ScenarioSchedulerService } from "../src/scenario/scenario-scheduler.service"; /** * 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; let testServer: http.Server; let testServerUrl: string; let scheduler: ScenarioSchedulerService; let scenarioRepo: Repository; let stepRepo: Repository; let runRepo: Repository; let runStepRepo: Repository; beforeAll(async () => { app = await buildTestApp(); scheduler = app.get(ScenarioSchedulerService); scenarioRepo = app.get>( getRepositoryToken(ScenarioEntity), ); stepRepo = app.get>( getRepositoryToken(ScenarioStepEntity), ); runRepo = app.get>( getRepositoryToken(ScenarioRunEntity), ); runStepRepo = app.get>( getRepositoryToken(ScenarioRunStepEntity), ); // Start a test HTTP server for downloadFile tests testServer = await createTestServer(); testServerUrl = `http://localhost:${(testServer.address() as any).port}`; }); afterAll(async () => { if (testServer) { testServer.close(); } await app.close(); }); // ── helpers ──────────────────────────────────────────────────────────────── function createTestServer(): Promise { return new Promise((resolve) => { const server = http.createServer((req, res) => { if (req.url === "/test-file.bin") { res.writeHead(200, { "Content-Type": "application/octet-stream" }); res.end(Buffer.from("test downloaded file content")); } else if (req.url === "/test.pdf") { res.writeHead(200, { "Content-Type": "application/pdf" }); res.end(Buffer.from("fake pdf content")); } else { res.writeHead(404); res.end(); } }); server.listen(0, "localhost", () => { resolve(server); }); }); } /** 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) }; } async function createScenario(name: string) { return scenarioRepo.save(scenarioRepo.create({ name })); } async function createStep(scenarioId: string, execCode: string, order = 0) { return stepRepo.save( stepRepo.create({ scenarioId, order, execCode, }), ); } async function createRun(scenarioId: string) { return runRepo.save(runRepo.create({ scenarioId, status: "pending" })); } async function createRunStep(runId: string, scenarioStepId: string, order = 0) { return runStepRepo.save( runStepRepo.create({ runId, scenarioStepId, order, status: "pending", }), ); } async function waitForRunCompletion(runId: string, maxAttempts = 50) { for (let index = 0; index < maxAttempts; index += 1) { await scheduler.pickUpPendingRuns(); await new Promise((resolve) => setTimeout(resolve, 100)); const run = await runRepo.findOneBy({ id: runId }); if (run && (run.status === "pass" || run.status === "fail")) { return run; } } throw new Error(`Run ${runId} did not complete within timeout`); } // ── 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(); }); }); // ── Scenario File Tools ──────────────────────────────────────────────────── describe("upload_scenario_file", () => { it("uploads a small text file via MCP and returns metadata", async () => { const scRpc = ( await mcpCall("create_scenario", { name: "mcp-file-upload-test" }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const content = "Hello, MCP!"; const contentBase64 = Buffer.from(content).toString("base64"); const { status, rpc } = await mcpCall("upload_scenario_file", { scenarioId: sc.id, name: "test.txt", contentBase64, mimeType: "text/plain", }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const uploaded = JSON.parse(result.content[0].text) as { id: string; name: string; mimeType: string; size: number; }; expect(uploaded.name).toBe("test.txt"); expect(uploaded.mimeType).toBe("text/plain"); expect(uploaded.size).toBe(content.length); expect(typeof uploaded.id).toBe("string"); }); it("returns MCP error for invalid base64", async () => { const scRpc = ( await mcpCall("create_scenario", { name: "mcp-file-bad-base64-test", }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const { status, rpc } = await mcpCall("upload_scenario_file", { scenarioId: sc.id, name: "test.txt", contentBase64: "!@#$%^&*()", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); describe("list_scenario_files", () => { it("returns uploaded files with metadata", async () => { const scRpc = ( await mcpCall("create_scenario", { name: "mcp-file-list-test" }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; // Upload a file const content = "Test content"; const contentBase64 = Buffer.from(content).toString("base64"); await mcpCall("upload_scenario_file", { scenarioId: sc.id, name: "test.txt", contentBase64, }); // List files const { status, rpc } = await mcpCall("list_scenario_files", { scenarioId: sc.id, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const list = JSON.parse(result.content[0].text) as { items: Array<{ id: string; name: string }>; total: number; }; expect(list.items.length).toBeGreaterThan(0); expect(list.items[0].name).toBe("test.txt"); expect(typeof list.total).toBe("number"); }); it("returns MCP error for non-existent scenario", async () => { const { status, rpc } = await mcpCall("list_scenario_files", { scenarioId: "00000000-0000-0000-0000-000000000000", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); describe("get_scenario_file_content", () => { it("retrieves file content as base64 with metadata", async () => { const scRpc = ( await mcpCall("create_scenario", { name: "mcp-file-content-test" }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; // Upload a file const originalContent = "Hello, this is test content!"; const contentBase64 = Buffer.from(originalContent).toString("base64"); const uploadRpc = ( await mcpCall("upload_scenario_file", { scenarioId: sc.id, name: "content-test.txt", contentBase64, mimeType: "text/plain", }) ).rpc; const uploaded = JSON.parse( (uploadRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; // Retrieve content const { status, rpc } = await mcpCall("get_scenario_file_content", { scenarioId: sc.id, fileId: uploaded.id, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const response = JSON.parse(result.content[0].text) as { file: { id: string; name: string; size: number }; contentBase64: string; encoding: string; }; // Verify roundtrip const retrievedContent = Buffer.from(response.contentBase64, "base64").toString( "utf8", ); expect(retrievedContent).toBe(originalContent); expect(response.file.name).toBe("content-test.txt"); expect(response.file.size).toBe(originalContent.length); expect(response.encoding).toBe("base64"); }); it("returns MCP error for unknown file ID", async () => { const scRpc = ( await mcpCall("create_scenario", { name: "mcp-file-unknown-id-test", }) ).rpc; const sc = JSON.parse( (scRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const { status, rpc } = await mcpCall("get_scenario_file_content", { scenarioId: sc.id, fileId: "00000000-0000-0000-0000-000000000000", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); // ── Run Artifact Tools ───────────────────────────────────────────────────── async function createRunArtifactFixture(name: string) { const scenario = await createScenario(name); const step = await createStep( scenario.id, `return await context.downloadFile('${testServerUrl}/test-file.bin', { filename: 'artifact.bin' });`, ); const run = await createRun(scenario.id); await createRunStep(run.id, step.id); const completedRun = await waitForRunCompletion(run.id); expect(completedRun.status).toBe("pass"); return { sc: scenario, run: completedRun }; } describe("list_run_files", () => { it("returns artifacts created during a run with expiry metadata", async () => { const { sc, run } = await createRunArtifactFixture("mcp-run-files-test"); const { status, rpc } = await mcpCall("list_run_files", { scenarioId: sc.id, runId: run.id, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const list = JSON.parse(result.content[0].text) as { items: Array<{ id: string; name: string; mimeType: string; expiresAt: string | null; }>; total: number; }; expect(list.total).toBe(1); expect(list.items).toHaveLength(1); expect(list.items[0].name).toBe("artifact.bin"); expect(list.items[0].mimeType).toBe("application/octet-stream"); expect(list.items[0].expiresAt).not.toBeNull(); }); it("returns MCP error for non-existent scenario", async () => { const { status, rpc } = await mcpCall("list_run_files", { scenarioId: "00000000-0000-0000-0000-000000000000", runId: "00000000-0000-0000-0000-000000000000", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); describe("get_run_file_content", () => { it("returns run artifact bytes as base64 for a valid file", async () => { const { sc, run } = await createRunArtifactFixture("mcp-run-file-content-test"); const listRpc = ( await mcpCall("list_run_files", { scenarioId: sc.id, runId: run.id, }) ).rpc; const list = JSON.parse( (listRpc.result as { content: { text: string }[] }).content[0].text, ) as { items: Array<{ id: string; name: string; mimeType: string }>; }; const file = list.items[0]; const { status, rpc } = await mcpCall("get_run_file_content", { scenarioId: sc.id, runId: run.id, fileId: file.id, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const response = JSON.parse(result.content[0].text) as { file: { id: string; name: string; mimeType: string; expiresAt: string | null }; contentBase64: string; encoding: string; }; expect(response.file.id).toBe(file.id); expect(response.file.name).toBe("artifact.bin"); expect(response.file.mimeType).toBe("application/octet-stream"); expect(response.file.expiresAt).not.toBeNull(); expect(response.encoding).toBe("base64"); expect(Buffer.from(response.contentBase64, "base64").toString("utf8")).toBe( "test downloaded file content", ); }); it("returns MCP error for mismatched run and file ids", async () => { const { sc: firstScenario, run: firstRun } = await createRunArtifactFixture( "mcp-run-file-mismatch-a", ); const { sc: secondScenario, run: secondRun } = await createRunArtifactFixture( "mcp-run-file-mismatch-b", ); const firstListRpc = ( await mcpCall("list_run_files", { scenarioId: firstScenario.id, runId: firstRun.id, }) ).rpc; const firstList = JSON.parse( (firstListRpc.result as { content: { text: string }[] }).content[0].text, ) as { items: Array<{ id: string }> }; const { status, rpc } = await mcpCall("get_run_file_content", { scenarioId: secondScenario.id, runId: secondRun.id, fileId: firstList.items[0].id, }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); // ── Snippet CRUD tools ──────────────────────────────────────────────────── describe("list_snippets", () => { it("returns a paginated result with a data array", async () => { const { status, rpc } = await mcpCall("list_snippets"); 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"); }); }); describe("create_snippet", () => { it("creates a snippet and returns it with alias and title", async () => { const { status, rpc } = await mcpCall("create_snippet", { alias: "mcp-test-snippet", title: "MCP Test Snippet", description: "Created by integration test", code: "await page.click('#btn');", }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const created = JSON.parse(result.content[0].text) as { id: string; alias: string; title: string; description: string | null; code: string; }; expect(created.alias).toBe("mcp-test-snippet"); expect(created.title).toBe("MCP Test Snippet"); expect(created.description).toBe("Created by integration test"); expect(created.code).toBe("await page.click('#btn');"); expect(typeof created.id).toBe("string"); }); it("returns an MCP error when alias already exists", async () => { await mcpCall("create_snippet", { alias: "mcp-duplicate-snippet", title: "First", code: "return 1;", }); const { status, rpc } = await mcpCall("create_snippet", { alias: "mcp-duplicate-snippet", title: "Second", code: "return 2;", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); describe("get_snippet", () => { it("returns the created snippet by id", async () => { const createRpc = ( await mcpCall("create_snippet", { alias: "mcp-get-snippet", title: "Get Me", code: "return 42;", }) ).rpc; const created = JSON.parse( (createRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const { status, rpc } = await mcpCall("get_snippet", { id: created.id }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const fetched = JSON.parse(result.content[0].text) as { id: string; alias: string; }; expect(fetched.id).toBe(created.id); expect(fetched.alias).toBe("mcp-get-snippet"); }); it("returns an MCP error for a non-existent snippet id", async () => { const { status, rpc } = await mcpCall("get_snippet", { id: "00000000-0000-0000-0000-000000000000", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); describe("update_snippet", () => { it("updates alias, title, and code of an existing snippet", async () => { const createRpc = ( await mcpCall("create_snippet", { alias: "mcp-upd-snippet-orig", title: "Original Title", code: "return 1;", }) ).rpc; const created = JSON.parse( (createRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const { status, rpc } = await mcpCall("update_snippet", { id: created.id, alias: "mcp-upd-snippet-new", title: "Updated Title", code: "return 2;", }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; const updated = JSON.parse(result.content[0].text) as { alias: string; title: string; code: string; }; expect(updated.alias).toBe("mcp-upd-snippet-new"); expect(updated.title).toBe("Updated Title"); expect(updated.code).toBe("return 2;"); }); it("returns an MCP error for a non-existent snippet id", async () => { const { status, rpc } = await mcpCall("update_snippet", { id: "00000000-0000-0000-0000-000000000000", title: "Ghost", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); describe("delete_snippet", () => { it("deletes an existing snippet and confirms deletion", async () => { const createRpc = ( await mcpCall("create_snippet", { alias: "mcp-del-snippet", title: "Delete Me", code: "return 0;", }) ).rpc; const created = JSON.parse( (createRpc.result as { content: { text: string }[] }).content[0].text, ) as { id: string }; const { status, rpc } = await mcpCall("delete_snippet", { id: created.id, }); expect(status).toBe(200); const result = rpc.result as { content: { text: string }[] }; expect(result.content[0].text).toContain(created.id); // confirm gone const getResult = (await mcpCall("get_snippet", { id: created.id })) .rpc.result as { isError: boolean }; expect(getResult.isError).toBe(true); }); it("returns an MCP error for a non-existent snippet id", async () => { const { status, rpc } = await mcpCall("delete_snippet", { id: "00000000-0000-0000-0000-000000000000", }); expect(status).toBe(200); const result = rpc.result as { isError: boolean }; expect(result.isError).toBe(true); }); }); });