snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const TOOL_NAMES = {
createCompany: 'create_one_company',
createOpportunity: 'create_one_opportunity',
findManyCompanies: 'find_many_companies',
findOneCompany: 'find_one_company',
findOneOpportunity: 'find_one_opportunity',
} as const;
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
type DatabaseToolPayload<TResult> = {
success: boolean;
message: string;
result: TResult;
warnings?: string[];
error?: string;
};
type CreatedRecord = { id: string };
type FindRecordsResult = {
records: Array<Record<string, unknown>>;
count: string | number;
hasNextPage: boolean;
};
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<DatabaseToolPayload<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;
};
describe('MCP find tools relation selection (integration)', () => {
let companyId: string;
let opportunityAId: string;
let opportunityBId: string;
const companyName = `mcp-relation-select-company-${randomUUID()}`;
const opportunityAName = `mcp-relation-select-opportunity-a-${randomUUID()}`;
const opportunityBName = `mcp-relation-select-opportunity-b-${randomUUID()}`;
beforeAll(async () => {
const company = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createCompany,
{ name: companyName },
);
companyId = company.result.id;
const opportunityA = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createOpportunity,
{ name: opportunityAName, companyId },
);
opportunityAId = opportunityA.result.id;
const opportunityB = await executeWorkspaceTool<CreatedRecord>(
TOOL_NAMES.createOpportunity,
{ name: opportunityBName, companyId },
);
opportunityBId = opportunityB.result.id;
});
afterAll(async () => {
await deleteRecordsByIds(
'opportunity',
[opportunityAId, opportunityBId].filter(Boolean),
);
await deleteRecordsByIds('company', [companyId].filter(Boolean));
});
it('should hydrate a one-to-many relation selected by name in find_many', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findManyCompanies,
{
id: { eq: companyId },
select: ['id', 'name', 'opportunities'],
limit: 1,
},
);
expect(payload.warnings).toBeUndefined();
expect(payload.result.records).toHaveLength(1);
const record = payload.result.records[0];
expect(record.id).toBe(companyId);
expect(record.name).toBe(companyName);
const opportunities = record.opportunities as Array<
Record<string, unknown>
>;
expect(Array.isArray(opportunities)).toBe(true);
expect(opportunities).toHaveLength(2);
expect(opportunities).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: opportunityAId,
name: opportunityAName,
}),
expect.objectContaining({
id: opportunityBId,
name: opportunityBName,
}),
]),
);
});
it('should hydrate a one-to-many relation selected by name in find_one', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findOneCompany,
{
id: companyId,
select: ['id', 'name', 'opportunities'],
},
);
const record = payload.result.records[0];
const opportunities = record.opportunities as Array<
Record<string, unknown>
>;
expect(opportunities).toHaveLength(2);
});
it('should hydrate a many-to-one relation selected by name as a single record', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findOneOpportunity,
{
id: opportunityAId,
select: ['id', 'name', 'company'],
},
);
const record = payload.result.records[0];
expect(record.id).toBe(opportunityAId);
expect(record.company).toEqual(
expect.objectContaining({
id: companyId,
name: companyName,
}),
);
});
it('should hydrate relation fields in wildcard selection', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findOneCompany,
{
id: companyId,
select: ['*'],
},
);
const record = payload.result.records[0];
expect(record.name).toBe(companyName);
const opportunities = record.opportunities as Array<
Record<string, unknown>
>;
expect(opportunities).toHaveLength(2);
});
it('should suggest the relation field for a near-miss selection', async () => {
const payload = await executeWorkspaceTool<FindRecordsResult>(
TOOL_NAMES.findManyCompanies,
{
id: { eq: companyId },
select: ['name', 'opportunitiez'],
limit: 1,
},
);
expect(payload.warnings).toEqual([
expect.stringContaining("'opportunities'"),
]);
expect(payload.result.records[0]).not.toHaveProperty('opportunities');
});
});

View File

