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 { LoggingInterceptor } from "./interceptors/logging.interceptor";
|
||||||
import { TraceInterceptor } from "./interceptors/trace.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() {
|
async function bootstrap() {
|
||||||
const API_PREFIX = "api/v1";
|
const API_PREFIX = "api/v1";
|
||||||
const logger = new TraceLogger("Bootstrap");
|
const logger = new TraceLogger("Bootstrap");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CredentialModule } from "../credential/credential.module";
|
|||||||
import { EnvironmentModule } from "../environment/environment.module";
|
import { EnvironmentModule } from "../environment/environment.module";
|
||||||
import { ScenarioModule } from "../scenario/scenario.module";
|
import { ScenarioModule } from "../scenario/scenario.module";
|
||||||
import { SessionModule } from "../session/session.module";
|
import { SessionModule } from "../session/session.module";
|
||||||
|
import { SnippetModule } from "../snippet/snippet.module";
|
||||||
import { McpController } from "./mcp.controller";
|
import { McpController } from "./mcp.controller";
|
||||||
import { McpService } from "./mcp.service";
|
import { McpService } from "./mcp.service";
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import { McpService } from "./mcp.service";
|
|||||||
BrowserModule,
|
BrowserModule,
|
||||||
CodeExecutorModule,
|
CodeExecutorModule,
|
||||||
ScenarioModule,
|
ScenarioModule,
|
||||||
|
SnippetModule,
|
||||||
],
|
],
|
||||||
controllers: [McpController],
|
controllers: [McpController],
|
||||||
providers: [McpService],
|
providers: [McpService],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CredentialService } from "../credential/credential.service";
|
|||||||
import { BrowserService } from "../browser/browser.service";
|
import { BrowserService } from "../browser/browser.service";
|
||||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||||
import { ScenarioService } from "../scenario/scenario.service";
|
import { ScenarioService } from "../scenario/scenario.service";
|
||||||
|
import { SnippetService } from "../snippet/snippet.service";
|
||||||
|
|
||||||
import pkg from "../../package.json";
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export class McpService {
|
|||||||
private readonly browserService: BrowserService,
|
private readonly browserService: BrowserService,
|
||||||
private readonly codeExecutor: CodeExecutorService,
|
private readonly codeExecutor: CodeExecutorService,
|
||||||
private readonly scenarioService: ScenarioService,
|
private readonly scenarioService: ScenarioService,
|
||||||
|
private readonly snippetService: SnippetService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private createServer(): McpServer {
|
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 ─────────────────────────────────────────────────────────────
|
// ── Transport ─────────────────────────────────────────────────────────────
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -367,18 +367,26 @@ export class ScenarioSchedulerService implements OnModuleInit {
|
|||||||
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
|
ScenarioSchedulerService.DEFAULT_STEP_TIMEOUT_SEC;
|
||||||
const stepTimeoutMs = stepTimeoutSec * 1000;
|
const stepTimeoutMs = stepTimeoutSec * 1000;
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
setTimeout(
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(
|
||||||
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
|
() => reject(new Error(`Step timed out after ${stepTimeoutSec}s`)),
|
||||||
stepTimeoutMs,
|
stepTimeoutMs,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
const { result: execOutput } = await Promise.race([
|
try {
|
||||||
this.codeExecutor.execute(execCtx),
|
const { result: execOutput } = await Promise.race([
|
||||||
timeoutPromise,
|
this.codeExecutor.execute(execCtx),
|
||||||
]);
|
timeoutPromise,
|
||||||
|
]);
|
||||||
await this.passStepRun(stepRun, null, execOutput);
|
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) {
|
} catch (err) {
|
||||||
const msg = (err as Error).message ?? String(err);
|
const msg = (err as Error).message ?? String(err);
|
||||||
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
this.logger.error(`StepRun #${stepRun.id} threw: ${msg}`);
|
||||||
|
|||||||
@@ -284,4 +284,173 @@ describe("McpController", () => {
|
|||||||
expect(cleared.timeoutSeconds).toBeNull();
|
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