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
+30 -40
View File
@@ -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);
});
});
});