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
This commit is contained in:
2026-04-07 17:25:55 +03:00
parent 00f310e899
commit d9cdb64ee2
3 changed files with 254 additions and 43 deletions
+2 -1
View File
@@ -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],
})
+222 -2
View File
@@ -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<void> {
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<void> {
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