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
292 lines
9.4 KiB
TypeScript
292 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|