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'; @Injectable() export class McpService { constructor( private readonly authService: AuthService, private readonly sessionService: SessionService, private readonly environmentService: EnvironmentService, private readonly browserService: BrowserService, private readonly codeExecutor: CodeExecutorService, private readonly scenarioService: ScenarioService, ) {} private createServer(): McpServer { const server = new McpServer({ name: pkg.name, version: pkg.version }); this.registerTools(server); return server; } private registerTools(server: McpServer): void { // ── Auth ────────────────────────────────────────────────────────────────── server.registerTool( '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) }] }; }, ); server.registerTool( 'login', { 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.'), }, }, async ({ key, environmentName, sessionName }) => { const result = await this.authService.login(key, environmentName, sessionName); return { content: [{ type: 'text' as const, text: JSON.stringify(result) }] }; }, ); // ── 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', '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', inputSchema: { 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` }] }; } await this.sessionService.remove(id); 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.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) }] }; } 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 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, …)'), }, }, 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) }] }; } 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 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'), }, }, 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) }] }; } 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.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` }] }; } 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 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'), }, }, 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) }] }; } 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.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) }] }; } 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'), }, }, async ({ name }) => { try { const scenario = await this.scenarioService.create({ name }); 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', inputSchema: { 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) }] }; } 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.number().int().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.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) }] }; } 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.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) }] }; } 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.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) }] }; } 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.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}` }] }; } 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.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) }] }; } 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.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) }] }; } 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(); } } }