- Added E2eExportService to package scenarios as plug-and-play zip archives containing package.json, playwright.config.ts, test.spec.ts, .env, credentials.json, snippets, and referenced scenario files - Only bundles credentials, snippets, and files actually referenced (directly or transitively) by the scenario's steps, reducing archive size - Exported test runs entirely offline using a context shim that mirrors liqa's API (page, getCredential, runSnippet, getScenarioFiles, downloadFile, assert, etc.) - Added GET /scenarios/:id/export-e2e HTTP endpoint and export_e2e_test MCP tool - Added getUsedSnippets() endpoint to list snippets referenced by a scenario - Added "Used Snippets" section on scenario detail page - Added archiver@^7.0.1 dependency for zip archive creation - Bumped version to 1.11.0
1541 lines
45 KiB
TypeScript
1541 lines
45 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
import { z } from "zod";
|
|
import type { Request, Response } from "express";
|
|
import { SessionService } from "../session/session.service";
|
|
import { SessionContextService } from "../session/session-context.service";
|
|
import { EnvironmentService } from "../environment/environment.service";
|
|
import type { EnvironmentData } from "../environment/environment.entity";
|
|
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 { E2eExportService } from "../scenario/e2e-export.service";
|
|
import { SnippetService } from "../snippet/snippet.service";
|
|
|
|
import pkg from "../../package.json";
|
|
|
|
@Injectable()
|
|
export class McpService {
|
|
constructor(
|
|
private readonly sessionService: SessionService,
|
|
private readonly sessionContextService: SessionContextService,
|
|
private readonly environmentService: EnvironmentService,
|
|
private readonly credentialService: CredentialService,
|
|
private readonly browserService: BrowserService,
|
|
private readonly codeExecutor: CodeExecutorService,
|
|
private readonly scenarioService: ScenarioService,
|
|
private readonly e2eExportService: E2eExportService,
|
|
private readonly snippetService: SnippetService,
|
|
) {}
|
|
|
|
private createServer(): McpServer {
|
|
const server = new McpServer({ name: pkg.name, version: pkg.version });
|
|
this.registerTools(server);
|
|
return server;
|
|
}
|
|
|
|
private registerTools(server: McpServer): void {
|
|
// ── Sessions ──────────────────────────────────────────────────────────────
|
|
|
|
server.registerTool(
|
|
"list_sessions",
|
|
{
|
|
description:
|
|
"List all stored sessions (id, sessionName, createdAt, updatedAt), 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 20)"),
|
|
orderBy: z
|
|
.enum([
|
|
"id",
|
|
"sessionName",
|
|
"status",
|
|
"lastUsedAt",
|
|
"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 sessions = await this.sessionService.findAll({
|
|
page,
|
|
limit,
|
|
orderBy,
|
|
orderDir,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(sessions) }],
|
|
};
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"delete_session",
|
|
{
|
|
description: "Delete a session by numeric ID (closes it first if open)",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Session ID to delete"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
await this.sessionContextService.delete(id);
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
return {
|
|
content: [{ type: "text" as const, text: `Session ${id} deleted` }],
|
|
};
|
|
},
|
|
);
|
|
|
|
// ── Environments ──────────────────────────────────────────────────────────
|
|
|
|
server.registerTool(
|
|
"list_environments",
|
|
{
|
|
description: "List all environments, 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 20)"),
|
|
orderBy: z
|
|
.enum(["id", "name", "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 envs = await this.environmentService.findAll({
|
|
page,
|
|
limit,
|
|
orderBy,
|
|
orderDir,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(envs) }],
|
|
};
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_environment",
|
|
{
|
|
description: "Get an environment record by ID",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Environment ID"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
const env = await this.environmentService.findOne(id);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
|
};
|
|
} catch {
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{ type: "text" as const, text: `Environment ${id} not found` },
|
|
],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"create_environment",
|
|
{
|
|
description: "Create a new named environment with generic data",
|
|
inputSchema: {
|
|
name: z
|
|
.string()
|
|
.describe("Unique environment name, e.g. liquio-diia-stg"),
|
|
description: z
|
|
.string()
|
|
.optional()
|
|
.describe("Optional markdown description"),
|
|
data: z
|
|
.record(z.string(), z.string())
|
|
.describe("Map of string keys to string values"),
|
|
},
|
|
},
|
|
async ({ name, description, data }) => {
|
|
try {
|
|
const env = await this.environmentService.create({
|
|
name,
|
|
description,
|
|
data: data as EnvironmentData,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"update_environment",
|
|
{
|
|
description:
|
|
"Update an existing environment (name, description, and/or data)",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Environment ID to update"),
|
|
name: z.string().optional().describe("New name"),
|
|
description: z
|
|
.string()
|
|
.optional()
|
|
.describe("New markdown description"),
|
|
data: z
|
|
.record(z.string(), z.string())
|
|
.optional()
|
|
.describe("New data map"),
|
|
},
|
|
},
|
|
async ({ id, name, description, data }) => {
|
|
try {
|
|
const env = await this.environmentService.update(id, {
|
|
name,
|
|
description,
|
|
data: data as EnvironmentData | undefined,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"delete_environment",
|
|
{
|
|
description: "Delete an environment by ID",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Environment ID to delete"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
await this.environmentService.remove(id);
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: `Environment ${id} deleted` },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Browser ───────────────────────────────────────────────────────────────
|
|
|
|
server.registerTool(
|
|
"open_url",
|
|
{
|
|
description:
|
|
"Open a URL using a stored session and return the page title and content",
|
|
inputSchema: {
|
|
sessionName: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Session name to restore cookies and localStorage from. Omit to open without a stored session.",
|
|
),
|
|
url: z.string().url().describe("URL to navigate to"),
|
|
readerMode: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Extract readable plain text instead of raw HTML"),
|
|
selector: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"CSS selector whose matching element content is returned; applied before readerMode",
|
|
),
|
|
},
|
|
},
|
|
async ({ sessionName, url, readerMode, selector }) => {
|
|
try {
|
|
const result = await this.browserService.open(
|
|
sessionName,
|
|
url,
|
|
readerMode ?? false,
|
|
selector,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"exec_code",
|
|
{
|
|
description:
|
|
"Execute Playwright JS — script receives a single `context` argument (see scenario.md). Use `environmentId` (UUID) and `credentials` (alias → credential UUID) to resolve data from the DB and enable context.getEnv() / context.getCredential() / context.runSnippet().",
|
|
inputSchema: {
|
|
sessionName: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
"Session name to restore. Omit to run without a stored session.",
|
|
),
|
|
code: z
|
|
.string()
|
|
.describe(
|
|
"Async JavaScript body. Use `context.page` for Playwright, `context.getEnv(key)`, `context.getCredential(alias)`, `context.runSnippet(name, ...args)`.",
|
|
),
|
|
url: z
|
|
.string()
|
|
.url()
|
|
.optional()
|
|
.describe("Optional URL to navigate to before running code"),
|
|
environmentId: z
|
|
.string()
|
|
.uuid()
|
|
.optional()
|
|
.describe(
|
|
"UUID of an existing environment entity. Its key/value data is available via context.env and context.getEnv().",
|
|
),
|
|
credentials: z
|
|
.record(z.string(), z.string())
|
|
.optional()
|
|
.describe(
|
|
"Map of alias → credential UUID. Each credential is loaded from the DB and available via context.getCredential(alias).",
|
|
),
|
|
},
|
|
},
|
|
async ({ sessionName, code, url, environmentId, credentials }) => {
|
|
try {
|
|
this.codeExecutor.validate(code);
|
|
|
|
let environment: EnvironmentData | undefined;
|
|
if (environmentId) {
|
|
const env = await this.environmentService.findOne(environmentId);
|
|
environment = env.data;
|
|
}
|
|
|
|
let resolvedCredentials: Record<string, unknown> | undefined;
|
|
if (credentials && Object.keys(credentials).length > 0) {
|
|
resolvedCredentials = {};
|
|
for (const [alias, credId] of Object.entries(credentials)) {
|
|
const cred = await this.credentialService.findOne(credId);
|
|
resolvedCredentials[alias] = cred.data
|
|
? JSON.parse(cred.data)
|
|
: {};
|
|
}
|
|
}
|
|
|
|
const result = await this.browserService.exec(
|
|
sessionName,
|
|
code,
|
|
url,
|
|
environment,
|
|
resolvedCredentials,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Scenarios ─────────────────────────────────────────────────────────────
|
|
|
|
server.registerTool(
|
|
"list_scenarios",
|
|
{
|
|
description: "List all scenarios (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 20)"),
|
|
orderBy: z
|
|
.enum(["id", "name", "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.scenarioService.findAll({
|
|
page,
|
|
limit,
|
|
orderBy,
|
|
orderDir,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_scenario",
|
|
{
|
|
description: "Get a scenario with its steps by ID",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Scenario ID"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
const scenario = await this.scenarioService.findOne(id);
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: JSON.stringify(scenario) },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"create_scenario",
|
|
{
|
|
description: "Create a new scenario",
|
|
inputSchema: {
|
|
name: z.string().describe("Scenario name"),
|
|
description: z
|
|
.string()
|
|
.optional()
|
|
.describe("Optional markdown description"),
|
|
environmentId: z
|
|
.uuid()
|
|
.optional()
|
|
.describe("Optional linked environment ID"),
|
|
timeoutSeconds: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Scenario-level timeout in seconds (default 600)"),
|
|
},
|
|
},
|
|
async ({ name, description, environmentId, timeoutSeconds }) => {
|
|
try {
|
|
const scenario = await this.scenarioService.create({
|
|
name,
|
|
description,
|
|
environmentId,
|
|
timeoutSeconds,
|
|
});
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: JSON.stringify(scenario) },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"update_scenario",
|
|
{
|
|
description: "Update a scenario name and/or description",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Scenario ID"),
|
|
name: z.string().optional().describe("New name"),
|
|
description: z
|
|
.string()
|
|
.optional()
|
|
.describe("New markdown description"),
|
|
environmentId: z
|
|
.uuid()
|
|
.nullable()
|
|
.optional()
|
|
.describe("Linked environment ID (null to unlink)"),
|
|
timeoutSeconds: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.nullable()
|
|
.optional()
|
|
.describe("Scenario-level timeout in seconds (null to reset to default 600)"),
|
|
},
|
|
},
|
|
async ({ id, name, description, environmentId, timeoutSeconds }) => {
|
|
try {
|
|
const scenario = await this.scenarioService.update(id, {
|
|
name,
|
|
description,
|
|
environmentId,
|
|
timeoutSeconds,
|
|
});
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: JSON.stringify(scenario) },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"delete_scenario",
|
|
{
|
|
description: "Delete a scenario by ID",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Scenario ID"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
await this.scenarioService.remove(id);
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: `Scenario ${id} deleted` },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"create_scenario_step",
|
|
{
|
|
description: "Add a step to a scenario",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Parent scenario ID"),
|
|
order: z
|
|
.number()
|
|
.int()
|
|
.min(0)
|
|
.describe("Execution order (ascending)"),
|
|
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
|
title: z.string().optional().describe("Optional step title"),
|
|
execCode: z
|
|
.string()
|
|
.optional()
|
|
.describe("Playwright JS code to execute (exec steps)"),
|
|
timeoutSeconds: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Step-level timeout in seconds (default 60, falls back to scenario timeout)"),
|
|
},
|
|
},
|
|
async ({ scenarioId, ...dto }) => {
|
|
try {
|
|
const step = await this.scenarioService.createStep(scenarioId, dto);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(step) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_scenario_step",
|
|
{
|
|
description: "Get a single step of a scenario",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
stepId: z.uuid().describe("Step ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId, stepId }) => {
|
|
try {
|
|
const step = await this.scenarioService.findStep(scenarioId, stepId);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(step) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"update_scenario_step",
|
|
{
|
|
description: "Update a step within a scenario",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
stepId: z.uuid().describe("Step ID"),
|
|
order: z
|
|
.number()
|
|
.int()
|
|
.min(0)
|
|
.optional()
|
|
.describe("New execution order"),
|
|
type: z
|
|
.enum(["login", "exec", "sign"])
|
|
.optional()
|
|
.describe("New step type"),
|
|
title: z.string().optional().describe("New step title"),
|
|
execCode: z.string().optional().describe("New exec code"),
|
|
timeoutSeconds: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.nullable()
|
|
.optional()
|
|
.describe("Step-level timeout in seconds (null to reset to default)"),
|
|
},
|
|
},
|
|
async ({ scenarioId, stepId, ...dto }) => {
|
|
try {
|
|
const step = await this.scenarioService.updateStep(
|
|
scenarioId,
|
|
stepId,
|
|
dto,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(step) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"delete_scenario_step",
|
|
{
|
|
description: "Delete a step from a scenario",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
stepId: z.uuid().describe("Step ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId, stepId }) => {
|
|
try {
|
|
await this.scenarioService.removeStep(scenarioId, stepId);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: `Step ${stepId} deleted from scenario ${scenarioId}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"list_scenario_runs",
|
|
{
|
|
description:
|
|
"List runs for a scenario (paginated, optionally filtered by status)",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
status: z
|
|
.enum(["pending", "in_progress", "pass", "fail"])
|
|
.optional()
|
|
.describe("Filter by run status"),
|
|
page: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Page number (default 1)"),
|
|
limit: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Items per page (default 20)"),
|
|
},
|
|
},
|
|
async ({ scenarioId, status, page, limit }) => {
|
|
try {
|
|
const result = await this.scenarioService.findRuns(scenarioId, {
|
|
status,
|
|
page,
|
|
limit,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"run_scenario",
|
|
{
|
|
description: "Trigger an immediate run of a scenario by ID",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Scenario ID to run"),
|
|
environmentId: z
|
|
.uuid()
|
|
.describe("Environment ID to run the scenario in"),
|
|
saveSession: z
|
|
.boolean()
|
|
.optional()
|
|
.describe("Save session after run completes"),
|
|
},
|
|
},
|
|
async ({ id, environmentId, saveSession }) => {
|
|
try {
|
|
const run = await this.scenarioService.createRun(
|
|
id,
|
|
environmentId,
|
|
saveSession,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_scenario_run",
|
|
{
|
|
description:
|
|
"Get a specific scenario run with all step runs and their outputs",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
runId: z.uuid().describe("Run ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId, runId }) => {
|
|
try {
|
|
const run = await this.scenarioService.findRun(scenarioId, runId);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"wait_for_scenario_run",
|
|
{
|
|
description:
|
|
"Block until a scenario run reaches pass or fail (max 5 min), then return the full run with step outputs",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
runId: z.uuid().describe("Run ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId, runId }) => {
|
|
try {
|
|
const run = await this.scenarioService.waitForRun(scenarioId, runId);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(run) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"list_scenario_credentials",
|
|
{
|
|
description: "List credentials assigned to a scenario",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId }) => {
|
|
try {
|
|
const creds =
|
|
await this.scenarioService.findScenarioCredentials(scenarioId);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(creds) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"add_scenario_credential",
|
|
{
|
|
description:
|
|
"Add a credential to a scenario with an alias (used in snippets)",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
credentialId: z.uuid().describe("Credential ID to associate"),
|
|
alias: z
|
|
.string()
|
|
.describe(
|
|
"Alias used to reference this credential in step code, e.g. pkcs_key",
|
|
),
|
|
},
|
|
},
|
|
async ({ scenarioId, credentialId, alias }) => {
|
|
try {
|
|
const result = await this.scenarioService.addScenarioCredential(
|
|
scenarioId,
|
|
{ credentialId, alias },
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"remove_scenario_credential",
|
|
{
|
|
description: "Remove a credential assignment from a scenario",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
scenarioCredentialId: z
|
|
.uuid()
|
|
.describe(
|
|
"Scenario-credential assignment ID (not the credential ID)",
|
|
),
|
|
},
|
|
},
|
|
async ({ scenarioId, scenarioCredentialId }) => {
|
|
try {
|
|
await this.scenarioService.removeScenarioCredential(
|
|
scenarioId,
|
|
scenarioCredentialId,
|
|
);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: `Credential assignment ${scenarioCredentialId} removed from scenario ${scenarioId}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"list_all_runs",
|
|
{
|
|
description:
|
|
"List runs across all scenarios (paginated, filterable by status)",
|
|
inputSchema: {
|
|
status: z
|
|
.enum(["pending", "in_progress", "pass", "fail"])
|
|
.optional()
|
|
.describe("Filter by run status"),
|
|
page: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Page number (default 1)"),
|
|
limit: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Items per page (default 20)"),
|
|
},
|
|
},
|
|
async ({ status, page, limit }) => {
|
|
try {
|
|
const result = await this.scenarioService.findAllRuns({
|
|
status,
|
|
page,
|
|
limit,
|
|
});
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"export_scenario",
|
|
{
|
|
description:
|
|
"Export a scenario as a portable JSON payload (name + steps)",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Scenario ID to export"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
const exported = await this.scenarioService.exportScenario(id, {
|
|
includeEnvironment: false,
|
|
credentialIds: [],
|
|
});
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: JSON.stringify(exported) },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"import_scenario",
|
|
{
|
|
description:
|
|
"Import a scenario from an export payload, creating a new scenario with all its steps",
|
|
inputSchema: {
|
|
name: z.string().describe("Scenario name"),
|
|
description: z
|
|
.string()
|
|
.optional()
|
|
.describe("Optional markdown description"),
|
|
steps: z
|
|
.array(
|
|
z.object({
|
|
order: z.number().int().min(0).describe("Execution order"),
|
|
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
|
title: z.string().nullable().optional().describe("Step title"),
|
|
execCode: z
|
|
.string()
|
|
.nullable()
|
|
.optional()
|
|
.describe("Exec/sign code"),
|
|
}),
|
|
)
|
|
.describe("Ordered list of steps"),
|
|
},
|
|
},
|
|
async ({ name, description, steps }) => {
|
|
try {
|
|
const scenario = await this.scenarioService.importScenario({
|
|
kind: "scenario",
|
|
name,
|
|
description,
|
|
steps: steps as { title: string | null; execCode: string | null }[],
|
|
});
|
|
return {
|
|
content: [
|
|
{ type: "text" as const, text: JSON.stringify(scenario) },
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"export_e2e_test",
|
|
{
|
|
description:
|
|
"Export a scenario as a standalone, plug-and-play Playwright e2e test project (package.json, playwright.config.ts, test.spec.ts, .env), zipped and returned as base64",
|
|
inputSchema: {
|
|
id: z.uuid().describe("Scenario ID to export"),
|
|
},
|
|
},
|
|
async ({ id }) => {
|
|
try {
|
|
const { filename, buffer } =
|
|
await this.e2eExportService.buildE2eTestPackage(id);
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: JSON.stringify({
|
|
filename,
|
|
contentBase64: buffer.toString("base64"),
|
|
encoding: "base64",
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Scenario Files ────────────────────────────────────────────────────────
|
|
|
|
server.registerTool(
|
|
"upload_scenario_file",
|
|
{
|
|
description:
|
|
"Upload a file to a scenario. Content must be base64-encoded. Returns file metadata.",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
name: z.string().describe("File name"),
|
|
contentBase64: z
|
|
.string()
|
|
.describe("Base64-encoded file content"),
|
|
mimeType: z
|
|
.string()
|
|
.optional()
|
|
.describe("MIME type (e.g., text/plain, application/json)"),
|
|
expiresAt: z
|
|
.string()
|
|
.optional()
|
|
.describe("ISO 8601 expiration date"),
|
|
},
|
|
},
|
|
async ({ scenarioId, name, contentBase64, mimeType, expiresAt }) => {
|
|
try {
|
|
// Validate base64 format
|
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(contentBase64)) {
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: "Invalid base64 encoding",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Decode base64 to buffer
|
|
let buffer: Buffer;
|
|
try {
|
|
buffer = Buffer.from(contentBase64, "base64");
|
|
// Verify the base64 can be re-encoded to match the original
|
|
if (buffer.toString("base64") !== contentBase64) {
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: "Invalid base64 encoding",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
} catch {
|
|
return {
|
|
isError: true,
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: "Invalid base64 encoding",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
// Create a mock Multer file object for the service
|
|
const file = {
|
|
buffer,
|
|
originalname: name,
|
|
mimetype: mimeType || "application/octet-stream",
|
|
} as any;
|
|
|
|
// Call the service method
|
|
const result = await this.scenarioService.uploadFile(
|
|
scenarioId,
|
|
file,
|
|
expiresAt,
|
|
);
|
|
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"list_scenario_files",
|
|
{
|
|
description:
|
|
"List files uploaded to a scenario (paginated)",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
limit: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Items per page (default 20)"),
|
|
offset: z
|
|
.number()
|
|
.int()
|
|
.min(0)
|
|
.optional()
|
|
.describe("Skip count (default 0)"),
|
|
},
|
|
},
|
|
async ({ scenarioId, limit, offset }) => {
|
|
try {
|
|
const result = await this.scenarioService.listScenarioFiles(
|
|
scenarioId,
|
|
limit,
|
|
offset,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_scenario_file_content",
|
|
{
|
|
description:
|
|
"Get a scenario file's content as base64 with metadata",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
fileId: z.uuid().describe("File ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId, fileId }) => {
|
|
try {
|
|
const { file, contentBuffer } =
|
|
await this.scenarioService.getScenarioFileContentAsBuffer(
|
|
scenarioId,
|
|
fileId,
|
|
);
|
|
const contentBase64 = contentBuffer.toString("base64");
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: JSON.stringify({
|
|
file,
|
|
contentBase64,
|
|
encoding: "base64",
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── Run Artifact Files ────────────────────────────────────────────────────
|
|
|
|
server.registerTool(
|
|
"list_run_files",
|
|
{
|
|
description:
|
|
"List files (artifacts) created during a scenario run (paginated)",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
runId: z.uuid().describe("Run ID"),
|
|
limit: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.optional()
|
|
.describe("Items per page (default 20)"),
|
|
offset: z
|
|
.number()
|
|
.int()
|
|
.min(0)
|
|
.optional()
|
|
.describe("Skip count (default 0)"),
|
|
},
|
|
},
|
|
async ({ scenarioId, runId, limit, offset }) => {
|
|
try {
|
|
const result = await this.scenarioService.listRunFiles(
|
|
scenarioId,
|
|
runId,
|
|
limit,
|
|
offset,
|
|
);
|
|
return {
|
|
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
"get_run_file_content",
|
|
{
|
|
description:
|
|
"Get a run artifact file's content as base64 with metadata",
|
|
inputSchema: {
|
|
scenarioId: z.uuid().describe("Scenario ID"),
|
|
runId: z.uuid().describe("Run ID"),
|
|
fileId: z.uuid().describe("File ID"),
|
|
},
|
|
},
|
|
async ({ scenarioId, runId, fileId }) => {
|
|
try {
|
|
const { file, contentBuffer } =
|
|
await this.scenarioService.getRunFileContentAsBuffer(
|
|
scenarioId,
|
|
runId,
|
|
fileId,
|
|
);
|
|
const contentBase64 = contentBuffer.toString("base64");
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text" as const,
|
|
text: JSON.stringify({
|
|
file,
|
|
contentBase64,
|
|
encoding: "base64",
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
isError: true,
|
|
content: [{ type: "text" as const, text: (err as Error).message }],
|
|
};
|
|
}
|
|
},
|
|
);
|
|
|
|
// ── 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 ─────────────────────────────────────────────────────────────
|
|
}
|
|
|
|
async handle(req: Request, res: Response): Promise<void> {
|
|
const server = this.createServer();
|
|
const transport = new StreamableHTTPServerTransport({
|
|
sessionIdGenerator: undefined,
|
|
});
|
|
await server.connect(transport);
|
|
try {
|
|
await transport.handleRequest(req, res, req.body);
|
|
} finally {
|
|
await transport.close();
|
|
await server.close();
|
|
}
|
|
}
|
|
}
|