feat(mcp) - optimize instruction prompt and hide get_tool_catalog (#21183)

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)
This commit is contained in:
Etienne
2026-06-03 18:58:32 +02:00
committed by GitHub
parent 4b15b949f3
commit dd0039ca1c
20 changed files with 313 additions and 96 deletions
@@ -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.
@@ -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.
@@ -110,7 +110,7 @@ codex mcp get <server-name>
When connected, use the Twenty MCP discovery flow:
```text
get_tool_catalog -> learn_tools -> execute_tool
learn_tools -> execute_tool
```
## Troubleshooting
@@ -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
@@ -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.';
@@ -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',
};
@@ -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,
],
@@ -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',
};
@@ -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<string> {
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);
}
}
@@ -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') {
@@ -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<ListObjectMetadataNamesResult> => {
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(', ')}.`,
};
},
});
@@ -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<ListSkillsResult> => {
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.',
};
},
});
@@ -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');
@@ -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) {
@@ -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';
@@ -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<ToolOutput> => {
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.`,
};
}
@@ -12,9 +12,7 @@ export type LearnToolsAspect = z.infer<typeof learnToolsAspectSchema>;
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<string>,
) => ({
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<LearnToolsResult> => {
const { toolNames, aspects } = parameters;
@@ -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': { '<category>': [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."""
@@ -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<Record<string, T>>,
): T[] => {
return Object.values(byUniversalIdentifier)
.filter(isDefined)
.filter((obj) => obj.isActive && !isWorkflowRelatedObject(obj));
};
@@ -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',