5ca41d55fb
# 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. -->
333 lines
11 KiB
TypeScript
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',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|