From d5e65c563e2b8b6659d389c57e58bd57f4d72318 Mon Sep 17 00:00:00 2001 From: Thomas des Francs Date: Mon, 18 May 2026 14:14:34 +0200 Subject: [PATCH] Add MCP tool annotations (#20672) ## Summary Adds explicit MCP tool annotations for the Twenty MCP server so ChatGPT app submission review can inspect the exposed tools without relying on protocol defaults. ## Changes - Adds one-export annotation constants for closed-world read-only tools, open-world read-only tools, and `execute_tool`. - Attaches annotations to the five exposed MCP tools: `search_help_center`, `get_tool_catalog`, `learn_tools`, `execute_tool`, and `load_skills`. - Marks `search_help_center` as read-only and open-world because it performs outbound help-center HTTP requests. - Keeps `get_tool_catalog`, `learn_tools`, and `load_skills` read-only and closed-world. - Keeps `execute_tool` non-read-only, open-world, and destructive because it can route to tools that create/update/delete records or send email. - Returns annotations through `tools/list` and updates MCP tests to cover them. No output schemas are included in this PR. ## Validation - `git diff --check origin/main...HEAD` - `jest --config packages/twenty-server/jest.config.mjs packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts --runInBand` Note: the Jest command was run with arm64 Node because the available shared `node_modules` install contains the arm64 SWC native binding. --- ...-world-read-only-tool-annotations.const.ts | 7 +++ .../mcp-execute-tool-annotations.const.ts | 7 +++ ...-world-read-only-tool-annotations.const.ts | 7 +++ .../__tests__/mcp-protocol.service.spec.ts | 19 +++++++- .../mcp-tool-executor.service.spec.ts | 3 ++ .../api/mcp/services/mcp-protocol.service.ts | 45 ++++++++++++++++--- .../mcp/services/mcp-tool-executor.service.ts | 26 +++++++---- .../mcp/types/mcp-tool-annotations.type.ts | 5 +++ 8 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/constants/mcp-execute-tool-annotations.const.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/types/mcp-tool-annotations.type.ts diff --git a/packages/twenty-server/src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const.ts b/packages/twenty-server/src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const.ts new file mode 100644 index 0000000000..0471a8ab7c --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const.ts @@ -0,0 +1,7 @@ +import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type'; + +export const MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS: McpToolAnnotations = { + readOnlyHint: true, + openWorldHint: false, + destructiveHint: false, +}; diff --git a/packages/twenty-server/src/engine/api/mcp/constants/mcp-execute-tool-annotations.const.ts b/packages/twenty-server/src/engine/api/mcp/constants/mcp-execute-tool-annotations.const.ts new file mode 100644 index 0000000000..6a6fb577a2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/constants/mcp-execute-tool-annotations.const.ts @@ -0,0 +1,7 @@ +import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type'; + +export const MCP_EXECUTE_TOOL_ANNOTATIONS: McpToolAnnotations = { + readOnlyHint: false, + openWorldHint: true, + destructiveHint: true, +}; diff --git a/packages/twenty-server/src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const.ts b/packages/twenty-server/src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const.ts new file mode 100644 index 0000000000..47de680fe4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const.ts @@ -0,0 +1,7 @@ +import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type'; + +export const MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS: McpToolAnnotations = { + readOnlyHint: true, + openWorldHint: true, + destructiveHint: false, +}; diff --git a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts index fc2e9c4199..b0d9fe8f5b 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts @@ -2,12 +2,16 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { Test, type TestingModule } from '@nestjs/testing'; import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const'; +import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const'; +import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-execute-tool-annotations.const'; +import { MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const'; import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const'; import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const'; import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const'; import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc'; import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service'; import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; +import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type'; import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; import { EXECUTE_TOOL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool'; @@ -41,7 +45,18 @@ describe('McpProtocolService', () => { EXECUTE_TOOL_TOOL_NAME, LOAD_SKILL_TOOL_NAME, 'search_help_center', - ]; + ] as const; + + const EXPECTED_MCP_TOOL_ANNOTATIONS: Record< + (typeof EXPECTED_MCP_TOOL_NAMES)[number], + McpToolAnnotations + > = { + [GET_TOOL_CATALOG_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + [LEARN_TOOLS_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + [EXECUTE_TOOL_TOOL_NAME]: MCP_EXECUTE_TOOL_ANNOTATIONS, + [LOAD_SKILL_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + search_help_center: MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + }; beforeEach(async () => { const mockSearchHelpCenterTool = { @@ -255,6 +270,7 @@ describe('McpProtocolService', () => { name, expect.objectContaining({ description: expect.any(String), + annotations: EXPECTED_MCP_TOOL_ANNOTATIONS[name], execute: expect.any(Function), }), ]), @@ -294,6 +310,7 @@ describe('McpProtocolService', () => { name, expect.objectContaining({ description: expect.any(String), + annotations: EXPECTED_MCP_TOOL_ANNOTATIONS[name], }), ]), ), diff --git a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts index ef6a13a392..a05e1e9c1e 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-tool-executor.service.spec.ts @@ -1,4 +1,5 @@ import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const'; +import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const'; import { MCP_PROGRESS_NOTIFICATION_METHOD, TOOL_CALL_PROGRESS_TOKEN_PREFIX, @@ -24,6 +25,7 @@ describe('McpToolExecutorService', () => { required: ['query'], }, }, + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, }, } as any; @@ -42,6 +44,7 @@ describe('McpToolExecutorService', () => { properties: { query: { type: 'string' } }, required: ['query'], }, + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, }, ], }, diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts index e2f958a4a0..b43c2b8251 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts @@ -4,12 +4,16 @@ import { type ToolSet, zodSchema } from 'ai'; import { isDefined } from 'twenty-shared/utils'; import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const'; +import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const'; import { MCP_EXCLUDED_TOOL_NAMES } from 'src/engine/api/mcp/constants/mcp-excluded-tool-names.const'; +import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-execute-tool-annotations.const'; +import { MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const'; import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const'; import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const'; import { MCP_SERVER_INSTRUCTIONS } from 'src/engine/api/mcp/constants/mcp-server-instructions.const'; import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc'; import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; +import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type'; import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util'; import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; @@ -41,6 +45,33 @@ import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; +type McpAnnotatedTool = ToolSet[string] & { + annotations: McpToolAnnotations; +}; + +const MCP_PRELOADED_TOOL_ANNOTATIONS: Record = { + search_help_center: MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, +}; + +const annotatePreloadedMcpTools = (toolSet: ToolSet): ToolSet => + Object.fromEntries( + Object.entries(toolSet).map(([name, toolDefinition]) => { + const annotations = MCP_PRELOADED_TOOL_ANNOTATIONS[name]; + + if (!isDefined(annotations)) { + throw new Error(`Missing MCP annotations for preloaded tool "${name}"`); + } + + return [ + name, + { + ...toolDefinition, + annotations, + } as McpAnnotatedTool, + ]; + }), + ); + @Injectable() export class McpProtocolService { constructor( @@ -121,7 +152,7 @@ export class McpProtocolService { ); return { - ...preloadedTools, + ...annotatePreloadedMcpTools(preloadedTools), [GET_TOOL_CATALOG_TOOL_NAME]: { ...createGetToolCatalogTool(this.toolRegistry, workspace.id, roleId, { userId: options?.userId, @@ -129,7 +160,8 @@ export class McpProtocolService { excludeTools: MCP_EXCLUDED_TOOL_NAMES, }), inputSchema: zodSchema(getToolCatalogInputSchema), - }, + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, [LEARN_TOOLS_TOOL_NAME]: { ...createLearnToolsTool( this.toolRegistry, @@ -137,13 +169,15 @@ export class McpProtocolService { MCP_EXCLUDED_TOOL_NAMES, ), inputSchema: zodSchema(learnToolsInputSchema), - }, + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, [EXECUTE_TOOL_TOOL_NAME]: { ...createExecuteToolTool(this.toolRegistry, toolContext, { excludeTools: MCP_EXCLUDED_TOOL_NAMES, }), inputSchema: executeToolInputSchema, - }, + annotations: MCP_EXECUTE_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, [LOAD_SKILL_TOOL_NAME]: { ...createLoadSkillTool( (names) => @@ -157,7 +191,8 @@ export class McpProtocolService { }, ), inputSchema: zodSchema(loadSkillInputSchema), - }, + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, }; } diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-tool-executor.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-tool-executor.service.ts index 59e3ce52eb..0078b47b65 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-tool-executor.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-tool-executor.service.ts @@ -8,8 +8,18 @@ import { MCP_PROGRESS_NOTIFICATION_METHOD, TOOL_CALL_PROGRESS_TOKEN_PREFIX, } from 'src/engine/api/mcp/constants/mcp-progress-notification.const'; +import { type McpToolAnnotations } from 'src/engine/api/mcp/types/mcp-tool-annotations.type'; import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util'; +type McpToolDefinition = ToolSet[string] & { + annotations?: McpToolAnnotations; +}; + +const unwrapJsonSchema = (schema: unknown) => + schema && typeof schema === 'object' && 'jsonSchema' in schema + ? schema.jsonSchema + : schema; + @Injectable() export class McpToolExecutorService { async handleToolCall( @@ -76,20 +86,18 @@ export class McpToolExecutorService { const toolsArray = Object.entries(toolSet) .filter(([, def]) => !!def.inputSchema) .map(([name, def]) => { + const toolDefinition = def as McpToolDefinition; // Unwrap the AI SDK's jsonSchema wrapper if present // The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly - const inputSchema = def.inputSchema; - const unwrappedSchema = - inputSchema && - typeof inputSchema === 'object' && - 'jsonSchema' in inputSchema - ? inputSchema.jsonSchema - : inputSchema; + const inputSchema = unwrapJsonSchema(toolDefinition.inputSchema); return { name, - description: def.description, - inputSchema: unwrappedSchema, + description: toolDefinition.description, + inputSchema, + ...(isDefined(toolDefinition.annotations) && { + annotations: toolDefinition.annotations, + }), }; }); diff --git a/packages/twenty-server/src/engine/api/mcp/types/mcp-tool-annotations.type.ts b/packages/twenty-server/src/engine/api/mcp/types/mcp-tool-annotations.type.ts new file mode 100644 index 0000000000..80c8751307 --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/types/mcp-tool-annotations.type.ts @@ -0,0 +1,5 @@ +export type McpToolAnnotations = { + readOnlyHint: boolean; + openWorldHint: boolean; + destructiveHint: boolean; +};