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.
This commit is contained in:
Thomas des Francs
2026-05-18 14:14:34 +02:00
committed by GitHub
parent 5c8ddb0c12
commit d5e65c563e
8 changed files with 104 additions and 15 deletions
@@ -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,
};
@@ -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,
};
@@ -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,
};
@@ -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],
}),
]),
),
@@ -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,
},
],
},
@@ -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<string, McpToolAnnotations> = {
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,
};
}
@@ -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,
}),
};
});
@@ -0,0 +1,5 @@
export type McpToolAnnotations = {
readOnlyHint: boolean;
openWorldHint: boolean;
destructiveHint: boolean;
};