diff --git a/server/src/main.ts b/server/src/main.ts index 04e0b43..3fea5f9 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -10,6 +10,21 @@ import { HttpExceptionFilter } from "./filters/http-exception.filter"; import { LoggingInterceptor } from "./interceptors/logging.interceptor"; import { TraceInterceptor } from "./interceptors/trace.interceptor"; +// Guard against uncaught exceptions thrown inside Playwright event listener +// callbacks (e.g. a bad waitForURL predicate). Without this, Node.js v15+ +// crashes the process on any unhandled rejection or uncaught exception. +const processLogger = new TraceLogger("Process"); +process.on("uncaughtException", (err) => { + processLogger.error(`Uncaught exception (process kept alive): ${err.message}`, err.stack); +}); +process.on("unhandledRejection", (reason) => { + processLogger.error( + `Unhandled promise rejection (process kept alive): ${ + reason instanceof Error ? reason.message : String(reason) + }`, + ); +}); + async function bootstrap() { const API_PREFIX = "api/v1"; const logger = new TraceLogger("Bootstrap"); diff --git a/server/src/mcp/mcp.module.ts b/server/src/mcp/mcp.module.ts index 680dd5f..2e14d8c 100644 --- a/server/src/mcp/mcp.module.ts +++ b/server/src/mcp/mcp.module.ts @@ -5,6 +5,7 @@ import { CredentialModule } from "../credential/credential.module"; import { EnvironmentModule } from "../environment/environment.module"; import { ScenarioModule } from "../scenario/scenario.module"; import { SessionModule } from "../session/session.module"; +import { SnippetModule } from "../snippet/snippet.module"; import { McpController } from "./mcp.controller"; import { McpService } from "./mcp.service"; @@ -16,6 +17,7 @@ import { McpService } from "./mcp.service"; BrowserModule, CodeExecutorModule, ScenarioModule, + SnippetModule, ], controllers: [McpController], providers: [McpService], diff --git a/server/src/mcp/mcp.service.ts b/server/src/mcp/mcp.service.ts index d69d22d..87fc826 100644 --- a/server/src/mcp/mcp.service.ts +++ b/server/src/mcp/mcp.service.ts @@ -11,6 +11,7 @@ import { CredentialService } from "../credential/credential.service"; import { BrowserService } from "../browser/browser.service"; import { CodeExecutorService } from "../code-executor/code-executor.service"; import { ScenarioService } from "../scenario/scenario.service"; +import { SnippetService } from "../snippet/snippet.service"; import pkg from "../../package.json"; @@ -24,6 +25,7 @@ export class McpService { private readonly browserService: BrowserService, private readonly codeExecutor: CodeExecutorService, private readonly scenarioService: ScenarioService, + private readonly snippetService: SnippetService, ) {} private createServer(): McpServer { @@ -1060,6 +1062,173 @@ export class McpService { }, ); + // ── Snippets ────────────────────────────────────────────────────────────── + + server.registerTool( + "list_snippets", + { + description: "List all snippets (paginated)", + inputSchema: { + page: z + .number() + .int() + .min(1) + .optional() + .describe("Page number (default 1)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Items per page (default 50)"), + orderBy: z + .enum(["id", "alias", "title", "createdAt", "updatedAt"]) + .optional() + .describe("Field to order by (default id)"), + orderDir: z + .enum(["ASC", "DESC"]) + .optional() + .describe("Sort direction (default ASC)"), + }, + }, + async ({ page, limit, orderBy, orderDir }) => { + const result = await this.snippetService.findAll({ + page, + limit, + orderBy, + orderDir, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; + }, + ); + + server.registerTool( + "get_snippet", + { + description: "Get a snippet by ID", + inputSchema: { + id: z.uuid().describe("Snippet ID"), + }, + }, + async ({ id }) => { + try { + const snippet = await this.snippetService.findOne(id); + return { + content: [{ type: "text" as const, text: JSON.stringify(snippet) }], + }; + } catch (err) { + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; + } + }, + ); + + server.registerTool( + "create_snippet", + { + description: "Create a new reusable code snippet", + inputSchema: { + alias: z + .string() + .describe( + "Unique identifier used to invoke the snippet via context.runSnippet(alias, ...args)", + ), + title: z.string().describe("Human-readable title"), + description: z + .string() + .optional() + .describe("Optional markdown description"), + code: z + .string() + .describe( + "Async JavaScript body. Receives the same context as exec steps plus any positional ...args.", + ), + }, + }, + async ({ alias, title, description, code }) => { + try { + const snippet = await this.snippetService.create({ + alias, + title, + description, + code, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(snippet) }], + }; + } catch (err) { + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; + } + }, + ); + + server.registerTool( + "update_snippet", + { + description: "Update an existing snippet", + inputSchema: { + id: z.uuid().describe("Snippet ID to update"), + alias: z.string().optional().describe("New alias"), + title: z.string().optional().describe("New title"), + description: z + .string() + .optional() + .describe("New markdown description"), + code: z.string().optional().describe("New code body"), + }, + }, + async ({ id, alias, title, description, code }) => { + try { + const snippet = await this.snippetService.update(id, { + alias, + title, + description, + code, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(snippet) }], + }; + } catch (err) { + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; + } + }, + ); + + server.registerTool( + "delete_snippet", + { + description: "Delete a snippet by ID", + inputSchema: { + id: z.uuid().describe("Snippet ID to delete"), + }, + }, + async ({ id }) => { + try { + await this.snippetService.remove(id); + return { + content: [ + { type: "text" as const, text: `Snippet ${id} deleted` }, + ], + }; + } catch (err) { + return { + isError: true, + content: [{ type: "text" as const, text: (err as Error).message }], + }; + } + }, + ); + // ── Transport ───────────────────────────────────────────────────────────── } diff --git a/server/src/scenario/scenario-scheduler.service.ts b/server/src/scenario/scenario-scheduler.service.ts index 49c7a59..8c2c81d 100644 --- a/server/src/scenario/scenario-scheduler.service.ts +++ b/server/src/scenario/scenario-scheduler.service.ts @@ -367,18 +367,26 @@ export class ScenarioSchedulerService implements OnModuleInit { ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC; const stepTimeoutMs = stepTimeoutSec * 1000; - const timeoutPromise = new Promise((_, reject) => - setTimeout( + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( () => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)), stepTimeoutMs, - ), - ); - const { result: execOutput } = await Promise.race([ - this.codeExecutor.execute(execCtx), - timeoutPromise, - ]); - - await this.passStepRun(stepRun, null, execOutput); + ); + }); + try { + const { result: execOutput } = await Promise.race([ + this.codeExecutor.execute(execCtx), + timeoutPromise, + ]); + await this.passStepRun(stepRun, null, execOutput); + } catch (err) { + const msg = (err as Error).message ?? String(err); + this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`); + await this.failStepRun(stepRun, msg); + } finally { + clearTimeout(timeoutId); + } } catch (err) { const msg = (err as Error).message ?? String(err); this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`); diff --git a/server/test/mcp.controller.spec.ts b/server/test/mcp.controller.spec.ts index 5f947f1..bf6b675 100644 --- a/server/test/mcp.controller.spec.ts +++ b/server/test/mcp.controller.spec.ts @@ -284,4 +284,173 @@ describe("McpController", () => { 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); + }); + }); });