@@ -0,0 +1,291 @@
import { gql } from 'graphql-tag';
import request from 'supertest';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { ToolCategory } from 'twenty-shared/ai';
/**
* Contract tests for the MCP tool catalog.
*
* 1. Catalog contract: every category advertised by get_tool_catalog must be
* dispatchable end to end through execute_tool. Dispatchable means the
* registry resolves and executes the tool: a structured business failure
* (e.g. a search tool rejecting empty arguments) still proves dispatch,
* only "not found" / "not available" outputs do not. Scales automatically:
* a newly registered provider is covered the moment it appears in the
* catalog, with no new test code.
* 2. Permission gating: the catalog is role-dependent. An API key bound to a
* role without settings permissions must not see settings-gated tools
* (e.g. the ROLE category), while an admin-bound key must.
*/
const baseUrl = `http://localhost:${APP_PORT}`;
const postMcp = (body: object, bearer: string) =>
request(baseUrl)
.post('/mcp')
.set('Authorization', `Bearer ${bearer}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(JSON.stringify(body));
const callMcpTool = async (
bearer: string,
toolName: string,
toolArguments: object,
) => {
const response = await postMcp(
{
jsonrpc: '2.0',
method: 'tools/call',
params: { name: toolName, arguments: toolArguments },
id: '1',
},
bearer,
).expect(200);
return response.body.result as {
content: { type: string; text: string }[];
isError: boolean;
};
};
const getToolCatalog = async (
bearer: string,
): Promise<Record<string, { name: string; description: string }[]>> => {
const result = await callMcpTool(bearer, 'get_tool_catalog', {});
expect(result.isError).toBe(false);
return JSON.parse(result.content[0].text).catalog;
};
const READ_ONLY_TOOL_NAME_PATTERN = /^(find_|list_|get_|search_)/;
// Dispatch-layer failures come from execute_tool gating or the registry
// (unknown or unavailable tool), not from the executed tool itself. Their
// exact wording is pinned by the "should report unknown tools as dispatch
// failures" control test below, so drift fails loudly instead of silently
// weakening the catalog contract.
const DISPATCH_FAILURE_MESSAGE_PATTERN =
/^Tool ".+" (not found|is not available)$/;
const isDispatchFailure = (result: {
content: { type: string; text: string }[];
}): boolean => {
let output: { success?: boolean; message?: string };
try {
output = JSON.parse(result.content[0].text);
} catch {
return false;
}
return (
output.success === false &&
DISPATCH_FAILURE_MESSAGE_PATTERN.test(output.message ?? '')
);
};
// Deliberate exceptions to the "every advertised category is dispatchable
// through a read-only tool" contract. Currently none: every category the MCP
// catalog advertises ships at least one read-only tool. Adding a category
// here must be a conscious decision, not silent drift.
const EXPECTED_CATEGORIES_WITHOUT_READ_ONLY_TOOLS: string[] = [];
const createApiKeyToken = async (roleId: string): Promise<string> => {
const createResponse = await makeMetadataAPIRequest({
query: gql`
mutation CreateApiKey($input: CreateApiKeyInput!) {
createApiKey(input: $input) {
id
}
}
`,
variables: {
input: {
name: `MCP catalog test key ${roleId}`,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
roleId,
},
},
});
const apiKeyId = createResponse.body.data?.createApiKey?.id;
jestExpectToBeDefined(apiKeyId);
createdApiKeyIds.push(apiKeyId);
const tokenResponse = await generateApiKeyToken({
apiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
const token = tokenResponse.body.data?.generateApiKeyToken?.token;
jestExpectToBeDefined(token);
return token;
};
const createdApiKeyIds: string[] = [];
describe('MCP tool catalog (integration)', () => {
let adminApiKeyToken: string;
let restrictedApiKeyToken: string;
let restrictedRoleId: string;
beforeAll(async () => {
const rolesResponse = await makeMetadataAPIRequest({
query: gql`
query GetRoles {
getRoles {
id
label
}
}
`,
});
const adminRoleId = rolesResponse.body.data?.getRoles?.find(
(role: { label: string }) => role.label === 'Admin',
)?.id;
jestExpectToBeDefined(adminRoleId);
const { data: restrictedRoleData } = await createOneRole({
expectToFail: false,
input: {
label: 'MCP Catalog Restricted Role',
description: 'API-key role without settings permissions',
icon: 'IconKey',
canUpdateAllSettings: false,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: true,
},
});
restrictedRoleId = restrictedRoleData?.createOneRole?.id as string;
jestExpectToBeDefined(restrictedRoleId);
adminApiKeyToken = await createApiKeyToken(adminRoleId);
restrictedApiKeyToken = await createApiKeyToken(restrictedRoleId);
});
afterAll(async () => {
for (const apiKeyId of createdApiKeyIds) {
await testDataSource
.query('DELETE FROM core."apiKey" WHERE id = $1', [apiKeyId])
.catch(() => {});
}
if (restrictedRoleId) {
await deleteOneRole({
expectToFail: false,
input: { idToDelete: restrictedRoleId },
});
}
});
describe('catalog contract', () => {
it('should dispatch one read-only tool per advertised category through execute_tool', async () => {
const catalog = await getToolCatalog(adminApiKeyToken);
const categories = Object.keys(catalog);
expect(categories.length).toBeGreaterThan(0);
const categoriesWithoutReadOnlyTool: string[] = [];
for (const category of categories) {
const readOnlyCandidates = catalog[category]
.filter((tool) => READ_ONLY_TOOL_NAME_PATTERN.test(tool.name))
.slice(0, 3);
if (readOnlyCandidates.length === 0) {
// A write-only category has nothing safe to dispatch in CI; it is
// collected and checked against the deliberate exception list below.
categoriesWithoutReadOnlyTool.push(category);
continue;
}
let dispatched = false;
for (const candidate of readOnlyCandidates) {
const result = await callMcpTool(adminApiKeyToken, 'execute_tool', {
toolName: candidate.name,
arguments: {},
});
// Called with empty arguments, a resolved tool may legitimately
// return a structured failure (isError true since the MCP layer
// surfaces success: false); only a dispatch-layer failure means the
// category is advertised but not actually wired up.
if (!isDispatchFailure(result)) {
dispatched = true;
break;
}
}
expect({ category, dispatched }).toEqual({
category,
dispatched: true,
});
}
// Exact equality fails in both directions, so gaining or losing a
// skipped category forces a deliberate update of the exception list.
expect([...categoriesWithoutReadOnlyTool].sort()).toEqual(
EXPECTED_CATEGORIES_WITHOUT_READ_ONLY_TOOLS,
);
});
it('should report unknown tools as dispatch failures', async () => {
const result = await callMcpTool(adminApiKeyToken, 'execute_tool', {
toolName: 'definitely_not_a_registered_tool',
arguments: {},
});
expect(result.isError).toBe(true);
expect(isDispatchFailure(result)).toBe(true);
});
});
describe('permission gating', () => {
it('should expose role tools to an admin-bound API key', async () => {
const catalog = await getToolCatalog(adminApiKeyToken);
jestExpectToBeDefined(catalog[ToolCategory.ROLE]);
const roleToolNames = catalog[ToolCategory.ROLE].map((tool) => tool.name);
expect(roleToolNames).toEqual(
expect.arrayContaining(['list_roles', 'create_role', 'update_role']),
);
});
it('should hide role tools from an API key without settings permissions', async () => {
const catalog = await getToolCatalog(restrictedApiKeyToken);
expect(catalog[ToolCategory.ROLE]).toBeUndefined();
const allToolNames = Object.values(catalog)
.flat()
.map((tool) => tool.name);
expect(allToolNames).not.toEqual(expect.arrayContaining(['create_role']));
// The restricted role still sees record read tools, proving the empty
// ROLE category is gating rather than a broken catalog.
expect(catalog[ToolCategory.DATABASE_CRUD]?.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,377 @@
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);
});
});
});

View File

@@ -0,0 +1,329 @@
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',
});
});
});
});

View File

@@ -0,0 +1,129 @@
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { uploadFileWithDirectUpload } from 'test/integration/graphql/utils/upload-file-with-direct-upload.util';
import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids';
const TEST_WORKSPACE_SCHEMA = 'workspace_1wgvd1injqtife6y4rvfbu3h5';
type McpToolCallResult = {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
};
type DatabaseToolPayload<TResult> = {
success: boolean;
message: string;
result: TResult;
};
const callExecuteTool = async <TResult>(
toolName: string,
args: Record<string, unknown>,
): Promise<DatabaseToolPayload<TResult>> => {
const id = `call-${randomUUID()}`;
const res = await request(`http://localhost:${APP_PORT}`)
.post('/mcp')
.set('Authorization', `Bearer ${API_KEY_ACCESS_TOKEN}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.send(
JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
id,
params: {
name: 'execute_tool',
arguments: { toolName, arguments: args },
},
}),
)
.expect(200);
const result = res.body.result as McpToolCallResult;
expect(result.isError).toBe(false);
return JSON.parse(
result.content?.[0]?.text as string,
) as DatabaseToolPayload<TResult>;
};
const getAttachmentFileIds = async (
attachmentIds: string[],
): Promise<string[]> => {
const rows = await global.testDataSource.query(
`SELECT id, file FROM "${TEST_WORKSPACE_SCHEMA}"."attachment" WHERE id = ANY($1)`,
[attachmentIds],
);
return rows.map(
(row: { file: Array<{ fileId: string }> }) => row.file[0].fileId,
);
};
describe('agent chat files in record CRUD tools (integration)', () => {
let chatFileId: string;
const createdAttachmentIds: string[] = [];
beforeAll(async () => {
jest.useRealTimers();
const uploadedFile = await uploadFileWithDirectUpload({
filename: 'chat-upload.pdf',
content: Buffer.from('%PDF-1.4 chat upload'),
fileFolder: 'AgentChat',
});
chatFileId = uploadedFile.id;
expect(uploadedFile.path).toContain('agent-chat/');
});
afterAll(async () => {
await deleteRecordsByIds('attachment', createdAttachmentIds);
});
it('should copy a chat upload once per record when several records reference it', async () => {
const payload = await callExecuteTool<Array<{ id: string }>>(
'create_many_attachments',
{
records: [
{
name: 'first.pdf',
file: [{ fileId: chatFileId, label: 'first.pdf' }],
},
{
name: 'second.pdf',
file: [{ fileId: chatFileId, label: 'second.pdf' }],
},
],
},
);
expect(payload.success).toBe(true);
const attachmentIds = payload.result.map((record) => record.id);
createdAttachmentIds.push(...attachmentIds);
expect(attachmentIds).toHaveLength(2);
const storedFileIds = await getAttachmentFileIds(attachmentIds);
expect(storedFileIds).toHaveLength(2);
expect(storedFileIds[0]).not.toBe(storedFileIds[1]);
expect(storedFileIds).not.toContain(chatFileId);
const copiedFiles = await global.testDataSource.query(
'SELECT id, path FROM core."file" WHERE id = ANY($1)',
[storedFileIds],
);
expect(copiedFiles).toHaveLength(2);
copiedFiles.forEach((file: { path: string }) => {
expect(file.path).toContain('files-field/');
});
});
});

View File

@@ -0,0 +1,121 @@
import { TEST_AI_MODEL_ID } from 'test/integration/constants/test-ai-model-ids.constants';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
describe('Workflow agent role assignment persistence (integration)', () => {
let agentWithRoleId: string;
let agentWithoutRoleId: string;
let roleId: string;
beforeAll(async () => {
const { data: roleData } = await createOneRole({
expectToFail: false,
input: {
label: 'Workflow Agent Assignment Test Role',
description:
'Role used to verify workflow agent role assignment persistence',
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
});
roleId = roleData.createOneRole.id;
const { data: agentWithRoleData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Role-Assigned Workflow Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
roleId,
},
});
agentWithRoleId = agentWithRoleData.createOneAgent.id;
const { data: agentWithoutRoleData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Roleless Workflow Agent',
prompt: 'Test prompt',
modelId: TEST_AI_MODEL_ID,
},
});
agentWithoutRoleId = agentWithoutRoleData.createOneAgent.id;
});
afterAll(async () => {
await deleteOneAgent({
expectToFail: false,
input: { id: agentWithRoleId },
});
await deleteOneAgent({
expectToFail: false,
input: { id: agentWithoutRoleId },
});
await deleteOneRole({
expectToFail: false,
input: { idToDelete: roleId },
});
});
it('persists a role target row when an agent is created with a roleId', async () => {
const rows = await global.testDataSource.query(
`SELECT "roleId" FROM "core"."roleTarget"
WHERE "agentId" = $1 AND "workspaceId" = $2`,
[agentWithRoleId, SEED_APPLE_WORKSPACE_ID],
);
expect(rows).toHaveLength(1);
expect(rows[0].roleId).toBe(roleId);
});
it('resolves the persisted agent role through the role target repository', async () => {
const roleTargetRepository = global.app.get<Repository<RoleTargetEntity>>(
getRepositoryToken(RoleTargetEntity),
);
const roleTarget = await roleTargetRepository.findOne({
where: {
agentId: agentWithRoleId,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
select: ['roleId'],
});
expect(roleTarget?.roleId).toBe(roleId);
});
it('does not create a role target row for an agent with no role assignment', async () => {
const roleTargetRepository = global.app.get<Repository<RoleTargetEntity>>(
getRepositoryToken(RoleTargetEntity),
);
const roleTarget = await roleTargetRepository.findOne({
where: {
agentId: agentWithoutRoleId,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
select: ['roleId'],
});
expect(roleTarget).toBeNull();
});
});