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:
+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