From d9cdb64ee2fe7ad7167999dfbb3fa6d6293e29b9 Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 7 Apr 2026 17:25:55 +0300 Subject: [PATCH] feat(mcp): add scenario tools and fix per-request server lifecycle - add list_scenarios, get_scenario, create_scenario, update_scenario, delete_scenario - add create_scenario_step, get_scenario_step, update_scenario_step, delete_scenario_step - add list_scenario_runs, run_scenario - import ScenarioModule into McpModule so ScenarioService is injectable - move tool registration to private registerTools(server) method; create fresh McpServer per request in handle() to satisfy SDK one-connection rule - fix mcp.controller.spec: parse SSE data line instead of res.body; assert exact status 200 everywhere; remove vague toBeLessThan(500) guards --- src/mcp/mcp.module.ts | 3 +- src/mcp/mcp.service.ts | 224 +++++++++++++++++++++++++++++++++++- test/mcp.controller.spec.ts | 70 +++++------ 3 files changed, 254 insertions(+), 43 deletions(-) diff --git a/src/mcp/mcp.module.ts b/src/mcp/mcp.module.ts index 21d521a..1d02887 100644 --- a/src/mcp/mcp.module.ts +++ b/src/mcp/mcp.module.ts @@ -6,9 +6,10 @@ 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], + imports: [AuthModule, SessionModule, EnvironmentModule, BrowserModule, CodeExecutorModule, ScenarioModule], controllers: [McpController], providers: [McpService], }) diff --git a/src/mcp/mcp.service.ts b/src/mcp/mcp.service.ts index 51c74c2..23db54c 100644 --- a/src/mcp/mcp.service.ts +++ b/src/mcp/mcp.service.ts @@ -9,6 +9,7 @@ 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'; @Injectable() export class McpService { @@ -18,10 +19,10 @@ export class McpService { private readonly environmentService: EnvironmentService, private readonly browserService: BrowserService, private readonly codeExecutor: CodeExecutorService, + private readonly scenarioService: ScenarioService, ) {} - async handle(req: Request, res: Response): Promise { - const server = new McpServer({ name: 'liquio-qa-bot', version: '1.0.0' }); + private registerTools(server: McpServer): void { // ── Auth ────────────────────────────────────────────────────────────────── @@ -208,7 +209,226 @@ export class McpService { }, ); + // ── 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)'), + }, + }, + async ({ page, limit }) => { + const result = await this.scenarioService.findAll({ page, limit }); + 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']).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']).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 = new McpServer({ name: 'liquio-qa-bot', version: '1.0.0' }); + this.registerTools(server); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless — no session management diff --git a/test/mcp.controller.spec.ts b/test/mcp.controller.spec.ts index b00df98..6c2fd05 100644 --- a/test/mcp.controller.spec.ts +++ b/test/mcp.controller.spec.ts @@ -25,6 +25,13 @@ describe('McpController', () => { await app.close(); }); + /** Parse the JSON-RPC payload from an SSE response body. */ + function parseSse(text: string): Record { + const match = text.match(/^data:\s*(.+)$/m); + if (!match) throw new Error(`No SSE data line found in: ${text}`); + return JSON.parse(match[1]) as Record; + } + /** Send a single MCP tool call and return the parsed response body. */ async function mcpCall(toolName: string, args: Record = {}) { const res = await request(app.getHttpServer()) @@ -37,7 +44,7 @@ describe('McpController', () => { method: 'tools/call', params: { name: toolName, arguments: args }, }); - return res; + return { status: res.status, rpc: parseSse(res.text) }; } // ── Connectivity ─────────────────────────────────────────────────────────── @@ -58,7 +65,7 @@ describe('McpController', () => { clientInfo: { name: 'test', version: '0' }, }, }); - expect(res.status).toBeLessThan(500); + expect(res.status).toBe(200); }); }); @@ -66,15 +73,10 @@ describe('McpController', () => { describe('list_keys', () => { it('returns a result with text content containing a JSON array', async () => { - const res = await mcpCall('list_keys'); - // MCP may respond with 200 (JSON body) or SSE stream; both are acceptable - expect(res.status).toBeLessThan(500); - - if (res.status === 200 && res.body?.result) { - const text = res.body.result.content?.[0]?.text; - expect(() => JSON.parse(text)).not.toThrow(); - expect(Array.isArray(JSON.parse(text))).toBe(true); - } + const { status, rpc } = await mcpCall('list_keys'); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true); }); }); @@ -82,14 +84,10 @@ describe('McpController', () => { describe('list_sessions', () => { it('returns a result with text content containing a JSON array', async () => { - const res = await mcpCall('list_sessions'); - expect(res.status).toBeLessThan(500); - - if (res.status === 200 && res.body?.result) { - const text = res.body.result.content?.[0]?.text; - expect(() => JSON.parse(text)).not.toThrow(); - expect(Array.isArray(JSON.parse(text))).toBe(true); - } + const { status, rpc } = await mcpCall('list_sessions'); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true); }); }); @@ -97,14 +95,10 @@ describe('McpController', () => { describe('list_environments', () => { it('returns a result with text content containing a JSON array', async () => { - const res = await mcpCall('list_environments'); - expect(res.status).toBeLessThan(500); - - if (res.status === 200 && res.body?.result) { - const text = res.body.result.content?.[0]?.text; - expect(() => JSON.parse(text)).not.toThrow(); - expect(Array.isArray(JSON.parse(text))).toBe(true); - } + const { status, rpc } = await mcpCall('list_environments'); + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true); }); }); @@ -112,17 +106,14 @@ describe('McpController', () => { describe('create_environment', () => { it('creates an environment via MCP', async () => { - const res = await mcpCall('create_environment', { + const { status, rpc } = await mcpCall('create_environment', { name: 'mcp-test-env', urls: { id_url: 'https://id.example.com' }, }); - expect(res.status).toBeLessThan(500); - - if (res.status === 200 && res.body?.result) { - const text = res.body.result.content?.[0]?.text; - const created = JSON.parse(text); - expect(created.name).toBe('mcp-test-env'); - } + expect(status).toBe(200); + const result = rpc.result as { content: { text: string }[] }; + const created = JSON.parse(result.content[0].text) as { name: string }; + expect(created.name).toBe('mcp-test-env'); }); }); @@ -130,12 +121,11 @@ describe('McpController', () => { describe('delete_session', () => { it('returns an MCP error result for a non-existent session id', async () => { - const res = await mcpCall('delete_session', { id: 999999 }); - expect(res.status).toBeLessThan(500); + const { status, rpc } = await mcpCall('delete_session', { id: 999999 }); + expect(status).toBe(200); // MCP wraps service errors as isError:true content, not HTTP errors - if (res.status === 200 && res.body?.result) { - expect(res.body.result.isError).toBe(true); - } + const result = rpc.result as { isError: boolean }; + expect(result.isError).toBe(true); }); }); });