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 | 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 { 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(); } } }