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:
committed by
GitHub
parent
5c8ddb0c12
commit
d5e65c563e
+7
@@ -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,
|
||||
};
|
||||
+7
@@ -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,
|
||||
};
|
||||
+7
@@ -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,
|
||||
};
|
||||
+18
-1
@@ -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],
|
||||
}),
|
||||
]),
|
||||
),
|
||||
|
||||
+3
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user