diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 2c3cf82c08..3d11195242 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -27,6 +27,8 @@ const { buildMCPAuthRunStepEvent, buildMCPAuthRunStepDeltaEvent, buildMCPAuthRunStepCompletedEvent, + normalizeServerName, + parseMCPToolName, resolveToolNameMaxLength, resolveToolNameForExecution, isFileAuthoringToolDefinition, @@ -1520,6 +1522,19 @@ async function loadToolsForExecution({ runtimeNamesByLoadName.set(loadName, runtimeNames); }; + const addNormalizedMCPRuntimeNames = (loadName, runtimeNames) => { + const parsed = parseMCPToolName(loadName); + if (!parsed) { + return; + } + + const normalizedLoadName = `${parsed.rawName}${Constants.mcp_delimiter}${normalizeServerName(parsed.serverName)}`; + for (const runtimeName of runtimeNames) { + addRuntimeNameForLoadName(normalizedLoadName, runtimeName); + } + addRuntimeNameForLoadName(normalizedLoadName, normalizedLoadName); + }; + const addLoadedTool = (tool) => { allLoadedTools.push(tool); if (!tool?.name) { @@ -1675,6 +1690,7 @@ async function loadToolsForExecution({ const resolvedName = resolveToolNameForExecution(name, toolRegistry); addRuntimeNameForLoadName(resolvedName, name); addRuntimeNameForLoadName(resolvedName, resolvedName); + addNormalizedMCPRuntimeNames(resolvedName, [name, resolvedName]); resolvedToolNamesToLoad.push(resolvedName); if (resolvedName !== name) { logger.debug(`[loadToolsForExecution] Resolved tool name "${name}" -> "${resolvedName}"`); diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 6e902de5cd..87c1e66e09 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -14,6 +14,24 @@ const mockGetMCPServerTools = jest.fn(); const mockGetCachedTools = jest.fn(); const mockSendEvent = jest.fn(); const mockEmitChunk = jest.fn(); +const mockMCPDelimiter = Constants.mcp_delimiter; +const mockNormalizeServerName = jest.fn((serverName) => + serverName.replace(/[^a-zA-Z0-9_.-]/g, '_').replace(/^_+|_+$/g, ''), +); +const mockParseMCPToolName = jest.fn((toolName) => { + const delimiterIndex = toolName.lastIndexOf(mockMCPDelimiter); + if (delimiterIndex === -1) { + return undefined; + } + return { + rawName: toolName.slice(0, delimiterIndex), + serverName: toolName.slice(delimiterIndex + mockMCPDelimiter.length), + }; +}); +const mockResolveToolNameForExecution = jest.fn( + (toolName, toolRegistry) => toolRegistry?.get(toolName)?.canonicalName ?? toolName, +); +const mockResolveToolNameMaxLength = jest.fn(() => 64); jest.mock('~/server/services/Config', () => ({ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args), getMCPServerTools: (...args) => mockGetMCPServerTools(...args), @@ -26,6 +44,10 @@ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), loadToolDefinitions: (...args) => mockLoadToolDefinitions(...args), getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args), + normalizeServerName: (...args) => mockNormalizeServerName(...args), + parseMCPToolName: (...args) => mockParseMCPToolName(...args), + resolveToolNameMaxLength: (...args) => mockResolveToolNameMaxLength(...args), + resolveToolNameForExecution: (...args) => mockResolveToolNameForExecution(...args), sendEvent: (...args) => mockSendEvent(...args), GenerationJobManager: { emitChunk: (...args) => mockEmitChunk(...args), @@ -1298,6 +1320,45 @@ describe('ToolService - Action Capability Gating', () => { expect(mockLoadActionSets).not.toHaveBeenCalled(); }); + + it('maps provider aliases to loaded MCP tools with normalized server names', async () => { + const req = createMockReq([AgentCapabilities.tools]); + req.config = {}; + const rawName = 'fetch_report'; + const serverName = 'Research Server'; + const canonicalName = `${rawName}${Constants.mcp_delimiter}${serverName}`; + const normalizedToolName = `${rawName}${Constants.mcp_delimiter}Research_Server`; + const providerToolName = 'mcp_fetch_report_abc123'; + const loadedTool = { name: normalizedToolName, _call: jest.fn() }; + const toolRegistry = new Map([ + [ + providerToolName, + { + name: providerToolName, + canonicalName, + providerToolName, + mcpRawName: rawName, + }, + ], + ]); + mockLoadToolsUtil.mockResolvedValueOnce({ loadedTools: [loadedTool], toolContextMap: {} }); + + const result = await loadToolsForExecution({ + req, + res: {}, + agent: { id: 'agent_mcp' }, + toolNames: [providerToolName], + toolRegistry, + actionsEnabled: false, + }); + + expect(mockLoadToolsUtil).toHaveBeenCalledWith( + expect.objectContaining({ tools: [canonicalName] }), + ); + expect(result.toolMap.get(normalizedToolName)).toBe(loadedTool); + expect(result.toolMap.get(providerToolName)).toBe(loadedTool); + expect(result.toolMap.get(canonicalName)).toBe(loadedTool); + }); }); describe('checkCapability logic', () => {