Files
upstream-twenty/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts
upstream-mirror e7da97194a
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s
snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
2026-09-17 23:28:13 +05:00

330 lines
10 KiB
TypeScript

import request from 'supertest';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
/**
* Integration tests for MCP core controller
*
* These tests hit the real Nest app bootstrapped by test/integration/utils/setup-test.ts
* and exercise the guarded POST /mcp endpoint using valid/invalid JSON-RPC payloads.
*/
describe('MCP Controller (integration)', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const endpoint = '/mcp';
const postMcp = (
body: any,
bearer: string = API_KEY_ACCESS_TOKEN,
accept: string = 'application/json',
) => {
return request(baseUrl)
.post(endpoint)
.set('Authorization', `Bearer ${bearer}`)
.set('Content-Type', 'application/json')
.set('Accept', accept)
.send(JSON.stringify(body));
};
it('should return 405 with JSON-RPC error for GET /mcp', async () => {
await request(baseUrl)
.get(endpoint)
.expect(405)
.expect((res) => {
expect(res.headers.allow).toBe('POST');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32600);
expect(res.body.id).toBeNull();
});
});
it('should return 405 with JSON-RPC error for DELETE /mcp', async () => {
await request(baseUrl)
.delete(endpoint)
.expect(405)
.expect((res) => {
expect(res.headers.allow).toBe('POST');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32600);
expect(res.body.id).toBeNull();
});
});
it('should respond to ping with a JSON-RPC result envelope', async () => {
await postMcp({ jsonrpc: '2.0', method: 'ping', id: '1' })
.expect(200)
.expect((res) => {
expect(res.body).toMatchObject({
id: '1',
jsonrpc: '2.0',
result: {},
});
expect(res.body.error).toBeUndefined();
});
});
it('should respond to initialize with spec-compliant InitializeResult', async () => {
await postMcp({ jsonrpc: '2.0', method: 'initialize', id: 123 })
.expect(200)
.expect((res) => {
expect(res.body.id).toBe(123);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.result).toBeDefined();
expect(res.body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
expect(res.body.result.capabilities).toBeDefined();
expect(res.body.result.serverInfo).toBeDefined();
expect(res.body.result.serverInfo.name).toBe('Twenty MCP Server');
expect(typeof res.body.result.instructions).toBe('string');
});
});
it('should return 202 with no body for notifications/initialized (no id)', async () => {
await postMcp({ jsonrpc: '2.0', method: 'notifications/initialized' })
.expect(202)
.expect((res) => {
expect(res.body).toEqual({});
});
});
it('should validate request body and return 400 for invalid payload (missing method)', async () => {
await postMcp({})
.expect(400)
.expect((res) => {
expect(res.body.error).toBe('BadRequestException');
});
});
describe('MCP Tools', () => {
it('should list available tools with schemas', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/list',
id: 'tools-list-1',
}).expect(200);
expect(res.body.id).toBe('tools-list-1');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.result).toBeDefined();
expect(Array.isArray(res.body.result.tools)).toBe(true);
expect(res.body.result.tools.length).toBeGreaterThanOrEqual(0);
const first = res.body.result.tools[0];
if (first) {
expect(first.name).toBeDefined();
expect(first.description).toBeDefined();
expect(first.inputSchema).toBeDefined();
}
});
it('should return invalid params error for tools/call without params', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id: 'tools-call-empty',
}).expect(200);
expect(res.body.id).toBe('tools-call-empty');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32602);
});
it('should return error when calling a non-existent tool', async () => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id: 'tools-call-missing',
params: { name: 'non_existent_tool', arguments: {} },
}).expect(200);
expect(res.body.id).toBe('tools-call-missing');
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeDefined();
expect(res.body.error.code).toBe(-32602);
expect(String(res.body.error.message)).toMatch(/non_existent_tool/);
});
it('should exercise each listed tool with a call and receive a JSON-RPC envelope', async () => {
const list = await postMcp({
jsonrpc: '2.0',
method: 'tools/list',
id: 'tools-list-for-calls',
}).expect(200);
const tools: Array<{ name: string }> = list.body.result.tools || [];
// For each tool, attempt a call with empty arguments. We only assert we get a valid envelope
// with either a result or an error. This ensures the MCP controller routes tool calls correctly
// for every tool exposed in the workspace, without relying on specific seeded data.
for (let i = 0; i < tools.length; i++) {
const tool = tools[i];
const id = `tool-call-${i}`;
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: { name: tool.name, arguments: {} },
}).expect(200);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.id).toBe(id);
const hasResultContent = !!res.body?.result?.content;
const hasError = !!res.body?.error;
expect(hasResultContent || hasError).toBe(true);
}
}, 60_000);
it('should list prompts and resources as empty arrays', async () => {
const prompts = await postMcp({
jsonrpc: '2.0',
method: 'prompts/list',
id: 'prompts-1',
}).expect(200);
expect(Array.isArray(prompts.body.result.prompts)).toBe(true);
const resources = await postMcp({
jsonrpc: '2.0',
method: 'resources/list',
id: 'resources-1',
}).expect(200);
expect(Array.isArray(resources.body.result.resources)).toBe(true);
});
});
describe('SSE Streaming', () => {
const parseSseEvents = (
rawBody: string,
): Array<Record<string, unknown>> => {
return rawBody
.split('\n\n')
.filter((block) => block.trim().length > 0)
.map((block) => {
const dataLine = block
.split('\n')
.find((line) => line.startsWith('data: '));
if (!dataLine) {
throw new Error(`No data line found in SSE block: ${block}`);
}
return JSON.parse(dataLine.slice('data: '.length));
});
};
it('should respond with SSE when Accept includes text/event-stream', async () => {
const res = await postMcp(
{ jsonrpc: '2.0', method: 'ping', id: 'sse-ping-1' },
API_KEY_ACCESS_TOKEN,
'application/json, text/event-stream',
).expect(200);
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
const events = parseSseEvents(res.text);
expect(events.length).toBeGreaterThanOrEqual(1);
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject({
id: 'sse-ping-1',
jsonrpc: '2.0',
result: {},
});
});
it('should respond with JSON when Accept is application/json only', async () => {
const res = await postMcp(
{ jsonrpc: '2.0', method: 'ping', id: 'json-ping-1' },
API_KEY_ACCESS_TOKEN,
'application/json',
).expect(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.body).toMatchObject({
id: 'json-ping-1',
jsonrpc: '2.0',
result: {},
});
});
it('should echo the client progress token before the tool call result in SSE', async () => {
const res = await postMcp(
{
jsonrpc: '2.0',
method: 'tools/call',
id: 'sse-tool-1',
params: {
name: 'learn_tools',
arguments: { toolNames: [] },
_meta: { progressToken: 'client-token-1' },
},
},
API_KEY_ACCESS_TOKEN,
'application/json, text/event-stream',
).expect(200);
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
const events = parseSseEvents(res.text);
expect(events.length).toBeGreaterThanOrEqual(2);
const progressEvent = events[0];
expect(progressEvent).toMatchObject({
jsonrpc: '2.0',
method: 'notifications/progress',
params: {
progressToken: 'client-token-1',
progress: 0,
total: 1,
},
});
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject({
id: 'sse-tool-1',
jsonrpc: '2.0',
});
const hasResultContent = !!(lastEvent as any)?.result?.content;
const hasError = !!(lastEvent as any)?.error;
expect(hasResultContent || hasError).toBe(true);
});
it('should send no progress notification when the client did not supply a token', async () => {
const res = await postMcp(
{
jsonrpc: '2.0',
method: 'tools/call',
id: 'sse-tool-no-progress',
params: { name: 'learn_tools', arguments: { toolNames: [] } },
},
API_KEY_ACCESS_TOKEN,
'application/json, text/event-stream',
).expect(200);
const events = parseSseEvents(res.text);
expect(
events.some((event) => event.method === 'notifications/progress'),
).toBe(false);
const lastEvent = events[events.length - 1];
expect(lastEvent).toMatchObject({
id: 'sse-tool-no-progress',
jsonrpc: '2.0',
});
});
});
});