Files
twenty/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts
T
Etienne 5ca41d55fb feat(ai): humanize tool-call (#21976)
# Humanize tool-call labels

cc: https://github.com/twentyhq/twenty/pull/21462

## Preview
<img width="459" height="156" alt="Screenshot 2026-06-22 at 19 13 11"
src="https://github.com/user-attachments/assets/e7a2f5f5-cd09-4ec6-920b-5eb16b98285c"
/>
<img width="461" height="156" alt="Screenshot 2026-06-22 at 19 14 54"
src="https://github.com/user-attachments/assets/c2114d2e-2aa8-499a-9801-68e3bb7c45f8"
/>
<img width="461" height="505" alt="Screenshot 2026-06-22 at 19 15 01"
src="https://github.com/user-attachments/assets/ee9ca5d0-8e79-4c63-a2ff-ed5e359a9a9c"
/>

## Why

In the AI chat, tool steps were displayed using raw tool identifiers
(`find_many_companies`, `create_one_task`, `send_email`...) and labels
were partially reconstructed/humanized on the frontend. This was hard to
localize and inconsistent across tool categories.

This PR makes the **backend the single source of truth for
human-readable, localized tool labels**, exposes them through
`getToolIndex`, and reduces the frontend to a thin resolver that picks
the right label for the current status (in-progress / completed).

## What changed

### Backend

- `ToolIndexEntry` (and the `getToolIndex` GraphQL DTO) now carry
`label`, `inProgressLabel?`, `completedLabel?`.
- New `getCrudToolLabels(operation, objectLabel, i18nService, locale)`
builds CRUD labels from a verb table (Search / Find / Group / Create /
Update / Upsert / Delete × imperative / in-progress / completed) + the
(translated, lowercased) object label.
- New `translate-tool-label.util.ts` translates a source label via
`I18nService` (`generateMessageId` → fallback to source when no
translation exists).
- Action tools: labels extracted to the `ACTION_TOOL_LABELS` constant
(`msg` + `i18nLabel`) and translated in
`ActionToolProvider.buildDescriptor`.
- Logic-function tools use the function name as label;
`toolSetToDescriptors` (workflow / view / metadata / dashboard) accepts
an optional `labels` map and falls back to a humanized tool name.
- Labels are localized server-side using the request locale
(`@RequestLocale` → `buildToolIndex` → `context.locale`, threaded
through `ToolContext` / `ToolProviderContext`).
- `code_interpreter` schema now asks the model for `loadingMessage`
(present tense) and `completedMessage` (past tense), so its status text
is model-generated.
- Removed the old generic `loadingMessage` injection mechanism
(`wrap-tool-for-execution.util.ts` deleted; `wrapJsonSchemaForExecution`
/ `stripLoadingMessage` no longer wrap every tool).

### Frontend

- New `useToolLabelMap()` hook builds a `Map<name, { label,
inProgressLabel, completedLabel }>` from `getToolIndex`.
- `getToolDisplayMessage` → `resolveToolDisplayMessage({ input,
toolName, isFinished, labelMap, output })`: a small resolver registry
keyed by tool name (`execute_tool`, `web_search`, `learn_tools`,
`load_skills`, `code_interpreter`, default).
- Default resolver prefers backend `completedLabel` / `inProgressLabel`,
falling back to `Ran X` / `Running X`.
- `learn_tools` / `load_skills` resolve their inner tool/skill names to
labels (label map → tool output labels via `getToolOutputLabelEntries` →
raw name).
- `code_interpreter` step is now expandable to show the code even while
running.

## How tool labelling flows (BE → FE)

```text
BACKEND
┌───────────────────────────────────────────────────────────────────────────┐
│ Tool providers (per category) → ToolIndexEntry                              │
│                                                                             │
│  DatabaseToolProvider                                                       │
│    getCrudToolLabels(operation, object.labelPlural/Singular, i18n, locale)  │
│      verb table (Search/Create/Update/Delete…) + translateToolLabel(object) │
│      → { label, inProgressLabel, completedLabel }                           │
│                                                                             │
│  ActionToolProvider                                                         │
│    ACTION_TOOL_LABELS[toolId] (msg) → translateToolLabel(…, locale)         │
│      → { label, inProgressLabel?, completedLabel? }                         │
│                                                                             │
│  LogicFunctionToolProvider   → label = logicFunction.name                   │
│  toolSetToDescriptors        → label = labels[name] ?? humanize(name)       │
│  (workflow / view / metadata / dashboard)                                   │
└───────────────────────────────────────────────────────────────────────────┘
            │ 
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ GraphQL  Query getToolIndex : [ToolIndexEntry]                              │
│   { name, label, inProgressLabel, completedLabel, description,              │
│     category, objectName, icon }                                            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
FRONTEND ─ resolve the right label for the current status
┌───────────────────────────────────────────────────────────────────────────┐
│ useGetToolIndex() → useToolLabelMap()                                       │
│   Map<name, { label, inProgressLabel?, completedLabel? }>                   │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ resolveToolDisplayMessage({ input, toolName, isFinished, labelMap, output })│
│                                                                             │
│   TOOL_LABEL_RESOLVERS[toolName] ?? defaultResolver                         │
│   ├─ execute_tool     → unwrap { toolName, arguments } then re-resolve      │
│   ├─ web_search       → "Searching/Searched the web for <query>"           │
│   ├─ learn_tools      → "Learning/Learned <labels>"                         │
│   ├─ load_skills      → "Loading/Loaded <labels>"                           │
│   │     inner names resolved via: labelMap → output labels → raw name       │
│   ├─ code_interpreter → model's loadingMessage / completedMessage           │
│   └─ default          → isFinished                                          │
│                           ? completedLabel ?? "Ran <label>"                 │
│                           : inProgressLabel ?? "Running <label>"            │
└───────────────────────────────────────────────────────────────────────────┘
            │
            ▼
   Rendered by ThinkingStepsDisplay / ToolStepRenderer
```

## Localization notes

- Standard object labels and action/CRUD verbs are translated
server-side via `I18nService` using the requester's locale.
- Custom object labels are not translated unless a workspace custom
translation exists (matched by `generateMessageId`); otherwise the
source label is used as-is.

## Tests

- **FE:** `resolveToolDisplayMessage` / `getToolOutputLabelEntries`
(status selection, inner-name resolution, `code_interpreter` model
labels, fallbacks).
- **BE:** `toolSetToDescriptors` (label map + humanized fallback) and
`database-tool.provider` label generation.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21976?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-24 13:41:09 +02:00

333 lines
11 KiB
TypeScript

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { type ToolSet, zodSchema } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { JSON_RPC_ERROR_CODE } from 'src/engine/api/mcp/constants/json-rpc-error-code.const';
import { MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-closed-world-read-only-tool-annotations.const';
import { MCP_EXCLUDED_TOOL_NAMES } from 'src/engine/api/mcp/constants/mcp-excluded-tool-names.const';
import { MCP_EXECUTE_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-execute-tool-annotations.const';
import { MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS } from 'src/engine/api/mcp/constants/mcp-open-world-read-only-tool-annotations.const';
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { MCP_SERVER_INFO } from 'src/engine/api/mcp/constants/mcp-server-info.const';
import { 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 { 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,
executeToolInputSchema,
} from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import {
createGetToolCatalogTool,
GET_TOOL_CATALOG_TOOL_NAME,
getToolCatalogInputSchema,
} from 'src/engine/core-modules/tool-provider/tools/get-tool-catalog.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';
type McpAnnotatedTool = ToolSet[string] & {
annotations: McpToolAnnotations;
};
const MCP_PRELOADED_TOOL_ANNOTATIONS: Record<string, McpToolAnnotations> = {
search_help_center: MCP_OPEN_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
};
const annotatePreloadedMcpTools = (toolSet: ToolSet): ToolSet =>
Object.fromEntries(
Object.entries(toolSet).map(([name, toolDefinition]) => {
const annotations = MCP_PRELOADED_TOOL_ANNOTATIONS[name];
if (!isDefined(annotations)) {
throw new Error(`Missing MCP annotations for preloaded tool "${name}"`);
}
return [
name,
{
...toolDefinition,
annotations,
} as McpAnnotatedTool,
];
}),
);
@Injectable()
export class McpProtocolService {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly userRoleService: UserRoleService,
private readonly mcpToolExecutorService: McpToolExecutorService,
private readonly apiKeyRoleService: ApiKeyRoleService,
private readonly skillService: SkillService,
private readonly mcpInstructionBuilderService: McpInstructionBuilderService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async handleInitialize(requestId: string | number, workspaceId: string) {
const instructions =
await this.mcpInstructionBuilderService.buildInstructions(workspaceId);
return wrapJsonRpcResponse(requestId, {
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
serverInfo: MCP_SERVER_INFO,
instructions,
},
});
}
async getRoleId(
workspaceId: string,
userWorkspaceId?: string,
apiKey?: FlatApiKey,
) {
if (isDefined(apiKey)) {
return this.apiKeyRoleService.getRoleIdForApiKeyId(
apiKey.id,
workspaceId,
);
}
if (!userWorkspaceId) {
throw new HttpException(
'User workspace ID missing',
HttpStatus.FORBIDDEN,
);
}
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
workspaceId,
userWorkspaceId,
});
if (!roleId) {
throw new HttpException('Role ID missing', HttpStatus.FORBIDDEN);
}
return roleId;
}
private async buildMcpToolSet(
workspace: FlatWorkspace,
roleId: string,
options?: {
authContext?: WorkspaceAuthContext;
userId?: string;
userWorkspaceId?: string;
},
): Promise<ToolSet> {
const toolContext = {
workspaceId: workspace.id,
roleId,
authContext: options?.authContext,
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
};
const preloadedTools = await this.toolRegistry.getToolsByName(
COMMON_PRELOAD_TOOLS,
toolContext,
);
return {
...annotatePreloadedMcpTools(preloadedTools),
[GET_TOOL_CATALOG_TOOL_NAME]: {
...createGetToolCatalogTool(this.toolRegistry, workspace.id, roleId, {
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
excludeTools: MCP_EXCLUDED_TOOL_NAMES,
}),
inputSchema: zodSchema(getToolCatalogInputSchema),
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
} as McpAnnotatedTool,
[EXECUTE_TOOL_TOOL_NAME]: {
...createExecuteToolTool(this.toolRegistry, toolContext, {
excludeTools: MCP_EXCLUDED_TOOL_NAMES,
}),
inputSchema: executeToolInputSchema,
annotations: MCP_EXECUTE_TOOL_ANNOTATIONS,
} as McpAnnotatedTool,
[LOAD_SKILL_TOOL_NAME]: {
...createLoadSkillTool(
(names) =>
this.skillService.findFlatSkillsByNames(names, workspace.id),
async () => {
const allSkills = await this.skillService.findAllFlatSkills(
workspace.id,
);
return allSkills.map((skill) => skill.name);
},
),
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,
};
}
// Returns null for JSON-RPC notifications (no id), which require no response body
async handleMCPCoreQuery(
{ id, method, params }: JsonRpc,
{
workspace,
userId,
userWorkspaceId,
apiKey,
}: {
workspace: FlatWorkspace;
userId?: string;
userWorkspaceId?: string;
apiKey: FlatApiKey | undefined;
},
sseWriter?: (data: Record<string, unknown>) => void,
): Promise<Record<string, unknown> | null> {
try {
// JSON-RPC notifications have no id and expect no response
if (!isDefined(id)) {
return null;
}
if (method === 'initialize') {
return this.handleInitialize(id, workspace.id);
}
if (method === 'ping') {
return wrapJsonRpcResponse(id, { result: {} });
}
if (method === 'prompts/list') {
return wrapJsonRpcResponse(id, {
result: { prompts: [] },
});
}
if (method === 'resources/list') {
return wrapJsonRpcResponse(id, {
result: { resources: [] },
});
}
if (method !== 'tools/list' && method !== 'tools/call') {
return wrapJsonRpcResponse(id, {
error: {
code: JSON_RPC_ERROR_CODE.METHOD_NOT_FOUND,
message: `Method '${method}' not found`,
},
});
}
const roleId = await this.getRoleId(
workspace.id,
userWorkspaceId,
apiKey,
);
const authContext = isDefined(apiKey)
? buildApiKeyAuthContext({ workspace, apiKey })
: undefined;
const toolSet = await this.buildMcpToolSet(workspace, roleId, {
authContext,
userId,
userWorkspaceId,
});
if (method === 'tools/call') {
if (!params) {
return wrapJsonRpcResponse(id, {
error: {
code: JSON_RPC_ERROR_CODE.INVALID_PARAMS,
message: 'tools/call requires params with name and arguments',
},
});
}
return await this.mcpToolExecutorService.handleToolCall(
id,
toolSet,
params,
sseWriter,
);
}
return this.mcpToolExecutorService.handleToolsListing(id, toolSet);
} catch (error) {
if (error instanceof HttpException) {
return wrapJsonRpcResponse(id ?? 0, {
error: {
code: JSON_RPC_ERROR_CODE.SERVER_ERROR,
message: error.message || 'Request failed',
},
});
}
return wrapJsonRpcResponse(id ?? 0, {
error: {
code: JSON_RPC_ERROR_CODE.INTERNAL_ERROR,
message:
error instanceof Error ? error.message : 'Internal server error',
},
});
}
}
}