fix MCP tool name limits

This commit is contained in:
Danny Avila 2026-06-07 09:22:29 -04:00
parent d8427ffc5e
commit 1349a2cd2d
13 changed files with 682 additions and 61 deletions

View file

@ -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,
};
}

View file

@ -71,6 +71,7 @@ export interface ToolExecuteOptions {
agentId?: string,
) => Promise<{
loadedTools: StructuredToolInterface[];
toolMap?: Map<string, StructuredToolInterface>;
/** Additional configurable properties to merge (e.g., userMCPAuthMap) */
configurable?: Record<string, unknown>;
}>;
@ -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<string, unknown> | undefined;
const loadedConfigurable = toolConfigurable as Record<string, unknown> | undefined;
const mergedConfigurable = mergeToolConfigurables(

View file

@ -53,6 +53,7 @@ export interface SkillConfigurableContext {
export interface SkillConfigurableLoadResult {
loadedTools: unknown[];
toolMap?: Map<string, unknown>;
configurable?: Record<string, unknown>;
}
@ -63,6 +64,7 @@ export interface EnrichWithSkillConfigurableParams {
export function enrichWithSkillConfigurable(params: EnrichWithSkillConfigurableParams): {
loadedTools: unknown[];
toolMap?: Map<string, unknown>;
configurable: Record<string, unknown>;
};
export function enrichWithSkillConfigurable(
@ -74,7 +76,11 @@ export function enrichWithSkillConfigurable(
activeSkillNames?: Set<string>,
skillAuthoringAvailable?: boolean,
fileAuthoringToolNames?: Set<string>,
): { loadedTools: unknown[]; configurable: Record<string, unknown> };
): {
loadedTools: unknown[];
toolMap?: Map<string, unknown>;
configurable: Record<string, unknown>;
};
export function enrichWithSkillConfigurable(
first: EnrichWithSkillConfigurableParams | SkillConfigurableLoadResult,
req?: { user?: { id?: string } },
@ -84,7 +90,11 @@ export function enrichWithSkillConfigurable(
activeSkillNames?: Set<string>,
skillAuthoringAvailable?: boolean,
fileAuthoringToolNames?: Set<string>,
): { loadedTools: unknown[]; configurable: Record<string, unknown> } {
): {
loadedTools: unknown[];
toolMap?: Map<string, unknown>;
configurable: Record<string, unknown>;
} {
const { result, context } =
'result' in first && 'context' in first
? first

View file

@ -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', () => {

View file

@ -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<string>;
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<string>();
const usedCanonicalNames = new Set<string>();
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<string, LCTool>();
const usedToolNames = new Set<string>();
const usedCanonicalNames = new Set<string>();
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<string, string>> | Record<string, string>;
/** 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);

View file

@ -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': {

View file

@ -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,
});

View file

@ -3,3 +3,4 @@ export * from './registry';
export * from './toolkits';
export * from './definitions';
export * from './classification';
export * from './names';

View file

@ -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);
});
});
});

View file

@ -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<Record<string, number>> =
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<string, unknown>;
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<string>(),
}: {
canonicalName: string;
maxLength?: number;
usedToolNames?: Set<string>;
}): 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;
}

View file

@ -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', () => {

View file

@ -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<typeof baseEndpointSchema>;

View file

@ -480,6 +480,7 @@ export type TConfig = {
disableBuilder?: boolean;
retrievalModels?: string[];
capabilities?: string[];
toolNameMaxLength?: number;
customParams?: {
defaultParamsEndpoint?: string;
reasoningFormat?: ReasoningParameterFormat;