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:
@@ -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
@@ -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
|
||||
|
||||
+30
-40
@@ -25,6 +25,13 @@ describe('McpController', () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
/** Parse the JSON-RPC payload from an SSE response body. */
|
||||
function parseSse(text: string): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/** Send a single MCP tool call and return the parsed response body. */
|
||||
async function mcpCall(toolName: string, args: Record<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user