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
This commit is contained in:
2026-04-20 16:21:52 +03:00
parent 4e494ce4fd
commit ba46789226
5 changed files with 373 additions and 10 deletions
+15
View File
@@ -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");
+2
View File
@@ -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],
+169
View File
@@ -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 ─────────────────────────────────────────────────────────────
}
@@ -367,22 +367,30 @@ export class ScenarioSchedulerService implements OnModuleInit {
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
const stepTimeoutMs = stepTimeoutSec * 1000;
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
stepTimeoutMs,
),
);
});
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}`);
await this.failStepRun(stepRun, msg);
}
}
+169
View File
@@ -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);
});
});
});