Files
upstream-twenty/packages/twenty-server/test/integration/ai/suites/mcp-tool-execution.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

378 lines
11 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const TEST_WORKSPACE_SCHEMA = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
const TOOL_NAMES = {
createCompany: 'create_one_company',
createNote: 'create_one_note',
createNoteTarget: 'create_one_note_target',
groupByNoteTargets: 'group_by_note_targets',
} as const;
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
type DatabaseToolPayload<TResult> = {
success: boolean;
message: string;
result: TResult;
error?: string;
};
type LearnToolsPayload = {
tools: Array<{
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}>;
notFound: string[];
message: string;
};
type CreatedRecord = { id: string };
const baseUrl = `http://localhost:${APP_PORT}`;
const endpoint = '/mcp';
const postMcp = (body: Record<string, unknown>) =>
request(baseUrl)
.post(endpoint)
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(JSON.stringify(body));
const callMcpTool = async (
name: string,
args: Record<string, unknown>,
id: string = `call-${randomUUID()}`,
): Promise<McpToolCallResult> => {
const res = await postMcp({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: { name, arguments: args },
}).expect(200);
expect(res.body.id).toBe(id);
expect(res.body.jsonrpc).toBe('2.0');
expect(res.body.error).toBeUndefined();
return res.body.result as McpToolCallResult;
};
const parseToolPayload = <T>(result: McpToolCallResult): T => {
expect(result.isError).toBe(false);
expect(result.content?.[0]?.type).toBe('text');
const raw = result.content?.[0]?.text;
expect(raw).toBeDefined();
return JSON.parse(raw as string) as T;
};
const executeWorkspaceTool = async <TResult>(
toolName: string,
args: Record<string, unknown>,
): Promise<TResult> => {
const mcpResult = await callMcpTool('execute_tool', {
toolName,
arguments: args,
});
const payload = parseToolPayload<DatabaseToolPayload<TResult>>(mcpResult);
expect(payload.success).toBe(true);
expect(payload.result).toBeDefined();
return payload.result;
};
const learnToolSchema = async (
toolName: string,
): Promise<Record<string, unknown>> => {
const mcpResult = await callMcpTool('learn_tools', {
toolNames: [toolName],
aspects: ['schema'],
});
const payload = parseToolPayload<LearnToolsPayload>(mcpResult);
expect(payload.notFound).toEqual([]);
expect(payload.tools).toHaveLength(1);
expect(payload.tools[0].name).toBe(toolName);
const inputSchema = payload.tools[0].inputSchema;
expect(inputSchema).toBeDefined();
return inputSchema as Record<string, unknown>;
};
describe('MCP tool execution (integration)', () => {
describe('create_note_target (morph relation join)', () => {
let createdCompanyId: string | undefined;
let createdNoteId: string | undefined;
let createdNoteTargetId: string | undefined;
afterAll(async () => {
if (createdNoteTargetId) {
await deleteRecordsByIds('noteTarget', [createdNoteTargetId]);
}
if (createdNoteId) {
await deleteRecordsByIds('note', [createdNoteId]);
}
if (createdCompanyId) {
await deleteRecordsByIds('company', [createdCompanyId]);
}
});
it('should expose the morph-relation join columns as `${name}Id` UUID parameters', async () => {
const inputSchema = await learnToolSchema(TOOL_NAMES.createNoteTarget);
const schema = inputSchema as {
properties?: Record<
string,
{ type?: string; format?: string; $ref?: string }
>;
$defs?: Record<string, { type?: string; format?: string }>;
};
const properties = schema.properties;
expect(properties).toBeDefined();
const resolveProperty = (
prop: { type?: string; format?: string; $ref?: string } | undefined,
) => {
if (!prop) return undefined;
if (prop.$ref && schema.$defs) {
const defKey = prop.$ref.replace('#/$defs/', '');
return schema.$defs[defKey];
}
return prop;
};
expect(resolveProperty(properties?.noteId)).toMatchObject({
type: 'string',
format: 'uuid',
});
// Morph relations must be exposed as `${name}Id` UUIDs (the join column),
// not as the relation name typed as string — the data-arg-processor only
// accepts the join-column form for write operations.
expect(resolveProperty(properties?.targetCompanyId)).toMatchObject({
type: 'string',
format: 'uuid',
});
expect(resolveProperty(properties?.targetPersonId)).toMatchObject({
type: 'string',
format: 'uuid',
});
expect(resolveProperty(properties?.targetOpportunityId)).toMatchObject({
type: 'string',
format: 'uuid',
});
expect(properties?.targetCompany).toBeUndefined();
expect(properties?.targetPerson).toBeUndefined();
expect(properties?.targetOpportunity).toBeUndefined();
});
it('should persist targetCompanyId when create_note_target is invoked via MCP', async () => {
const company = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: `mcp-tool-exec-company-${randomUUID()}` },
);
createdCompanyId = company.id;
expect(createdCompanyId).toBeDefined();
const note = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNote,
{ title: `mcp-tool-exec-note-${randomUUID()}` },
);
createdNoteId = note.id;
expect(createdNoteId).toBeDefined();
const noteTarget = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNoteTarget,
{
noteId: createdNoteId,
targetCompanyId: createdCompanyId,
},
);
createdNoteTargetId = noteTarget.id;
expect(createdNoteTargetId).toBeDefined();
const rows = (await global.testDataSource.query(
`SELECT "noteId", "targetCompanyId", "targetPersonId", "targetOpportunityId"
FROM "${TEST_WORKSPACE_SCHEMA}"."noteTarget" WHERE id = $1`,
[createdNoteTargetId],
)) as Array<{
noteId: string;
targetCompanyId: string | null;
targetPersonId: string | null;
targetOpportunityId: string | null;
}>;
expect(rows).toHaveLength(1);
expect(rows[0].noteId).toBe(createdNoteId);
expect(rows[0].targetCompanyId).toBe(createdCompanyId);
expect(rows[0].targetPersonId).toBeNull();
expect(rows[0].targetOpportunityId).toBeNull();
});
});
describe('group_by_note_targets (morph relation column)', () => {
let createdCompanyAId: string | undefined;
let createdCompanyBId: string | undefined;
const createdNoteIds: string[] = [];
const createdNoteTargetIds: string[] = [];
beforeAll(async () => {
const companyA = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: `mcp-group-by-company-A-${randomUUID()}` },
);
createdCompanyAId = companyA.id;
const companyB = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: `mcp-group-by-company-B-${randomUUID()}` },
);
createdCompanyBId = companyB.id;
// Two targets on company A, one on company B — the grouped counts
// we'll assert against later.
const targetCompanyIds = [
createdCompanyAId,
createdCompanyAId,
createdCompanyBId,
];
for (const targetCompanyId of targetCompanyIds) {
const note = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNote,
{ title: `mcp-group-by-note-${randomUUID()}` },
);
createdNoteIds.push(note.id);
const noteTarget = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createNoteTarget,
{ noteId: note.id, targetCompanyId },
);
createdNoteTargetIds.push(noteTarget.id);
}
});
afterAll(async () => {
if (createdNoteTargetIds.length > 0) {
await deleteRecordsByIds('noteTarget', createdNoteTargetIds);
}
if (createdNoteIds.length > 0) {
await deleteRecordsByIds('note', createdNoteIds);
}
const companyIds = [createdCompanyAId, createdCompanyBId].filter(
(id): id is string => typeof id === 'string',
);
if (companyIds.length > 0) {
await deleteRecordsByIds('company', companyIds);
}
});
it('should expose targetCompanyId as a valid groupBy option', async () => {
const inputSchema = await learnToolSchema(TOOL_NAMES.groupByNoteTargets);
const groupByItems = (
inputSchema as {
properties?: {
groupBy?: {
items?: {
anyOf?: Array<{ properties?: Record<string, unknown> }>;
properties?: Record<string, unknown>;
};
};
};
}
).properties?.groupBy?.items;
expect(groupByItems).toBeDefined();
// Each groupBy variant is { [columnName]: true }. Collect every column
// the schema offers so we can assert on the morph-relation columns.
const groupByColumns = new Set<string>();
if (groupByItems?.anyOf) {
for (const variant of groupByItems.anyOf) {
for (const propertyName of Object.keys(variant.properties ?? {})) {
groupByColumns.add(propertyName);
}
}
} else if (groupByItems?.properties) {
for (const propertyName of Object.keys(groupByItems.properties)) {
groupByColumns.add(propertyName);
}
}
expect(groupByColumns.has('noteId')).toBe(true);
expect(groupByColumns.has('targetCompanyId')).toBe(true);
expect(groupByColumns.has('targetPersonId')).toBe(true);
expect(groupByColumns.has('targetOpportunityId')).toBe(true);
});
it('should group noteTargets by targetCompanyId via MCP', async () => {
type GroupByGroup = {
dimensions: unknown[];
value: string | number;
};
type GroupByResult = {
groups: GroupByGroup[];
dimensionLabels: string[];
aggregation: string;
groupCount: number;
};
const result = await executeWorkspaceTool<GroupByResult>(
TOOL_NAMES.groupByNoteTargets,
{
groupBy: [{ targetCompanyId: true }],
aggregateOperation: 'COUNT',
// Scope to the noteTargets we created so other seeded rows don't
// leak into the counts.
noteId: { in: createdNoteIds },
},
);
expect(result.dimensionLabels).toEqual(['targetCompanyId']);
expect(result.aggregation).toBe('COUNT');
expect(result.groupCount).toBe(2);
// Dimensions are returned positionally, aligned with dimensionLabels.
const countsByCompany = Object.fromEntries(
result.groups.map((group) => [
String(group.dimensions[0]),
Number(group.value),
]),
);
expect(countsByCompany[createdCompanyAId as string]).toBe(2);
expect(countsByCompany[createdCompanyBId as string]).toBe(1);
});
});
});