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:
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user