Files
liqa/server/test/mcp.controller.spec.ts
T
ars9 ba46789226 feat(mcp): add snippet CRUD tools to MCP server
- register list_snippets, get_snippet, create_snippet, update_snippet, delete_snippet
- inject SnippetService into McpService and import SnippetModule into McpModule
- cover all five tools with integration tests including error cases
2026-04-20 16:21:52 +03:00

457 lines
16 KiB
TypeScript

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<string, unknown> {
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<string, unknown>;
}
/** Send a single MCP tool call and return the parsed response body. */
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
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();
});
});
// ── 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);
});
});
});