From 1349a2cd2d96c04cd898266528b0768d141e2d12 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 7 Jun 2026 09:22:29 -0400 Subject: [PATCH] fix MCP tool name limits --- api/server/services/ToolService.js | 78 +++++- packages/api/src/agents/handlers.ts | 12 +- packages/api/src/agents/skillConfigurable.ts | 14 +- packages/api/src/tools/classification.spec.ts | 61 ++++ packages/api/src/tools/classification.ts | 147 +++++++--- packages/api/src/tools/definitions.spec.ts | 54 ++++ packages/api/src/tools/definitions.ts | 4 + packages/api/src/tools/index.ts | 1 + packages/api/src/tools/names.spec.ts | 74 +++++ packages/api/src/tools/names.ts | 265 ++++++++++++++++++ packages/data-provider/src/config.spec.ts | 30 ++ packages/data-provider/src/config.ts | 2 + packages/data-provider/src/types.ts | 1 + 13 files changed, 682 insertions(+), 61 deletions(-) create mode 100644 packages/api/src/tools/names.spec.ts create mode 100644 packages/api/src/tools/names.ts diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index ec28d15b55..2c3cf82c08 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -27,6 +27,8 @@ const { buildMCPAuthRunStepEvent, buildMCPAuthRunStepDeltaEvent, buildMCPAuthRunStepCompletedEvent, + resolveToolNameMaxLength, + resolveToolNameForExecution, isFileAuthoringToolDefinition, ASK_USER_QUESTION_TOOL_NAME, } = require('@librechat/api'); @@ -561,6 +563,10 @@ async function loadToolDefinitionsWrapper({ } const appConfig = req.config; + const toolNameMaxLength = resolveToolNameMaxLength({ + appConfig, + provider: agent.provider, + }); const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id); const checkCapability = (capability) => enabledCapabilities.has(capability); @@ -885,6 +891,7 @@ async function loadToolDefinitionsWrapper({ programmaticToolsEnabled, codeExecutionEnabled, provider: agent.provider, + toolNameMaxLength, }, { isBuiltInTool, @@ -967,6 +974,7 @@ async function loadToolDefinitionsWrapper({ programmaticToolsEnabled, codeExecutionEnabled, provider: agent.provider, + toolNameMaxLength, }, { isBuiltInTool, @@ -1127,6 +1135,10 @@ async function loadAgentTools({ } const appConfig = req.config; + const toolNameMaxLength = resolveToolNameMaxLength({ + appConfig, + provider: agent.provider, + }); const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id); const checkCapability = (capability) => { const enabled = enabledCapabilities.has(capability); @@ -1233,6 +1245,7 @@ async function loadAgentTools({ programmaticToolsEnabled, codeExecutionEnabled, authHeaders: () => getCodeApiAuthHeaders(req), + toolNameMaxLength, }); const agentTools = []; @@ -1487,6 +1500,8 @@ async function loadToolsForExecution({ }) { const appConfig = req.config; const allLoadedTools = []; + const runtimeToolMap = new Map(); + const runtimeNamesByLoadName = new Map(); const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res); const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections }; /** Per-agent set of tools that received the injected `run_in_background` @@ -1496,6 +1511,31 @@ async function loadToolsForExecution({ configurable.backgroundToolNames = backgroundToolNames; } + const addRuntimeNameForLoadName = (loadName, runtimeName) => { + if (!loadName || !runtimeName) { + return; + } + const runtimeNames = runtimeNamesByLoadName.get(loadName) ?? new Set(); + runtimeNames.add(runtimeName); + runtimeNamesByLoadName.set(loadName, runtimeNames); + }; + + const addLoadedTool = (tool) => { + allLoadedTools.push(tool); + if (!tool?.name) { + return; + } + + runtimeToolMap.set(tool.name, tool); + const runtimeNames = runtimeNamesByLoadName.get(tool.name); + if (!runtimeNames) { + return; + } + for (const runtimeName of runtimeNames) { + runtimeToolMap.set(runtimeName, tool); + } + }; + const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH); const ptcToolNames = [ AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING, @@ -1543,7 +1583,7 @@ async function loadToolsForExecution({ mode: 'local', toolRegistry, }); - allLoadedTools.push(toolSearchTool); + addLoadedTool(toolSearchTool); configurable.toolRegistry = toolRegistry; } @@ -1559,7 +1599,7 @@ async function loadToolsForExecution({ authHeaders: () => getCodeApiAuthHeaders(req), }); ptcTool.name = name; - allLoadedTools.push(ptcTool); + addLoadedTool(ptcTool); } } catch (error) { logger.error('[loadToolsForExecution] Error creating PTC tool:', error); @@ -1582,7 +1622,7 @@ async function loadToolsForExecution({ authHeaders: () => getCodeApiAuthHeaders(req), statefulSessions: statefulCodeSessions, }); - allLoadedTools.push(bashTool); + addLoadedTool(bashTool); } catch (error) { logger.error('[loadToolsForExecution] Failed to create bash_tool', error); } @@ -1630,9 +1670,21 @@ async function loadToolsForExecution({ ? [...new Set([...allowedNonSpecialToolNames, ...ptcOrchestratedToolNames])] : allowedNonSpecialToolNames; + const resolvedToolNamesToLoad = []; + for (const name of allToolNamesToLoad) { + const resolvedName = resolveToolNameForExecution(name, toolRegistry); + addRuntimeNameForLoadName(resolvedName, name); + addRuntimeNameForLoadName(resolvedName, resolvedName); + resolvedToolNamesToLoad.push(resolvedName); + if (resolvedName !== name) { + logger.debug(`[loadToolsForExecution] Resolved tool name "${name}" -> "${resolvedName}"`); + } + } + const uniqueToolNamesToLoad = [...new Set(resolvedToolNamesToLoad)]; + const actionToolNames = []; const regularToolNames = []; - for (const name of allToolNamesToLoad) { + for (const name of uniqueToolNamesToLoad) { (isActionTool(name) ? actionToolNames : regularToolNames).push(name); } @@ -1667,7 +1719,9 @@ async function loadToolsForExecution({ }); if (loadedTools) { - allLoadedTools.push(...loadedTools); + for (const tool of loadedTools) { + addLoadedTool(tool); + } } } @@ -1681,7 +1735,9 @@ async function loadToolsForExecution({ jobCreatedAt, actionToolNames, }); - allLoadedTools.push(...actionTools); + for (const tool of actionTools) { + addLoadedTool(tool); + } } else if (actionToolNames.length > 0 && agent && !actionsEnabled) { logger.warn( `[loadToolsForExecution] Capability "${AgentCapabilities.actions}" disabled. ` + @@ -1691,13 +1747,12 @@ async function loadToolsForExecution({ if (isPTC && allLoadedTools.length > 0) { const ptcToolMap = new Map(); - for (const tool of allLoadedTools) { + for (const [name, tool] of runtimeToolMap.entries()) { if ( - tool.name && - tool.name !== AgentConstants.PROGRAMMATIC_TOOL_CALLING && - tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING + name !== AgentConstants.PROGRAMMATIC_TOOL_CALLING && + name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING ) { - ptcToolMap.set(tool.name, tool); + ptcToolMap.set(name, tool); } } configurable.ptcToolMap = ptcToolMap; @@ -1706,6 +1761,7 @@ async function loadToolsForExecution({ return { configurable, loadedTools: allLoadedTools, + toolMap: runtimeToolMap, }; } diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 5a31f26448..24f226e14b 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -71,6 +71,7 @@ export interface ToolExecuteOptions { agentId?: string, ) => Promise<{ loadedTools: StructuredToolInterface[]; + toolMap?: Map; /** Additional configurable properties to merge (e.g., userMCPAuthMap) */ configurable?: Record; }>; @@ -3631,11 +3632,12 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand await runOutsideTracing(async () => { try { const toolNames = [...new Set(toolCalls.map((tc: ToolCallRequest) => tc.name))]; - const { loadedTools, configurable: toolConfigurable } = await loadTools( - toolNames, - agentId, - ); - const toolMap = new Map(loadedTools.map((t) => [t.name, t])); + const { + loadedTools, + toolMap: loadedToolMap, + configurable: toolConfigurable, + } = await loadTools(toolNames, agentId); + const toolMap = loadedToolMap ?? new Map(loadedTools.map((t) => [t.name, t])); const sourceConfigurable = configurable as Record | undefined; const loadedConfigurable = toolConfigurable as Record | undefined; const mergedConfigurable = mergeToolConfigurables( diff --git a/packages/api/src/agents/skillConfigurable.ts b/packages/api/src/agents/skillConfigurable.ts index ada280d6a0..10951677da 100644 --- a/packages/api/src/agents/skillConfigurable.ts +++ b/packages/api/src/agents/skillConfigurable.ts @@ -53,6 +53,7 @@ export interface SkillConfigurableContext { export interface SkillConfigurableLoadResult { loadedTools: unknown[]; + toolMap?: Map; configurable?: Record; } @@ -63,6 +64,7 @@ export interface EnrichWithSkillConfigurableParams { export function enrichWithSkillConfigurable(params: EnrichWithSkillConfigurableParams): { loadedTools: unknown[]; + toolMap?: Map; configurable: Record; }; export function enrichWithSkillConfigurable( @@ -74,7 +76,11 @@ export function enrichWithSkillConfigurable( activeSkillNames?: Set, skillAuthoringAvailable?: boolean, fileAuthoringToolNames?: Set, -): { loadedTools: unknown[]; configurable: Record }; +): { + loadedTools: unknown[]; + toolMap?: Map; + configurable: Record; +}; export function enrichWithSkillConfigurable( first: EnrichWithSkillConfigurableParams | SkillConfigurableLoadResult, req?: { user?: { id?: string } }, @@ -84,7 +90,11 @@ export function enrichWithSkillConfigurable( activeSkillNames?: Set, skillAuthoringAvailable?: boolean, fileAuthoringToolNames?: Set, -): { loadedTools: unknown[]; configurable: Record } { +): { + loadedTools: unknown[]; + toolMap?: Map; + configurable: Record; +} { const { result, context } = 'result' in first && 'context' in first ? first diff --git a/packages/api/src/tools/classification.spec.ts b/packages/api/src/tools/classification.spec.ts index b543d2bad1..7af85f0904 100644 --- a/packages/api/src/tools/classification.spec.ts +++ b/packages/api/src/tools/classification.spec.ts @@ -9,6 +9,13 @@ import { getServerNameFromTool, agentHasDeferredTools, } from './classification'; +import { resolveToolNameForExecution } from './names'; + +type MCPNameMetadata = { + canonicalName?: string; + providerToolName?: string; + mcpRawName?: string; +}; describe('classification.ts', () => { describe('getServerNameFromTool', () => { @@ -79,6 +86,60 @@ describe('classification.ts', () => { expect(registry.get('tool1')?.allowed_callers).toEqual(['direct']); }); + + it('should alias long MCP names and preserve canonical tool options', () => { + const rawName = 'search_records_with_a_very_long_raw_name'; + const canonicalName = `${rawName}_mcp_server_with_a_very_long_name_that_exceeds_limits`; + const tools = [{ name: canonicalName, description: 'Long MCP tool' }]; + + const agentToolOptions: AgentToolOptions = { + [canonicalName]: { + defer_loading: true, + allowed_callers: ['code_execution'], + }, + }; + + const registry = buildToolRegistryFromAgentOptions(tools, agentToolOptions, 64); + const [providerToolName, toolDef] = Array.from(registry.entries())[0]; + const metadata = toolDef as typeof toolDef & MCPNameMetadata; + + expect(providerToolName).not.toBe(canonicalName); + expect(providerToolName.length).toBeLessThanOrEqual(64); + expect(toolDef.name).toBe(providerToolName); + expect(toolDef.defer_loading).toBe(true); + expect(toolDef.allowed_callers).toEqual(['code_execution']); + expect(metadata.canonicalName).toBe(canonicalName); + expect(metadata.providerToolName).toBe(providerToolName); + expect(metadata.mcpRawName).toBe(rawName); + expect(resolveToolNameForExecution(providerToolName, registry)).toBe(canonicalName); + expect(resolveToolNameForExecution(rawName, registry)).toBe(canonicalName); + }); + + it('should not resolve ambiguous raw MCP names', () => { + const rawName = 'search_records'; + const firstCanonicalName = `${rawName}_mcp_server_one`; + const secondCanonicalName = `${rawName}_mcp_server_two`; + const registry = buildToolRegistryFromAgentOptions( + [{ name: firstCanonicalName }, { name: secondCanonicalName }], + {}, + ); + + expect(resolveToolNameForExecution(rawName, registry)).toBe(rawName); + }); + + it('should expose duplicate canonical MCP definitions once', () => { + const canonicalName = + 'search_records_with_a_very_long_raw_name_mcp_server_with_a_very_long_name'; + const registry = buildToolRegistryFromAgentOptions( + [{ name: canonicalName }, { name: canonicalName }], + {}, + 64, + ); + + expect(registry.size).toBe(1); + const [providerToolName] = Array.from(registry.keys()); + expect(resolveToolNameForExecution(providerToolName, registry)).toBe(canonicalName); + }); }); describe('agentHasDeferredTools', () => { diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts index b461c85990..861884a011 100644 --- a/packages/api/src/tools/classification.ts +++ b/packages/api/src/tools/classification.ts @@ -6,7 +6,6 @@ */ import { logger } from '@librechat/data-schemas'; -import { Constants } from 'librechat-data-provider'; import { Providers, createToolSearch, @@ -22,6 +21,8 @@ import type { LCTool, } from '@librechat/agents'; import type { AgentToolOptions } from 'librechat-data-provider'; +import type { LCToolWithMCPNameMetadata } from './names'; +import { parseMCPToolName, createProviderToolName, DEFAULT_TOOL_NAME_MAX_LENGTH } from './names'; import { sanitizeGeminiSchema } from '~/mcp/zod'; export type { LCTool, LCToolRegistry, AllowedCaller, JsonSchemaType }; @@ -32,6 +33,10 @@ export interface ToolDefinition { parameters?: JsonSchemaType; /** MCP server name extracted from tool name */ serverName?: string; + /** Original LibreChat MCP key: toolName_mcp_serverName */ + canonicalName?: string; + /** Raw MCP tool name before LibreChat appends the server suffix */ + mcpRawName?: string; } /** @@ -41,11 +46,60 @@ export interface ToolDefinition { * @returns The server name or undefined if not an MCP tool */ export function getServerNameFromTool(toolName: string): string | undefined { - const parts = toolName.split(Constants.mcp_delimiter); - if (parts.length >= 2) { - return parts[parts.length - 1]; + return parseMCPToolName(toolName)?.serverName; +} + +function createRegistryTool({ + tool, + agentToolOptions, + usedToolNames, + toolNameMaxLength, +}: { + tool: ToolDefinition; + agentToolOptions?: AgentToolOptions; + usedToolNames: Set; + toolNameMaxLength: number; +}): LCToolWithMCPNameMetadata { + const { description, parameters } = tool; + const canonicalName = tool.canonicalName ?? tool.name; + const parsed = parseMCPToolName(canonicalName); + const providerToolName = createProviderToolName({ + canonicalName, + usedToolNames, + maxLength: toolNameMaxLength, + }); + usedToolNames.add(providerToolName); + + const agentOptions = agentToolOptions?.[canonicalName] ?? agentToolOptions?.[providerToolName]; + + const allowed_callers: AllowedCaller[] = + agentOptions?.allowed_callers && agentOptions.allowed_callers.length > 0 + ? agentOptions.allowed_callers + : ['direct']; + + const defer_loading = agentOptions?.defer_loading === true; + + const toolDef: LCToolWithMCPNameMetadata = { + name: providerToolName, + allowed_callers, + defer_loading, + toolType: 'mcp', + canonicalName, + providerToolName, + mcpRawName: tool.mcpRawName ?? parsed?.rawName, + }; + + if (description) { + toolDef.description = description; } - return undefined; + if (parameters) { + toolDef.parameters = parameters; + } + if (tool.serverName || parsed?.serverName) { + toolDef.serverName = tool.serverName ?? parsed?.serverName; + } + + return toolDef; } /** @@ -58,38 +112,26 @@ export function getServerNameFromTool(toolName: string): string | undefined { export function buildToolRegistryFromAgentOptions( tools: ToolDefinition[], agentToolOptions: AgentToolOptions, + toolNameMaxLength = DEFAULT_TOOL_NAME_MAX_LENGTH, ): LCToolRegistry { const registry: LCToolRegistry = new Map(); + const usedToolNames = new Set(); + const usedCanonicalNames = new Set(); for (const tool of tools) { - const { name, description, parameters } = tool; - const agentOptions = agentToolOptions[name]; - - const allowed_callers: AllowedCaller[] = - agentOptions?.allowed_callers && agentOptions.allowed_callers.length > 0 - ? agentOptions.allowed_callers - : ['direct']; - - const defer_loading = agentOptions?.defer_loading === true; - - const toolDef: LCTool = { - name, - allowed_callers, - defer_loading, - toolType: 'mcp', - }; - - if (description) { - toolDef.description = description; - } - if (parameters) { - toolDef.parameters = parameters; - } - if (tool.serverName) { - toolDef.serverName = tool.serverName; + const canonicalName = tool.canonicalName ?? tool.name; + if (usedCanonicalNames.has(canonicalName)) { + continue; } + usedCanonicalNames.add(canonicalName); - registry.set(name, toolDef); + const toolDef = createRegistryTool({ + tool, + agentToolOptions, + usedToolNames, + toolNameMaxLength, + }); + registry.set(toolDef.name, toolDef); } return registry; @@ -111,7 +153,12 @@ interface MCPToolInstance { * @returns Tool definition */ export function extractMCPToolDefinition(tool: MCPToolInstance): ToolDefinition { - const def: ToolDefinition = { name: tool.name }; + const parsed = parseMCPToolName(tool.name); + const def: ToolDefinition = { + name: tool.name, + canonicalName: tool.name, + mcpRawName: parsed?.rawName, + }; if (tool.description) { def.description = tool.description; @@ -121,9 +168,8 @@ export function extractMCPToolDefinition(tool: MCPToolInstance): ToolDefinition def.parameters = tool.mcpJsonSchema; } - const serverName = getServerNameFromTool(tool.name); - if (serverName) { - def.serverName = serverName; + if (parsed?.serverName) { + def.serverName = parsed.serverName; } return def; @@ -156,21 +202,29 @@ export function cleanupMCPToolSchemas(tools: MCPToolInstance[]): void { function buildToolRegistry( mcpToolDefs: ToolDefinition[], agentToolOptions?: AgentToolOptions, + toolNameMaxLength = DEFAULT_TOOL_NAME_MAX_LENGTH, ): LCToolRegistry { if (agentToolOptions && Object.keys(agentToolOptions).length > 0) { - return buildToolRegistryFromAgentOptions(mcpToolDefs, agentToolOptions); + return buildToolRegistryFromAgentOptions(mcpToolDefs, agentToolOptions, toolNameMaxLength); } /** No agent options - build basic definitions for event-driven mode */ const registry: LCToolRegistry = new Map(); + const usedToolNames = new Set(); + const usedCanonicalNames = new Set(); for (const toolDef of mcpToolDefs) { - registry.set(toolDef.name, { - name: toolDef.name, - description: toolDef.description, - parameters: toolDef.parameters, - serverName: toolDef.serverName, - toolType: 'mcp', + const canonicalName = toolDef.canonicalName ?? toolDef.name; + if (usedCanonicalNames.has(canonicalName)) { + continue; + } + usedCanonicalNames.add(canonicalName); + + const registryTool = createRegistryTool({ + tool: toolDef, + usedToolNames, + toolNameMaxLength, }); + registry.set(registryTool.name, registryTool); } return registry; } @@ -197,6 +251,8 @@ export interface BuildToolClassificationParams { provider?: Providers | string; /** Optional host-supplied Code API auth headers for remote programmatic execution. */ authHeaders?: () => Promise> | Record; + /** Provider-facing maximum tool/function name length. */ + toolNameMaxLength?: number; } /** Result from building tool classification */ @@ -265,6 +321,7 @@ export async function buildToolClassification( programmaticToolsEnabled = false, codeExecutionEnabled = false, authHeaders, + toolNameMaxLength = DEFAULT_TOOL_NAME_MAX_LENGTH, } = params; const isGoogle = provider === Providers.GOOGLE || provider === Providers.VERTEXAI; const additionalTools: GenericTool[] = []; @@ -280,7 +337,11 @@ export async function buildToolClassification( } const mcpToolDefs = mcpTools.map(extractMCPToolDefinition); - const toolRegistry: LCToolRegistry = buildToolRegistry(mcpToolDefs, agentToolOptions); + const toolRegistry: LCToolRegistry = buildToolRegistry( + mcpToolDefs, + agentToolOptions, + toolNameMaxLength, + ); /** Clean up temporary mcpJsonSchema property from tools now that registry is populated */ cleanupMCPToolSchemas(mcpTools); diff --git a/packages/api/src/tools/definitions.spec.ts b/packages/api/src/tools/definitions.spec.ts index 0291ea789c..f62004896c 100644 --- a/packages/api/src/tools/definitions.spec.ts +++ b/packages/api/src/tools/definitions.spec.ts @@ -5,11 +5,18 @@ import type { ActionToolDefinition, } from './definitions'; import { toolkitExpansion, toolkitParent } from './toolkits/mapping'; +import { resolveToolNameForExecution } from './names'; import { getToolDefinition } from './registry/definitions'; import { loadToolDefinitions } from './definitions'; const MAX_PROVIDER_TOOL_DESCRIPTION_LENGTH = 1024; +type MCPNameMetadata = { + canonicalName?: string; + providerToolName?: string; + mcpRawName?: string; +}; + describe('definitions.ts', () => { const mockGetOrFetchMCPServerTools = jest.fn().mockResolvedValue(null); const mockIsBuiltInTool = jest.fn().mockReturnValue(false); @@ -598,6 +605,53 @@ describe('definitions.ts', () => { expect(result.toolDefinitions[0].name).toBe('list_items_mcp_server-one'); }); + it('should expose aliases for MCP tools that exceed provider name limits', async () => { + const rawName = 'search_records_with_a_very_long_raw_name'; + const serverName = 'server_with_a_very_long_name_that_exceeds_provider_limits'; + const canonicalName = `${rawName}_mcp_${serverName}`; + const mockServerTools = { + [canonicalName]: { + function: { + name: canonicalName, + description: 'Search records', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + }, + }, + }, + }; + + mockGetOrFetchMCPServerTools.mockResolvedValue(mockServerTools); + + const result = await loadToolDefinitions( + { + userId: 'user-123', + agentId: 'agent-123', + tools: [canonicalName], + toolNameMaxLength: 64, + }, + { + getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools, + isBuiltInTool: mockIsBuiltInTool, + }, + ); + + expect(result.toolDefinitions).toHaveLength(1); + const toolDef = result.toolDefinitions[0]; + const metadata = toolDef as typeof toolDef & MCPNameMetadata; + + expect(toolDef.name).not.toBe(canonicalName); + expect(toolDef.name.length).toBeLessThanOrEqual(64); + expect(metadata.canonicalName).toBe(canonicalName); + expect(metadata.providerToolName).toBe(toolDef.name); + expect(metadata.mcpRawName).toBe(rawName); + expect(resolveToolNameForExecution(toolDef.name, result.toolRegistry)).toBe(canonicalName); + expect(resolveToolNameForExecution(rawName, result.toolRegistry)).toBe(canonicalName); + }); + it('should include hyphenated server name tools in registry with correct serverName', async () => { const mockServerTools = { 'list_items_mcp_my-server': { diff --git a/packages/api/src/tools/definitions.ts b/packages/api/src/tools/definitions.ts index 16d689807b..a70e9c9f22 100644 --- a/packages/api/src/tools/definitions.ts +++ b/packages/api/src/tools/definitions.ts @@ -42,6 +42,8 @@ export interface LoadToolDefinitionsParams { codeExecutionEnabled?: boolean; /** Agent provider — Gemini/Vertex tool schemas get union-flattened for compatibility */ provider?: Providers; + /** Provider-facing maximum tool/function name length */ + toolNameMaxLength?: number; } export interface ActionToolDefinition { @@ -87,6 +89,7 @@ export async function loadToolDefinitions( programmaticToolsEnabled = false, codeExecutionEnabled = false, provider, + toolNameMaxLength, } = params; const { getOrFetchMCPServerTools, isBuiltInTool, getActionToolDefinitions } = deps; @@ -217,6 +220,7 @@ export async function loadToolDefinitions( deferredToolsEnabled, programmaticToolsEnabled, codeExecutionEnabled, + toolNameMaxLength, definitionsOnly: true, agentToolOptions: toolOptions, }); diff --git a/packages/api/src/tools/index.ts b/packages/api/src/tools/index.ts index 8695d06707..3173d7a5d5 100644 --- a/packages/api/src/tools/index.ts +++ b/packages/api/src/tools/index.ts @@ -3,3 +3,4 @@ export * from './registry'; export * from './toolkits'; export * from './definitions'; export * from './classification'; +export * from './names'; diff --git a/packages/api/src/tools/names.spec.ts b/packages/api/src/tools/names.spec.ts new file mode 100644 index 0000000000..f6a2e281e7 --- /dev/null +++ b/packages/api/src/tools/names.spec.ts @@ -0,0 +1,74 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import { + createProviderToolName, + resolveToolNameMaxLength, + isProviderToolNameCompatible, +} from './names'; + +describe('names.ts', () => { + describe('resolveToolNameMaxLength', () => { + it('uses the provider default when config is missing', () => { + expect(resolveToolNameMaxLength({ provider: EModelEndpoint.openAI })).toBe(64); + expect(resolveToolNameMaxLength({ provider: EModelEndpoint.anthropic })).toBe(64); + expect(resolveToolNameMaxLength({ provider: EModelEndpoint.google })).toBe(64); + expect(resolveToolNameMaxLength({ provider: EModelEndpoint.bedrock })).toBe(64); + }); + + it('uses endpoints.all as the global override', () => { + const appConfig = { + endpoints: { + all: { toolNameMaxLength: 80 }, + }, + }; + + expect(resolveToolNameMaxLength({ appConfig, provider: EModelEndpoint.openAI })).toBe(80); + }); + + it('uses provider config before endpoints.all', () => { + const appConfig = { + endpoints: { + all: { toolNameMaxLength: 80 }, + [EModelEndpoint.openAI]: { toolNameMaxLength: 48 }, + }, + }; + + expect(resolveToolNameMaxLength({ appConfig, provider: EModelEndpoint.openAI })).toBe(48); + }); + + it('uses matching custom endpoint config before endpoints.all', () => { + const appConfig = { + endpoints: { + all: { toolNameMaxLength: 80 }, + [EModelEndpoint.custom]: [{ name: 'OpenRouter', toolNameMaxLength: 52 }], + }, + }; + + expect(resolveToolNameMaxLength({ appConfig, provider: 'openrouter' })).toBe(52); + }); + }); + + describe('createProviderToolName', () => { + it('keeps compatible canonical names unchanged', () => { + const canonicalName = 'list_items_mcp_server'; + expect(createProviderToolName({ canonicalName, maxLength: 64 })).toBe(canonicalName); + }); + + it('aliases long canonical names within the configured limit', () => { + const canonicalName = + 'search_records_with_a_very_long_raw_name_mcp_server_with_a_very_long_name'; + const alias = createProviderToolName({ canonicalName, maxLength: 64 }); + + expect(alias).not.toBe(canonicalName); + expect(alias.length).toBeLessThanOrEqual(64); + expect(isProviderToolNameCompatible(alias, 64)).toBe(true); + }); + + it('aliases provider-incompatible canonical names', () => { + const canonicalName = 'search.records_mcp_server'; + const alias = createProviderToolName({ canonicalName, maxLength: 64 }); + + expect(alias).not.toBe(canonicalName); + expect(isProviderToolNameCompatible(alias, 64)).toBe(true); + }); + }); +}); diff --git a/packages/api/src/tools/names.ts b/packages/api/src/tools/names.ts new file mode 100644 index 0000000000..3901973bf3 --- /dev/null +++ b/packages/api/src/tools/names.ts @@ -0,0 +1,265 @@ +import { createHash } from 'crypto'; +import { + Constants, + EModelEndpoint, + Providers, + normalizeEndpointName, +} from 'librechat-data-provider'; +import type { LCTool, LCToolRegistry } from '@librechat/agents'; + +export const DEFAULT_TOOL_NAME_MAX_LENGTH = 64; +export const MIN_TOOL_NAME_MAX_LENGTH = 16; + +export const PROVIDER_TOOL_NAME_MAX_LENGTH_DEFAULTS: Readonly> = + Object.freeze({ + [EModelEndpoint.openAI]: 64, + [EModelEndpoint.azureOpenAI]: 64, + [EModelEndpoint.anthropic]: 64, + [EModelEndpoint.google]: 64, + [EModelEndpoint.bedrock]: 64, + [EModelEndpoint.custom]: 64, + [EModelEndpoint.agents]: 64, + [Providers.VERTEXAI]: 64, + [Providers.MISTRALAI]: 64, + [Providers.MISTRAL]: 64, + [Providers.DEEPSEEK]: 64, + [Providers.MOONSHOT]: 64, + [Providers.OPENROUTER]: 64, + [Providers.XAI]: 64, + }); + +export interface MCPToolNameParts { + rawName: string; + serverName: string; +} + +export interface MCPToolNameMetadata { + canonicalName?: string; + providerToolName?: string; + mcpRawName?: string; +} + +export type LCToolWithMCPNameMetadata = LCTool & MCPToolNameMetadata; + +type EndpointConfigRecord = Record; + +function isRecord(value: unknown): value is EndpointConfigRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getEndpoints(appConfig?: unknown): EndpointConfigRecord | undefined { + if (!isRecord(appConfig)) { + return undefined; + } + + const endpoints = appConfig.endpoints; + return isRecord(endpoints) ? endpoints : undefined; +} + +function readToolNameMaxLength(config: unknown): number | undefined { + if (!isRecord(config)) { + return undefined; + } + + const maxLength = config.toolNameMaxLength; + if (typeof maxLength !== 'number' || !Number.isFinite(maxLength)) { + return undefined; + } + + return normalizeToolNameMaxLength(maxLength); +} + +function getCustomEndpointConfig( + endpoints: EndpointConfigRecord | undefined, + provider?: string, +): EndpointConfigRecord | undefined { + if (!endpoints || !provider) { + return undefined; + } + + const customEndpoints = endpoints[EModelEndpoint.custom]; + if (!Array.isArray(customEndpoints)) { + return undefined; + } + + const normalizedProvider = normalizeEndpointName(provider); + const customEndpointRecords = customEndpoints.filter( + (endpoint): endpoint is EndpointConfigRecord => + isRecord(endpoint) && typeof endpoint.name === 'string', + ); + const match = customEndpointRecords.find( + (endpoint) => normalizeEndpointName(String(endpoint.name)) === normalizedProvider, + ); + if (match) { + return match; + } + + const lowercaseMatches = customEndpointRecords.filter( + (endpoint) => String(endpoint.name).toLowerCase() === provider.toLowerCase(), + ); + return lowercaseMatches.length === 1 ? lowercaseMatches[0] : undefined; +} + +function getProviderEndpointConfig( + endpoints: EndpointConfigRecord | undefined, + provider?: string, +): unknown { + if (!endpoints || !provider) { + return undefined; + } + + const directConfig = endpoints[provider]; + if (isRecord(directConfig)) { + return directConfig; + } + + return getCustomEndpointConfig(endpoints, provider); +} + +export function normalizeToolNameMaxLength(maxLength?: number): number { + if (typeof maxLength !== 'number' || !Number.isFinite(maxLength)) { + return DEFAULT_TOOL_NAME_MAX_LENGTH; + } + + return Math.max(MIN_TOOL_NAME_MAX_LENGTH, Math.floor(maxLength)); +} + +export function getProviderToolNameMaxLengthDefault(provider?: string): number { + if (!provider) { + return DEFAULT_TOOL_NAME_MAX_LENGTH; + } + + return ( + PROVIDER_TOOL_NAME_MAX_LENGTH_DEFAULTS[provider] ?? + PROVIDER_TOOL_NAME_MAX_LENGTH_DEFAULTS[provider.toLowerCase()] ?? + DEFAULT_TOOL_NAME_MAX_LENGTH + ); +} + +export function resolveToolNameMaxLength({ + appConfig, + provider, +}: { + appConfig?: unknown; + provider?: string; +}): number { + const endpoints = getEndpoints(appConfig); + const providerConfig = getProviderEndpointConfig(endpoints, provider); + const providerMaxLength = readToolNameMaxLength(providerConfig); + const allMaxLength = readToolNameMaxLength(endpoints?.all); + + return normalizeToolNameMaxLength( + providerMaxLength ?? allMaxLength ?? getProviderToolNameMaxLengthDefault(provider), + ); +} + +export function parseMCPToolName(toolName: string): MCPToolNameParts | undefined { + const delimiterIndex = toolName.lastIndexOf(Constants.mcp_delimiter); + if (delimiterIndex === -1) { + return undefined; + } + + return { + rawName: toolName.slice(0, delimiterIndex), + serverName: toolName.slice(delimiterIndex + Constants.mcp_delimiter.length), + }; +} + +export function isProviderToolNameCompatible( + toolName: string, + maxLength = DEFAULT_TOOL_NAME_MAX_LENGTH, +): boolean { + return ( + toolName.length > 0 && + toolName.length <= normalizeToolNameMaxLength(maxLength) && + /^[A-Za-z0-9_-]+$/.test(toolName) + ); +} + +function sanitizeToolNamePart(name: string): string { + const sanitized = name + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + + return sanitized || 'tool'; +} + +function getToolNameHash(input: string, attempt: number, length: number): string { + const hashInput = attempt === 0 ? input : `${input}:${attempt}`; + return createHash('sha256').update(hashInput).digest('hex').slice(0, length); +} + +export function createProviderToolName({ + canonicalName, + maxLength = DEFAULT_TOOL_NAME_MAX_LENGTH, + usedToolNames = new Set(), +}: { + canonicalName: string; + maxLength?: number; + usedToolNames?: Set; +}): string { + const normalizedMaxLength = normalizeToolNameMaxLength(maxLength); + if ( + isProviderToolNameCompatible(canonicalName, normalizedMaxLength) && + !usedToolNames.has(canonicalName) + ) { + return canonicalName; + } + + const parsed = parseMCPToolName(canonicalName); + const sourceName = parsed?.rawName ?? canonicalName; + const prefix = 'mcp_'; + const hashLength = normalizedMaxLength >= 24 ? 12 : 8; + const suffixLength = hashLength + 1; + const baseLength = Math.max(1, normalizedMaxLength - prefix.length - suffixLength); + const base = sanitizeToolNamePart(sourceName).slice(0, baseLength); + + for (let attempt = 0; attempt < 1000; attempt++) { + const hash = getToolNameHash(canonicalName, attempt, hashLength); + const candidate = `${prefix}${base}_${hash}`; + if (!usedToolNames.has(candidate)) { + return candidate; + } + } + + throw new Error(`Unable to create unique provider tool name for "${canonicalName}"`); +} + +function getCanonicalName(registryName: string, tool: LCTool): string { + return (tool as LCToolWithMCPNameMetadata).canonicalName ?? registryName; +} + +export function resolveToolNameForExecution( + toolName: string, + toolRegistry?: LCToolRegistry, +): string { + if (!toolRegistry) { + return toolName; + } + + const directTool = toolRegistry.get(toolName); + if (directTool) { + return getCanonicalName(toolName, directTool); + } + + const rawMatches: string[] = []; + for (const [registryName, tool] of toolRegistry.entries()) { + const metadata = tool as LCToolWithMCPNameMetadata; + const canonicalName = getCanonicalName(registryName, tool); + + if ( + metadata.providerToolName === toolName || + metadata.canonicalName === toolName || + tool.name === toolName + ) { + return canonicalName; + } + + if (metadata.mcpRawName === toolName) { + rawMatches.push(canonicalName); + } + } + + return rawMatches.length === 1 ? rawMatches[0] : toolName; +} diff --git a/packages/data-provider/src/config.spec.ts b/packages/data-provider/src/config.spec.ts index 3b2ae383cb..8c790cc57e 100644 --- a/packages/data-provider/src/config.spec.ts +++ b/packages/data-provider/src/config.spec.ts @@ -58,6 +58,36 @@ describe('bedrockEndpointSchema', () => { }); }); +describe('baseEndpointSchema toolNameMaxLength', () => { + it('accepts endpoint-wide and provider-specific tool name limits', () => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + all: { toolNameMaxLength: 80 }, + [EModelEndpoint.openAI]: { toolNameMaxLength: 64 }, + }, + }); + + expect(result.success).toBe(true); + if (!result.success) { + return; + } + expect(result.data.endpoints?.all?.toolNameMaxLength).toBe(80); + expect(result.data.endpoints?.[EModelEndpoint.openAI]?.toolNameMaxLength).toBe(64); + }); + + it('rejects tool name limits below the alias minimum', () => { + const result = configSchema.safeParse({ + version: '1.0', + endpoints: { + all: { toolNameMaxLength: 8 }, + }, + }); + + expect(result.success).toBe(false); + }); +}); + describe('resolveEndpointType', () => { describe('non-agents endpoints', () => { it('returns the config type for a custom endpoint', () => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 97607f99c0..dd4f6fc172 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -615,6 +615,8 @@ export const baseEndpointSchema = z.object({ titleTiming: z.union([z.literal('immediate'), z.literal('final')]).optional(), /** Maximum characters allowed in a single tool result before truncation. */ maxToolResultChars: z.number().positive().optional(), + /** Maximum provider-facing tool/function name length. */ + toolNameMaxLength: z.number().int().min(16).optional(), }); export type TBaseEndpoint = z.infer; diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 7316d2b33e..d9c4761f4b 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -480,6 +480,7 @@ export type TConfig = { disableBuilder?: boolean; retrievalModels?: string[]; capabilities?: string[]; + toolNameMaxLength?: number; customParams?: { defaultParamsEndpoint?: string; reasoningFormat?: ReasoningParameterFormat;