From dd0039ca1cf8400c7c34d4a6e0090e841d8f7812 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:58:32 +0200 Subject: [PATCH] feat(mcp) - optimize instruction prompt and hide get_tool_catalog (#21183) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace-aware initialize.instructions - Deleted the static mcp-server-instructions.const.ts - Created build-mcp-server-instructions.util.ts — a comprehensive system prompt with identity, object list, tool grammar, routing decision tree, intent mapping, skills vs tools, safety constraints, and data efficiency guidelines - Created McpInstructionBuilderService — fetches workspace-specific object names + skill names and injects them into the instructions Hide/deprecate get_tool_catalog Benefit : skip first MCP call (tools are included in instruction) --- .../twenty-record-presentation/SKILL.md | 2 +- .../use-twenty-mcp/result-formatting.md | 2 +- .../references/use-twenty-mcp/setup.md | 2 +- .../user-guide/ai/capabilities/mcp.mdx | 7 +- .../mcp-server-instructions.const.ts | 2 - .../__tests__/mcp-core.controller.spec.ts | 5 +- .../src/engine/api/mcp/mcp.module.ts | 4 ++ .../__tests__/mcp-protocol.service.spec.ts | 44 +++++++++--- .../mcp-instruction-builder.service.ts | 40 +++++++++++ .../api/mcp/services/mcp-protocol.service.ts | 67 +++++++++++++----- .../tools/list-object-metadata-names.tool.ts | 43 ++++++++++++ .../engine/api/mcp/tools/list-skills.tool.ts | 34 +++++++++ .../build-mcp-server-instructions.util.ts | 70 +++++++++++++++++++ .../providers/database-tool.provider.ts | 14 ++-- .../services/tool-executor.service.ts | 6 +- .../tool-provider/tools/execute-tool.tool.ts | 8 +-- .../tool-provider/tools/learn-tools.tool.ts | 6 +- .../twenty-mcp-helper.const.ts | 33 +-------- ...et-database-crud-tool-flat-objects.util.ts | 18 +++++ .../suites/mcp.controller.integration-spec.ts | 2 +- 20 files changed, 313 insertions(+), 96 deletions(-) delete mode 100644 packages/twenty-server/src/engine/api/mcp/constants/mcp-server-instructions.const.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/services/mcp-instruction-builder.service.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/tools/list-object-metadata-names.tool.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/tools/list-skills.tool.ts create mode 100644 packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util.ts diff --git a/packages/twenty-claude-skills/skills/twenty-record-presentation/SKILL.md b/packages/twenty-claude-skills/skills/twenty-record-presentation/SKILL.md index 11b66e97bf..baf732b161 100644 --- a/packages/twenty-claude-skills/skills/twenty-record-presentation/SKILL.md +++ b/packages/twenty-claude-skills/skills/twenty-record-presentation/SKILL.md @@ -13,7 +13,7 @@ Retrieve the Twenty records needed to answer the user's question, then present t Use the selected connected Twenty MCP server when it is available -- `get_tool_catalog` → `learn_tools` → `execute_tool` +- `learn_tools` → `execute_tool` - Discover the relevant object, fields, filters, and sort options instead of guessing exact API names. - Retrieve only the fields needed for the answer, plus the fields needed for ordering or disambiguation. - For "latest", "most recent", or "recent" requests, include the relevant timestamp field used for sorting. diff --git a/packages/twenty-codex-plugin/references/use-twenty-mcp/result-formatting.md b/packages/twenty-codex-plugin/references/use-twenty-mcp/result-formatting.md index d7e48f3e24..68046b900e 100644 --- a/packages/twenty-codex-plugin/references/use-twenty-mcp/result-formatting.md +++ b/packages/twenty-codex-plugin/references/use-twenty-mcp/result-formatting.md @@ -17,7 +17,7 @@ Resolve the intended workspace before selecting a Twenty MCP server: Use the selected connected Twenty MCP server when it is available: ```text -get_tool_catalog -> learn_tools -> execute_tool +learn_tools -> execute_tool ``` - Discover object, field, filter, and sort names before querying. diff --git a/packages/twenty-codex-plugin/references/use-twenty-mcp/setup.md b/packages/twenty-codex-plugin/references/use-twenty-mcp/setup.md index d0033ed3e5..f24b85b4cb 100644 --- a/packages/twenty-codex-plugin/references/use-twenty-mcp/setup.md +++ b/packages/twenty-codex-plugin/references/use-twenty-mcp/setup.md @@ -110,7 +110,7 @@ codex mcp get When connected, use the Twenty MCP discovery flow: ```text -get_tool_catalog -> learn_tools -> execute_tool +learn_tools -> execute_tool ``` ## Troubleshooting diff --git a/packages/twenty-docs/user-guide/ai/capabilities/mcp.mdx b/packages/twenty-docs/user-guide/ai/capabilities/mcp.mdx index 4867dbb0fa..2ec645a54f 100644 --- a/packages/twenty-docs/user-guide/ai/capabilities/mcp.mdx +++ b/packages/twenty-docs/user-guide/ai/capabilities/mcp.mdx @@ -103,11 +103,10 @@ Ask your AI assistant to interact with your CRM: Once connected, the MCP server exposes tools that mirror the Twenty API. The recommended workflow is: -1. **`get_tool_catalog`** — discover all available tools -2. **`learn_tools`** — get the input schema for specific tools -3. **`execute_tool`** — run a tool +1. **`learn_tools`** — get the input schema for specific tools +2. **`execute_tool`** — run a tool -You don't need to remember tool names. Ask your AI assistant what it can do and it will call `get_tool_catalog` automatically. +You don't need to remember tool names. Ask your AI assistant what it can do and it will call `learn_tools` automatically. ## Permissions diff --git a/packages/twenty-server/src/engine/api/mcp/constants/mcp-server-instructions.const.ts b/packages/twenty-server/src/engine/api/mcp/constants/mcp-server-instructions.const.ts deleted file mode 100644 index 3899aa550c..0000000000 --- a/packages/twenty-server/src/engine/api/mcp/constants/mcp-server-instructions.const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const MCP_SERVER_INSTRUCTIONS = - 'Twenty CRM MCP Server. Follow this workflow: (1) get_tool_catalog to discover tools, (2) learn_tools to get input schemas, (3) execute_tool to run them. Never guess tool names — always start with get_tool_catalog. Use load_skills for guidance on complex tasks like workflow or dashboard building. For comparative/grouped analytics (by/per/top/most/least/average/total/ranking), use group_by tools, and use find tools for record retrieval; for multiple metrics, run multiple group_by calls and merge results.'; diff --git a/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts b/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts index 966c4d278a..a5f64deb7c 100644 --- a/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/controllers/__tests__/mcp-core.controller.spec.ts @@ -4,7 +4,6 @@ import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function'; 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 { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller'; import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc'; import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard'; @@ -146,7 +145,7 @@ describe('McpCoreController', () => { prompts: { listChanged: false }, }, serverInfo: MCP_SERVER_INFO, - instructions: MCP_SERVER_INSTRUCTIONS, + instructions: 'mock instructions', }, }; @@ -245,7 +244,7 @@ describe('McpCoreController', () => { const mockRequest: JsonRpc = { jsonrpc: '2.0', method: 'tools/call', - params: { name: 'get_tool_catalog', arguments: {} }, + params: { name: 'execute_tool', arguments: {} }, id: '456', }; diff --git a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts index 382478fd6a..650c770107 100644 --- a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts +++ b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller'; import { McpAuthGuard } from 'src/engine/api/mcp/guards/mcp-auth.guard'; +import { McpInstructionBuilderService } from 'src/engine/api/mcp/services/mcp-instruction-builder.service'; import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service'; import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; @@ -11,6 +12,7 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module'; import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -21,6 +23,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ MetricsModule, TokenModule, WorkspaceCacheStorageModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, UserRoleModule, ToolProviderModule, SkillModule, @@ -32,6 +35,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ JwtAuthGuard, McpAuthGuard, WorkspaceAuthGuard, + McpInstructionBuilderService, McpProtocolService, McpToolExecutorService, ], 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 b0d9fe8f5b..801a5f9d31 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 @@ -7,19 +7,21 @@ import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-e 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 { McpInstructionBuilderService } from 'src/engine/api/mcp/services/mcp-instruction-builder.service'; import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service'; import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; +import { LIST_OBJECT_METADATA_NAMES_TOOL_NAME } from 'src/engine/api/mcp/tools/list-object-metadata-names.tool'; +import { LIST_SKILLS_TOOL_NAME } from 'src/engine/api/mcp/tools/list-skills.tool'; 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'; -import { GET_TOOL_CATALOG_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool'; import { LEARN_TOOLS_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool'; import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools/load-skill.tool'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -40,10 +42,11 @@ describe('McpProtocolService', () => { } as FlatApiKey; const EXPECTED_MCP_TOOL_NAMES = [ - GET_TOOL_CATALOG_TOOL_NAME, LEARN_TOOLS_TOOL_NAME, EXECUTE_TOOL_TOOL_NAME, LOAD_SKILL_TOOL_NAME, + LIST_OBJECT_METADATA_NAMES_TOOL_NAME, + LIST_SKILLS_TOOL_NAME, 'search_help_center', ] as const; @@ -51,10 +54,12 @@ describe('McpProtocolService', () => { (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, + [LIST_OBJECT_METADATA_NAMES_TOOL_NAME]: + MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + [LIST_SKILLS_TOOL_NAME]: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, search_help_center: MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS, }; @@ -103,6 +108,20 @@ describe('McpProtocolService', () => { findAllFlatSkills: jest.fn().mockResolvedValue([]), }, }, + { + provide: McpInstructionBuilderService, + useValue: { + buildInstructions: jest.fn().mockResolvedValue('mock instructions'), + }, + }, + { + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: { + getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({ + flatObjectMetadataMaps: { byUniversalIdentifier: {} }, + }), + }, + }, ], }).compile(); @@ -118,9 +137,12 @@ describe('McpProtocolService', () => { }); describe('handleInitialize', () => { - it('should return spec-compliant initialization response', () => { + it('should return spec-compliant initialization response', async () => { const requestId = '123'; - const result = service.handleInitialize(requestId); + const result = await service.handleInitialize( + requestId, + mockWorkspace.id, + ); expect(result).toEqual({ id: requestId, @@ -133,7 +155,7 @@ describe('McpProtocolService', () => { prompts: { listChanged: false }, }, serverInfo: MCP_SERVER_INFO, - instructions: MCP_SERVER_INSTRUCTIONS, + instructions: expect.any(String), }, }); }); @@ -210,7 +232,7 @@ describe('McpProtocolService', () => { prompts: { listChanged: false }, }, serverInfo: MCP_SERVER_INFO, - instructions: MCP_SERVER_INSTRUCTIONS, + instructions: expect.any(String), }, }); }); @@ -230,7 +252,7 @@ describe('McpProtocolService', () => { expect(result).toBeNull(); }); - it('should build a ToolSet with exactly 5 tools and pass it to executor for tools/call', async () => { + it('should build a ToolSet with exactly 6 tools and pass it to executor for tools/call', async () => { userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId); const mockToolCallResponse = { @@ -281,7 +303,7 @@ describe('McpProtocolService', () => { ); }); - it('should build a ToolSet with exactly 5 tools and pass it to executor for tools/list', async () => { + it('should build a ToolSet with exactly 6 tools and pass it to executor for tools/list', async () => { userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId); mcpToolExecutorService.handleToolsListing.mockReturnValue({ @@ -397,7 +419,7 @@ describe('McpProtocolService', () => { const mockRequest: JsonRpc = { jsonrpc: '2.0', method: 'tools/call', - params: { name: 'get_tool_catalog', arguments: {} }, + params: { name: 'execute_tool', arguments: {} }, id: '123', }; diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-instruction-builder.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-instruction-builder.service.ts new file mode 100644 index 0000000000..909afcf019 --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-instruction-builder.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { camelToSnakeCase } from 'twenty-shared/utils'; + +import { buildMcpServerInstructions } from 'src/engine/api/mcp/utils/build-mcp-server-instructions.util'; +import { getDatabaseCrudToolFlatObjects } from 'src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; + +@Injectable() +export class McpInstructionBuilderService { + constructor( + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly skillService: SkillService, + ) {} + + async buildInstructions(workspaceId: string): Promise { + const [{ flatObjectMetadataMaps }, allSkills] = await Promise.all([ + this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({ + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }), + this.skillService.findAllFlatSkills(workspaceId), + ]); + + const objectNames = getDatabaseCrudToolFlatObjects( + flatObjectMetadataMaps.byUniversalIdentifier, + ) + .map((obj) => camelToSnakeCase(obj.namePlural)) + .sort() + .join(', '); + + const skillNames = + allSkills.length > 0 + ? allSkills.map((skill) => skill.name).join(', ') + : undefined; + + return buildMcpServerInstructions(objectNames, skillNames); + } +} 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 b43c2b8251..875852ea40 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 @@ -10,17 +10,32 @@ import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-e 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 { McpInstructionBuilderService } from 'src/engine/api/mcp/services/mcp-instruction-builder.service'; import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; +import { + createListObjectMetadataNamesTool, + LIST_OBJECT_METADATA_NAMES_TOOL_NAME, + listObjectMetadataNamesInputSchema, +} from 'src/engine/api/mcp/tools/list-object-metadata-names.tool'; +import { + createListSkillsTool, + LIST_SKILLS_TOOL_NAME, + listSkillsInputSchema, +} from 'src/engine/api/mcp/tools/list-skills.tool'; 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'; +import { type FlatApiKey } from 'src/engine/core-modules/api-key/types/flat-api-key.type'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util'; import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { + createLearnToolsTool, + LEARN_TOOLS_TOOL_NAME, + learnToolsInputSchema, +} from 'src/engine/core-modules/tool-provider/tools'; import { createExecuteToolTool, EXECUTE_TOOL_TOOL_NAME, @@ -31,17 +46,13 @@ import { GET_TOOL_CATALOG_TOOL_NAME, getToolCatalogInputSchema, } from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.tool'; -import { - createLearnToolsTool, - LEARN_TOOLS_TOOL_NAME, - learnToolsInputSchema, -} from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool'; import { createLoadSkillTool, LOAD_SKILL_TOOL_NAME, loadSkillInputSchema, } from 'src/engine/core-modules/tool-provider/tools/load-skill.tool'; import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -80,9 +91,14 @@ export class McpProtocolService { private readonly mcpToolExecutorService: McpToolExecutorService, private readonly apiKeyRoleService: ApiKeyRoleService, private readonly skillService: SkillService, + private readonly mcpInstructionBuilderService: McpInstructionBuilderService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, ) {} - handleInitialize(requestId: string | number) { + async handleInitialize(requestId: string | number, workspaceId: string) { + const instructions = + await this.mcpInstructionBuilderService.buildInstructions(workspaceId); + return wrapJsonRpcResponse(requestId, { result: { protocolVersion: MCP_PROTOCOL_VERSION, @@ -92,7 +108,7 @@ export class McpProtocolService { prompts: { listChanged: false }, }, serverInfo: MCP_SERVER_INFO, - instructions: MCP_SERVER_INSTRUCTIONS, + instructions, }, }); } @@ -162,15 +178,6 @@ export class McpProtocolService { inputSchema: zodSchema(getToolCatalogInputSchema), annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, } as McpAnnotatedTool, - [LEARN_TOOLS_TOOL_NAME]: { - ...createLearnToolsTool( - this.toolRegistry, - toolContext, - 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, @@ -193,6 +200,28 @@ export class McpProtocolService { inputSchema: zodSchema(loadSkillInputSchema), annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, } as McpAnnotatedTool, + [LIST_OBJECT_METADATA_NAMES_TOOL_NAME]: { + ...createListObjectMetadataNamesTool( + this.flatEntityMapsCacheService, + workspace.id, + ), + inputSchema: zodSchema(listObjectMetadataNamesInputSchema), + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, + [LIST_SKILLS_TOOL_NAME]: { + ...createListSkillsTool(this.skillService, workspace.id), + inputSchema: zodSchema(listSkillsInputSchema), + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, + [LEARN_TOOLS_TOOL_NAME]: { + ...createLearnToolsTool( + this.toolRegistry, + toolContext, + MCP_EXCLUDED_TOOL_NAMES, + ), + inputSchema: zodSchema(learnToolsInputSchema), + annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS, + } as McpAnnotatedTool, }; } @@ -219,7 +248,7 @@ export class McpProtocolService { } if (method === 'initialize') { - return this.handleInitialize(id); + return this.handleInitialize(id, workspace.id); } if (method === 'ping') { diff --git a/packages/twenty-server/src/engine/api/mcp/tools/list-object-metadata-names.tool.ts b/packages/twenty-server/src/engine/api/mcp/tools/list-object-metadata-names.tool.ts new file mode 100644 index 0000000000..5a637d6305 --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/tools/list-object-metadata-names.tool.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; + +import { camelToSnakeCase } from 'twenty-shared/utils'; + +import { getDatabaseCrudToolFlatObjects } from 'src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util'; +import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; + +export const LIST_OBJECT_METADATA_NAMES_TOOL_NAME = + 'list_object_metadata_names'; + +export const listObjectMetadataNamesInputSchema = z.object({}); + +export type ListObjectMetadataNamesResult = { + objectNames: string[]; + message: string; +}; + +export const createListObjectMetadataNamesTool = ( + flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + workspaceId: string, +) => ({ + description: + 'List all available object metadata names in the workspace. Use this to get a fresh list of objects when the initial instructions may be outdated.', + inputSchema: listObjectMetadataNamesInputSchema, + execute: async (): Promise => { + const { flatObjectMetadataMaps } = + await flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({ + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }); + + const objectNames = getDatabaseCrudToolFlatObjects( + flatObjectMetadataMaps.byUniversalIdentifier, + ) + .map((obj) => camelToSnakeCase(obj.namePlural)) + .sort(); + + return { + objectNames, + message: `Found ${objectNames.length} object(s): ${objectNames.join(', ')}.`, + }; + }, +}); diff --git a/packages/twenty-server/src/engine/api/mcp/tools/list-skills.tool.ts b/packages/twenty-server/src/engine/api/mcp/tools/list-skills.tool.ts new file mode 100644 index 0000000000..df5d69f5da --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/tools/list-skills.tool.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { type SkillService } from 'src/engine/metadata-modules/skill/skill.service'; + +export const LIST_SKILLS_TOOL_NAME = 'list_skills'; + +export const listSkillsInputSchema = z.object({}); + +export type ListSkillsResult = { + skillNames: string[]; + message: string; +}; + +export const createListSkillsTool = ( + skillService: SkillService, + workspaceId: string, +) => ({ + description: + 'List all available skill names in the workspace. Use this to get a fresh list of skills when the initial instructions may be outdated.', + inputSchema: listSkillsInputSchema, + execute: async (): Promise => { + const allSkills = await skillService.findAllFlatSkills(workspaceId); + + const skillNames = allSkills.map((skill) => skill.name); + + return { + skillNames, + message: + skillNames.length > 0 + ? `Found ${skillNames.length} skill(s): ${skillNames.join(', ')}.` + : 'No skills are currently available in this workspace.', + }; + }, +}); diff --git a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts new file mode 100644 index 0000000000..40c30075b0 --- /dev/null +++ b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts @@ -0,0 +1,70 @@ +export const buildMcpServerInstructions = ( + objectNames: string, + skillNames?: string, +): string => + [ + `You are an AI assistant for a Twenty CRM workspace.`, + `Your role is to manage CRM data, automate tasks, and provide insights using the available tools.`, + ``, + `Available objects: ${objectNames}.`, + ``, + `Key relations:`, + ` person.companyId → company`, + ` note / task / activity are attachable to any object via targetRecordId`, + ``, + `Meta-tools (always available):`, + ` execute_tool(toolName, arguments) — execute any CRUD or action tool by name`, + ` learn_tools(toolNames) — fetch input schema for specific tools before calling them`, + ` load_skills(skillNames) — load step-by-step instructions for complex tasks`, + ``, + ...(skillNames ? [`Available skills: ${skillNames}.`, ``] : []), + `CRUD tool name grammar — construct names directly without prior discovery:`, + ` Read: find_{objects} | find_one_{object} | group_by_{objects}`, + ` Write: create_{object} | create_many_{objects} | update_{object} | update_many_{objects} | delete_{object}`, + ``, + `Non-CRUD tools — use learn_tools for schemas:`, + ` ACTION: http_request | send_email | draft_email | navigate_app | code_interpreter | search_help_center`, + ` WORKFLOW: create_complete_workflow | create/update/delete_workflow_version_step | activate/deactivate_workflow_version`, + ` METADATA: get/create/update/delete_object_metadata | get/create/update/delete_field_metadata`, + ` VIEW: get_views | get_view_query_parameters | create/update/delete_view | manage view fields, filters, sorts`, + ` DASHBOARD: list_dashboards | get_dashboard | create_complete_dashboard | add/update/delete_dashboard_widget`, + ` WEBHOOK: list/create/update/delete_webhook`, + ` NAVIGATION: list/create/update/delete_navigation_menu_item`, + ` LOGIC_FUNCTION: app_{function_name} — workspace-specific; use list_logic_function_tools to discover`, + ``, + `Skills vs Tools:`, + ` Skills = documentation (load_skills) — teach HOW to do something, correct schemas and patterns`, + ` Tools = execution (execute_tool) — let you DO something`, + ` For complex tasks (workflows, dashboards, metadata), load the matching skill BEFORE calling tools.`, + ` ⚠️ Never call workflow, dashboard, or metadata tools without loading their skill first.`, + ``, + `Route by intent:`, + ` Named entity ("Acme company") → find_{objects} to resolve id first, then operate on id`, + ` Retrieve records → find_{objects} (default limit: 10, always report total count)`, + ` Single record by id → find_one_{object}`, + ` Analytics / grouped metrics → group_by_{objects} (COUNT, SUM, AVG, MIN, MAX)`, + ` Multiple metrics → run parallel group_by calls, merge results`, + ` Non-CRUD task → use tool name from the list above + learn_tools for schema`, + ``, + `Execution rules:`, + ` Independent tool calls → run in parallel`, + ` Dependent operations → run sequentially (find-then-mutate)`, + ``, + `Data efficiency:`, + ` Default limit: 10. Only increase if user explicitly needs more.`, + ` Always apply filters to narrow results — don't fetch all records of a type.`, + ` Use batch tools (create_many_*, update_many_*) instead of looping single-item calls.`, + ``, + `Destructive operations (update_many, delete):`, + ` 1. Run find_{objects} with the same filter — state record count to user`, + ` 2. Wait for explicit confirmation before executing`, + ``, + `Twenty primitives:`, + ` Favorites are navigation menu items. To favorite something, call create_navigation_menu_item with scope: 'user'.`, + ` A default OBJECT navigation item is auto-created with create_object_metadata — do not add another.`, + ` http_request is ONLY for external third-party APIs, never for Twenty's own data.`, + ``, + `On tool failure: read the error message, do not retry silently, report to user.`, + `Present results as readable summaries, not raw JSON.`, + `For large result sets, show count + first N records and offer to paginate.`, + ].join('\n'); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index b5ba80853c..78e7ab513a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -4,7 +4,7 @@ import { type ObjectsPermissions, type ObjectsPermissionsByRoleId, } from 'twenty-shared/types'; -import { camelToSnakeCase, isDefined } from 'twenty-shared/utils'; +import { camelToSnakeCase } from 'twenty-shared/utils'; import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow'; import { z } from 'zod'; @@ -27,7 +27,7 @@ import { import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; +import { getDatabaseCrudToolFlatObjects } from 'src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -90,17 +90,11 @@ export class DatabaseToolProvider implements ToolProvider { }, ); - const allFlatObjects = Object.values( + const allFlatObjects = getDatabaseCrudToolFlatObjects( flatObjectMetadataMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((obj) => obj.isActive); + ); for (const flatObject of allFlatObjects) { - if (isWorkflowRelatedObject(flatObject)) { - continue; - } - const permission = objectPermissions[flatObject.id]; if (!permission) { diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts index 635382b00a..542dd12154 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts @@ -7,9 +7,9 @@ import { Repository } from 'typeorm'; import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; -import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; +import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util'; +import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type'; import { AuthException, @@ -23,9 +23,9 @@ import { CreateRecordService } from 'src/engine/core-modules/record-crud/service import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; import { GroupByRecordsService } from 'src/engine/core-modules/record-crud/services/group-by-records.service'; -import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type'; import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service'; import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type'; import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts index 73da25fb34..7d7ef67bc5 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts @@ -9,9 +9,7 @@ import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output. export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool'; const executeToolInputZodSchema = z.object({ - toolName: z - .string() - .describe('Exact tool name from get_tool_catalog. Do not guess.'), + toolName: z.string().describe('Exact tool name. Do not guess.'), arguments: z .record(z.string(), z.unknown()) .describe('Arguments matching the schema returned by learn_tools.'), @@ -53,7 +51,7 @@ export const createExecuteToolTool = ( }, ) => ({ description: - 'STEP 3: Execute a tool by name with arguments. You MUST call get_tool_catalog (step 1) and learn_tools (step 2) first to discover the tool name and its required input schema.', + 'Execute a tool by name with arguments. Call learn_tools first to discover the required input schema.', inputSchema: executeToolInputSchema, execute: async (parameters: ExecuteToolInput): Promise => { const { toolName, arguments: args = {} } = parameters; @@ -62,7 +60,7 @@ export const createExecuteToolTool = ( return { success: false, message: `Tool "${toolName}" is not available`, - error: `Tool "${toolName}" is not available in this context. Use get_tool_catalog to see which tools are available.`, + error: `Tool "${toolName}" is not available in this context. Use learn_tools to discover available tools.`, }; } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts index 0fef395a8f..3efdd975a1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts @@ -12,9 +12,7 @@ export type LearnToolsAspect = z.infer; export const learnToolsInputSchema = z.object({ toolNames: z .array(z.string()) - .describe( - 'Exact tool names from get_tool_catalog. Do not guess tool names.', - ), + .describe('Exact tool names. Do not guess tool names.'), aspects: z .array(learnToolsAspectSchema) .optional() @@ -42,7 +40,7 @@ export const createLearnToolsTool = ( excludeTools?: Set, ) => ({ description: - 'STEP 2: Get input schemas for tools discovered via get_tool_catalog. Call this with exact tool names to learn the required arguments before calling execute_tool.', + 'Get input schemas for tools. Call this with exact tool names to learn the required arguments before calling execute_tool.', inputSchema: learnToolsInputSchema, execute: async (parameters: LearnToolsInput): Promise => { const { toolNames, aspects } = parameters; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts index ced7db2e37..6df43bf0c0 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts @@ -14,8 +14,8 @@ class TwentyMCP: Two categories of tools exist behind /mcp: - - MCP-native: execute_tool, learn_tools, load_skills, get_tool_catalog, - search_help_center. These are the 5 surfaces exposed directly. + - MCP-native: execute_tool, learn_tools, load_skills, + search_help_center. These are the 4 surfaces exposed directly. - Workspace catalog: 250+ CRUD / view / workflow / dashboard tools like find_companies, create_person, update_opportunity. These are @@ -30,7 +30,6 @@ class TwentyMCP: 'execute_tool', 'learn_tools', 'load_skills', - 'get_tool_catalog', 'search_help_center', }) @@ -86,34 +85,6 @@ class TwentyMCP: return wrapped['result'] return wrapped - def list_tools(self): - """ - List all workspace catalog tools (250+), not the 5 MCP meta-tools. - - Use call_tool(name, args) to invoke any of them — routing is - handled for you. - - Returns: - Flat list of tool entries, each with name, description, and - category. get_tool_catalog groups by category internally; we - flatten for ergonomics. - """ - catalog = self.call_tool('get_tool_catalog', {}) - # get_tool_catalog returns { 'catalog': { '': [tools...] } } - # Flatten to a single list; preserve the category as a per-tool field - # so the docstring's promise holds and consumers don't have to re-call - # the catalog. Leave untouched if the shape is unexpected. - if isinstance(catalog, dict): - grouped = catalog.get('catalog', catalog) - if isinstance(grouped, dict): - return [ - ({**tool, 'category': category} - if isinstance(tool, dict) and 'category' not in tool - else tool) - for category, tools in grouped.items() - for tool in tools - ] - return catalog def _raw_mcp_call(self, name: str, arguments: dict = None): """Low-level: issue a tools/call against the MCP surface verbatim.""" diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util.ts new file mode 100644 index 0000000000..835b061296 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/get-database-crud-tool-flat-objects.util.ts @@ -0,0 +1,18 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; + +type FlatObjectWithActivityAndIdentifier = { + isActive: boolean; + universalIdentifier: string; +}; + +export const getDatabaseCrudToolFlatObjects = < + T extends FlatObjectWithActivityAndIdentifier, +>( + byUniversalIdentifier: Partial>, +): T[] => { + return Object.values(byUniversalIdentifier) + .filter(isDefined) + .filter((obj) => obj.isActive && !isWorkflowRelatedObject(obj)); +}; diff --git a/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts b/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts index c08529c6e0..c3ee868bbe 100644 --- a/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts +++ b/packages/twenty-server/test/integration/ai/suites/mcp.controller.integration-spec.ts @@ -263,7 +263,7 @@ describe('MCP Controller (integration)', () => { jsonrpc: '2.0', method: 'tools/call', id: 'sse-tool-1', - params: { name: 'get_tool_catalog', arguments: {} }, + params: { name: 'learn_tools', arguments: { toolNames: [] } }, }, API_KEY_ACCESS_TOKEN, 'application/json, text/event-stream',