feat(scenario): run logs, lint/format tooling, CONTRIBUTING
- add ScenarioRunLogEntity to persist step script output to DB - stepLogger dual-writes to NestJS logger and DB (fire-and-forget) - add GET /scenarios/:id/run/:runId returning run, stepRuns and logs - add POST /scenarios/:id/run/:runId/wait (polls until terminal state) - 9 new integration tests for the two endpoints (136 total) - add eslint with typescript-eslint and eslint-config-prettier - add npm scripts: format, lint, lint:fix - resolve all lint errors across src and test (no any types) - add CONTRIBUTING.md covering dev workflow
This commit is contained in:
+41
-25
@@ -1,52 +1,68 @@
|
||||
import { Controller, Delete, Get, Post, Req, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import { McpService } from './mcp.service';
|
||||
import { Controller, Delete, Get, Post, Req, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import type { Request, Response } from "express";
|
||||
import { McpService } from "./mcp.service";
|
||||
|
||||
@ApiTags('mcp')
|
||||
@Controller('mcp')
|
||||
@ApiTags("mcp")
|
||||
@Controller("mcp")
|
||||
export class McpController {
|
||||
constructor(private readonly mcpService: McpService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: 'Send JSON-RPC message',
|
||||
summary: "Send JSON-RPC message",
|
||||
description:
|
||||
'Accepts a JSON-RPC request, notification, or response. ' +
|
||||
'Returns either `application/json` for a single response or ' +
|
||||
'`text/event-stream` (SSE) when the server streams multiple messages.',
|
||||
"Accepts a JSON-RPC request, notification, or response. " +
|
||||
"Returns either `application/json` for a single response or " +
|
||||
"`text/event-stream` (SSE) when the server streams multiple messages.",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "JSON-RPC response (application/json or text/event-stream)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 202,
|
||||
description: "Accepted — input was a notification or response only",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Bad Request — malformed JSON-RPC payload",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'JSON-RPC response (application/json or text/event-stream)' })
|
||||
@ApiResponse({ status: 202, description: 'Accepted — input was a notification or response only' })
|
||||
@ApiResponse({ status: 400, description: 'Bad Request — malformed JSON-RPC payload' })
|
||||
post(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
return this.mcpService.handle(req, res);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Open server-sent event stream',
|
||||
summary: "Open server-sent event stream",
|
||||
description:
|
||||
'Opens a persistent SSE stream so the server can push JSON-RPC requests and ' +
|
||||
'notifications to the client without a prior POST. ' +
|
||||
'Requires `Accept: text/event-stream`. Pass `Last-Event-ID` to resume a broken stream.',
|
||||
"Opens a persistent SSE stream so the server can push JSON-RPC requests and " +
|
||||
"notifications to the client without a prior POST. " +
|
||||
"Requires `Accept: text/event-stream`. Pass `Last-Event-ID` to resume a broken stream.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "SSE stream (text/event-stream)" })
|
||||
@ApiResponse({
|
||||
status: 405,
|
||||
description: "Method Not Allowed — server does not offer an SSE stream",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'SSE stream (text/event-stream)' })
|
||||
@ApiResponse({ status: 405, description: 'Method Not Allowed — server does not offer an SSE stream' })
|
||||
get(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
return this.mcpService.handle(req, res);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@ApiOperation({
|
||||
summary: 'Terminate session',
|
||||
summary: "Terminate session",
|
||||
description:
|
||||
'Explicitly terminates a session identified by the `Mcp-Session-Id` header. ' +
|
||||
'The server may return 405 if it does not support client-initiated session termination.',
|
||||
"Explicitly terminates a session identified by the `Mcp-Session-Id` header. " +
|
||||
"The server may return 405 if it does not support client-initiated session termination.",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: "Session terminated" })
|
||||
@ApiResponse({ status: 404, description: "Session not found" })
|
||||
@ApiResponse({
|
||||
status: 405,
|
||||
description:
|
||||
"Method Not Allowed — server does not support session termination",
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Session terminated' })
|
||||
@ApiResponse({ status: 404, description: 'Session not found' })
|
||||
@ApiResponse({ status: 405, description: 'Method Not Allowed — server does not support session termination' })
|
||||
delete(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
return this.mcpService.handle(req, res);
|
||||
}
|
||||
|
||||
+17
-10
@@ -1,15 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { McpController } from './mcp.controller';
|
||||
import { McpService } from './mcp.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SessionModule } from '../session/session.module';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { BrowserModule } from '../browser/browser.module';
|
||||
import { CodeExecutorModule } from '../code-executor/code-executor.module';
|
||||
import { ScenarioModule } from '../scenario/scenario.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { McpController } from "./mcp.controller";
|
||||
import { McpService } from "./mcp.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
import { BrowserModule } from "../browser/browser.module";
|
||||
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||
import { ScenarioModule } from "../scenario/scenario.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule, CodeExecutorModule, ScenarioModule],
|
||||
imports: [
|
||||
AuthModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
BrowserModule,
|
||||
CodeExecutorModule,
|
||||
ScenarioModule,
|
||||
],
|
||||
controllers: [McpController],
|
||||
providers: [McpService],
|
||||
})
|
||||
|
||||
+448
-165
@@ -1,17 +1,17 @@
|
||||
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 { AuthService } from '../auth/auth.service';
|
||||
import { SessionService } from '../session/session.service';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import type { EnvironmentUrls } from '../environment/environment.entity';
|
||||
import { BrowserService } from '../browser/browser.service';
|
||||
import { CodeExecutorService } from '../code-executor/code-executor.service';
|
||||
import { ScenarioService } from '../scenario/scenario.service';
|
||||
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 { AuthService } from "../auth/auth.service";
|
||||
import { SessionService } from "../session/session.service";
|
||||
import { EnvironmentService } from "../environment/environment.service";
|
||||
import type { EnvironmentUrls } from "../environment/environment.entity";
|
||||
import { BrowserService } from "../browser/browser.service";
|
||||
import { CodeExecutorService } from "../code-executor/code-executor.service";
|
||||
import { ScenarioService } from "../scenario/scenario.service";
|
||||
|
||||
import pkg from '../../package.json';
|
||||
import pkg from "../../package.json";
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
@@ -31,161 +31,273 @@ export class McpService {
|
||||
}
|
||||
|
||||
private registerTools(server: McpServer): void {
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'list_keys',
|
||||
{ description: 'List available key identifiers from the keys directory' },
|
||||
"list_keys",
|
||||
{ description: "List available key identifiers from the keys directory" },
|
||||
async () => {
|
||||
const keys = this.authService.listKeys();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(keys) }] };
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(keys) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'login',
|
||||
"login",
|
||||
{
|
||||
description: 'Log in using a file key against a named environment and store the session',
|
||||
description:
|
||||
"Log in using a file key against a named environment and store the session",
|
||||
inputSchema: {
|
||||
key: z.string().describe('Key identifier (filename without extension from keys/ dir)'),
|
||||
environmentName: z.string().describe('Environment name to resolve login/cabinet URLs'),
|
||||
sessionName: z.string().optional().describe('Session name to store credentials under. Auto-UUID if omitted.'),
|
||||
key: z
|
||||
.string()
|
||||
.describe(
|
||||
"Key identifier (filename without extension from keys/ dir)",
|
||||
),
|
||||
environmentName: z
|
||||
.string()
|
||||
.describe("Environment name to resolve login/cabinet URLs"),
|
||||
sessionName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Session name to store credentials under. Auto-UUID if omitted.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ key, environmentName, sessionName }) => {
|
||||
const result = await this.authService.login(key, environmentName, sessionName);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||
const result = await this.authService.login(
|
||||
key,
|
||||
environmentName,
|
||||
sessionName,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'list_sessions',
|
||||
"list_sessions",
|
||||
{
|
||||
description: 'List all stored sessions (id, sessionName, createdAt, updatedAt), paginated',
|
||||
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', 'createdAt', 'updatedAt']).optional().describe('Field to order by (default id)'),
|
||||
orderDir: z.enum(['ASC', 'DESC']).optional().describe('Sort direction (default ASC)'),
|
||||
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", "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) }] };
|
||||
const sessions = await this.sessionService.findAll({
|
||||
page,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(sessions) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_session',
|
||||
"delete_session",
|
||||
{
|
||||
description: 'Delete a session by numeric ID',
|
||||
description: "Delete a session by numeric ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Session ID to delete'),
|
||||
id: z.number().int().describe("Session ID to delete"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
const { data } = await this.sessionService.findAll();
|
||||
if (!data.find(s => s.id === id)) {
|
||||
return { isError: true, content: [{ type: 'text' as const, text: `Session ${id} not found` }] };
|
||||
if (!data.find((s) => s.id === id)) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: "text" as const, text: `Session ${id} not found` },
|
||||
],
|
||||
};
|
||||
}
|
||||
await this.sessionService.remove(id);
|
||||
return { content: [{ type: 'text' as const, text: `Session ${id} deleted` }] };
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Session ${id} deleted` }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── Environments ──────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'list_environments',
|
||||
"list_environments",
|
||||
{
|
||||
description: 'List all environments, paginated',
|
||||
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)'),
|
||||
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) }] };
|
||||
const envs = await this.environmentService.findAll({
|
||||
page,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(envs) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_environment',
|
||||
"get_environment",
|
||||
{
|
||||
description: 'Get an environment record by ID',
|
||||
description: "Get an environment record by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Environment ID'),
|
||||
id: z.number().int().describe("Environment ID"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const env = await this.environmentService.findOne(id);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] };
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(env) }],
|
||||
};
|
||||
} catch {
|
||||
return { isError: true, content: [{ type: 'text' as const, text: `Environment ${id} not found` }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: "text" as const, text: `Environment ${id} not found` },
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_environment',
|
||||
"create_environment",
|
||||
{
|
||||
description: 'Create a new named environment with a set of URLs',
|
||||
description: "Create a new named environment with a set of URLs",
|
||||
inputSchema: {
|
||||
name: z.string().describe('Unique environment name, e.g. liquio-diia-stg'),
|
||||
urls: z.record(z.string(), z.string()).describe('Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)'),
|
||||
name: z
|
||||
.string()
|
||||
.describe("Unique environment name, e.g. liquio-diia-stg"),
|
||||
urls: z
|
||||
.record(z.string(), z.string())
|
||||
.describe(
|
||||
"Map of URL keys to URL strings (id_url, cabinet_url, admin_url, …)",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ name, urls }) => {
|
||||
try {
|
||||
const env = await this.environmentService.create({ name, urls: urls as EnvironmentUrls });
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] };
|
||||
const env = await this.environmentService.create({
|
||||
name,
|
||||
urls: urls as EnvironmentUrls,
|
||||
});
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_environment',
|
||||
"update_environment",
|
||||
{
|
||||
description: 'Update an existing environment (name and/or urls)',
|
||||
description: "Update an existing environment (name and/or urls)",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Environment ID to update'),
|
||||
name: z.string().optional().describe('New name'),
|
||||
urls: z.record(z.string(), z.string()).optional().describe('New URLs map'),
|
||||
id: z.number().int().describe("Environment ID to update"),
|
||||
name: z.string().optional().describe("New name"),
|
||||
urls: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("New URLs map"),
|
||||
},
|
||||
},
|
||||
async ({ id, name, urls }) => {
|
||||
try {
|
||||
const env = await this.environmentService.update(id, { name, urls: urls as EnvironmentUrls | undefined });
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(env) }] };
|
||||
const env = await this.environmentService.update(id, {
|
||||
name,
|
||||
urls: urls as EnvironmentUrls | 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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_environment',
|
||||
"delete_environment",
|
||||
{
|
||||
description: 'Delete an environment by ID',
|
||||
description: "Delete an environment by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Environment ID to delete'),
|
||||
id: z.number().int().describe("Environment ID to delete"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
await this.environmentService.remove(id);
|
||||
return { content: [{ type: 'text' as const, text: `Environment ${id} deleted` }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -193,43 +305,86 @@ export class McpService {
|
||||
// ── Browser ───────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'open_url',
|
||||
"open_url",
|
||||
{
|
||||
description: 'Open a URL using a stored session and return the page title and content',
|
||||
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'),
|
||||
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) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'exec_code',
|
||||
"exec_code",
|
||||
{
|
||||
description: 'Execute arbitrary Playwright JavaScript with `page` and `context` in scope',
|
||||
description:
|
||||
"Execute arbitrary Playwright JavaScript with `page` and `context` in scope",
|
||||
inputSchema: {
|
||||
sessionName: z.string().optional().describe('Session name to restore. Omit to run without a stored session.'),
|
||||
code: z.string().describe('JavaScript code body to execute (async-safe, may use `page` and `context`)'),
|
||||
url: z.string().url().optional().describe('Optional URL to navigate to before running code'),
|
||||
sessionName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Session name to restore. Omit to run without a stored session.",
|
||||
),
|
||||
code: z
|
||||
.string()
|
||||
.describe(
|
||||
"JavaScript code body to execute (async-safe, may use `page` and `context`)",
|
||||
),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.describe("Optional URL to navigate to before running code"),
|
||||
},
|
||||
},
|
||||
async ({ sessionName, code, url }) => {
|
||||
try {
|
||||
this.codeExecutor.validate(code);
|
||||
const result = await this.browserService.exec(sessionName, code, url);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -237,215 +392,341 @@ export class McpService {
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'list_scenarios',
|
||||
"list_scenarios",
|
||||
{
|
||||
description: 'List all scenarios (paginated)',
|
||||
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)'),
|
||||
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) }] };
|
||||
const result = await this.scenarioService.findAll({
|
||||
page,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDir,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_scenario',
|
||||
"get_scenario",
|
||||
{
|
||||
description: 'Get a scenario with its steps by ID',
|
||||
description: "Get a scenario with its steps by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Scenario ID'),
|
||||
id: z.number().int().describe("Scenario ID"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.findOne(id);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scenario) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_scenario',
|
||||
"create_scenario",
|
||||
{
|
||||
description: 'Create a new scenario',
|
||||
description: "Create a new scenario",
|
||||
inputSchema: {
|
||||
name: z.string().describe('Scenario name'),
|
||||
name: z.string().describe("Scenario name"),
|
||||
},
|
||||
},
|
||||
async ({ name }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.create({ name });
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scenario) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_scenario',
|
||||
"update_scenario",
|
||||
{
|
||||
description: 'Update a scenario name',
|
||||
description: "Update a scenario name",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Scenario ID'),
|
||||
name: z.string().optional().describe('New name'),
|
||||
id: z.number().int().describe("Scenario ID"),
|
||||
name: z.string().optional().describe("New name"),
|
||||
},
|
||||
},
|
||||
async ({ id, name }) => {
|
||||
try {
|
||||
const scenario = await this.scenarioService.update(id, { name });
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scenario) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_scenario',
|
||||
"delete_scenario",
|
||||
{
|
||||
description: 'Delete a scenario by ID',
|
||||
description: "Delete a scenario by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Scenario ID'),
|
||||
id: z.number().int().describe("Scenario ID"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
await this.scenarioService.remove(id);
|
||||
return { content: [{ type: 'text' as const, text: `Scenario ${id} deleted` }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'create_scenario_step',
|
||||
"create_scenario_step",
|
||||
{
|
||||
description: 'Add a step to a scenario',
|
||||
description: "Add a step to a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe('Parent scenario ID'),
|
||||
order: z.number().int().min(0).describe('Execution order (ascending)'),
|
||||
type: z.enum(['login', 'exec', 'sign']).describe('Step type'),
|
||||
sessionName: z.string().describe('Session name used by this step'),
|
||||
execCode: z.string().optional().describe('Playwright JS code to execute (exec steps)'),
|
||||
validateCode: z.string().optional().describe('Validation JS code returning { success, description }'),
|
||||
scenarioId: z.number().int().describe("Parent scenario ID"),
|
||||
order: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Execution order (ascending)"),
|
||||
type: z.enum(["login", "exec", "sign"]).describe("Step type"),
|
||||
sessionName: z.string().describe("Session name used by this step"),
|
||||
execCode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Playwright JS code to execute (exec steps)"),
|
||||
validateCode: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Validation JS code returning { success, description }"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, ...dto }) => {
|
||||
try {
|
||||
const step = await this.scenarioService.createStep(scenarioId, dto);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(step) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'get_scenario_step',
|
||||
"get_scenario_step",
|
||||
{
|
||||
description: 'Get a single step of a scenario',
|
||||
description: "Get a single step of a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe('Scenario ID'),
|
||||
stepId: z.number().int().describe('Step ID'),
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
stepId: z.number().int().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) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_scenario_step',
|
||||
"update_scenario_step",
|
||||
{
|
||||
description: 'Update a step within a scenario',
|
||||
description: "Update a step within a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe('Scenario ID'),
|
||||
stepId: z.number().int().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'),
|
||||
sessionName: z.string().optional().describe('New session name'),
|
||||
execCode: z.string().optional().describe('New exec code'),
|
||||
validateCode: z.string().optional().describe('New validation code'),
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
stepId: z.number().int().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"),
|
||||
sessionName: z.string().optional().describe("New session name"),
|
||||
execCode: z.string().optional().describe("New exec code"),
|
||||
validateCode: z.string().optional().describe("New validation code"),
|
||||
},
|
||||
},
|
||||
async ({ scenarioId, stepId, ...dto }) => {
|
||||
try {
|
||||
const step = await this.scenarioService.updateStep(scenarioId, stepId, dto);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(step) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_scenario_step',
|
||||
"delete_scenario_step",
|
||||
{
|
||||
description: 'Delete a step from a scenario',
|
||||
description: "Delete a step from a scenario",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().describe('Scenario ID'),
|
||||
stepId: z.number().int().describe('Step ID'),
|
||||
scenarioId: z.number().int().describe("Scenario ID"),
|
||||
stepId: z.number().int().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}` }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'list_scenario_runs',
|
||||
"list_scenario_runs",
|
||||
{
|
||||
description: 'List runs for a scenario (paginated, optionally filtered by status)',
|
||||
description:
|
||||
"List runs for a scenario (paginated, optionally filtered by status)",
|
||||
inputSchema: {
|
||||
scenarioId: z.number().int().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)'),
|
||||
scenarioId: z.number().int().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) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'run_scenario',
|
||||
"run_scenario",
|
||||
{
|
||||
description: 'Trigger an immediate run of a scenario by ID',
|
||||
description: "Trigger an immediate run of a scenario by ID",
|
||||
inputSchema: {
|
||||
id: z.number().int().describe('Scenario ID to run'),
|
||||
id: z.number().int().describe("Scenario ID to run"),
|
||||
},
|
||||
},
|
||||
async ({ id }) => {
|
||||
try {
|
||||
const run = await this.scenarioService.createRun(id);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(run) }] };
|
||||
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 }] };
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: (err as Error).message }],
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -455,7 +736,9 @@ export class McpService {
|
||||
|
||||
async handle(req: Request, res: Response): Promise<void> {
|
||||
const server = this.createServer();
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
});
|
||||
await server.connect(transport);
|
||||
try {
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
Reference in New Issue
Block a user