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:
@@ -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.';
|
||||
+2
-3
@@ -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,
|
||||
],
|
||||
|
||||
+33
-11
@@ -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
-10
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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';
|
||||
|
||||
+3
-5
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-4
@@ -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;
|
||||
|
||||
+2
-31
@@ -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."""
|
||||
|
||||
+18
@@ -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));
|
||||
};
|
||||
+1
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user