diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 3698ce49c2..b29e5bcad1 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -456,7 +456,7 @@ const loadTools = async ({ for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) { index++; /** @type {LCAvailableTools} */ - let availableTools; + let availableTools = options.mcpAvailableTools?.[serverName]; for (const config of toolConfigs) { try { if (failedMCPServers.has(serverName)) { diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 3889ae9622..7d61a2a3a5 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -339,6 +339,54 @@ describe('Tool Handlers', () => { ); }); + it('uses run-scoped MCP tool definitions before cache lookup', async () => { + const serverName = 'body-scoped'; + const toolKey = `search${Constants.mcp_delimiter}${serverName}`; + const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + const serverConfig = { + type: 'streamable-http', + url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }; + const runScopedTools = { + [toolKey]: { + function: { + name: toolKey, + description: 'Run-scoped search', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetServerConfig.mockResolvedValue(serverConfig); + mockCreateMCPTool.mockResolvedValue({ name: 'loaded-mcp-tool' }); + + const result = await loadTools({ + user: fakeUser._id.toString(), + tools: [toolKey], + options: { + mcpAvailableTools: { + [serverName]: runScopedTools, + }, + req: { + user: { id: fakeUser._id.toString(), role: 'USER' }, + body: requestBody, + }, + }, + }); + + expect(result.loadedTools).toEqual([{ name: 'loaded-mcp-tool' }]); + expect(mockGetMCPServerTools).not.toHaveBeenCalled(); + expect(mockCreateMCPTool).toHaveBeenCalledWith( + expect.objectContaining({ + availableTools: runScopedTools, + requestBody, + toolKey, + config: serverConfig, + }), + ); + }); + it('reuses discovered request-scoped MCP tool definitions within a server loop', async () => { const serverName = 'body-scoped'; const firstToolKey = `search${Constants.mcp_delimiter}${serverName}`; diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index aedc700204..ff4d5f6dc9 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -492,6 +492,7 @@ const OpenAIChatCompletionController = async (req, res) => { agent: ctx.agent ?? agent, signal: abortController.signal, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index f2d8992a7f..4247c99702 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -684,6 +684,7 @@ const createResponse = async (req, res) => { agent: ctx.agent ?? agent, signal: abortController.signal, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, @@ -858,6 +859,7 @@ const createResponse = async (req, res) => { agent: ctx.agent ?? agent, signal: abortController.signal, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 225634f01d..478c6c4e12 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -203,6 +203,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { toolNames, agent: ctx.agent, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 6a8e64361b..f47541bf05 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -265,12 +265,14 @@ function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) { * @param {object} params * @param {object} params.agent * @param {object} params.config + * @param {Record} [params.config.mcpAvailableTools] * @returns {object} */ function buildAgentToolContext({ agent, config }) { return { agent, toolRegistry: config.toolRegistry, + mcpAvailableTools: config.mcpAvailableTools, userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 36d9cd78fb..8cf71c0c02 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -530,6 +530,7 @@ const isBuiltInTool = (toolName) => * @returns {Promise<{ * toolDefinitions?: import('@librechat/api').LCTool[]; * toolRegistry?: Map; + * mcpAvailableTools?: Record; * userMCPAuthMap?: Record>; * hasDeferredTools?: boolean; * }>} @@ -605,6 +606,14 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to const emittedOAuthStarts = new Map(); const oauthToolCallIds = new Map(); const oauthStepIndexes = new Map(); + /** @type {Record} */ + const mcpAvailableTools = {}; + const rememberMCPAvailableTools = (serverName, availableTools) => { + if (!availableTools || Object.keys(availableTools).length === 0) { + return; + } + mcpAvailableTools[serverName] = availableTools; + }; const createOAuthEmitter = (serverName, index) => { return async (authURL, options) => { @@ -744,10 +753,14 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to return null; } - const cached = requiresEphemeralUserConnection(serverConfig) - ? null - : await getMCPServerTools(userId, serverName); + const requestScoped = requiresEphemeralUserConnection(serverConfig); + if (mcpAvailableTools[serverName]) { + return mcpAvailableTools[serverName]; + } + + const cached = requestScoped ? null : await getMCPServerTools(userId, serverName); if (cached) { + rememberMCPAvailableTools(serverName, cached); await addPendingOAuthServer(); return cached; } @@ -773,6 +786,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to requestBody: req.body, }); + rememberMCPAvailableTools(serverName, result?.availableTools); return result?.availableTools || null; }; @@ -897,6 +911,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }); if (result?.availableTools) { + rememberMCPAvailableTools(serverName, result.availableTools); logger.info(`[Tool Definitions] OAuth completed for ${serverName}, tools available`); return { serverName, success: true }; } @@ -1025,6 +1040,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to return { toolRegistry, + mcpAvailableTools, userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -1397,6 +1413,7 @@ async function loadAgentTools({ * @param {Object} params.agent - The agent object * @param {string[]} params.toolNames - Names of tools to load * @param {Map} [params.toolRegistry] - Tool registry + * @param {Record} [params.mcpAvailableTools] - Run-scoped MCP tool definitions * @param {Record>} [params.userMCPAuthMap] - User MCP auth map * @param {Object} [params.tool_resources] - Tool resources * @param {string|null} [params.streamId] - Stream ID for web search callbacks @@ -1410,6 +1427,7 @@ async function loadToolsForExecution({ agent, toolNames, toolRegistry, + mcpAvailableTools, userMCPAuthMap, tool_resources, streamId = null, @@ -1537,6 +1555,7 @@ async function loadToolsForExecution({ processFileURL, uploadImageBuffer, returnMetadata: true, + mcpAvailableTools, [Tools.web_search]: webSearchCallbacks, }, webSearch: appConfig?.webSearch, diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index bb6e4e8df1..80b7e8c317 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -759,6 +759,51 @@ describe('ToolService - Action Capability Gating', () => { expect(mockGetMCPServerTools).not.toHaveBeenCalled(); }); + it('returns run-scoped MCP tool definitions for request-scoped servers', async () => { + const serverName = 'ClickHouse'; + const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + req.body = { conversationId: 'conv-123', messageId: 'msg-123' }; + const availableTools = { + [mcpTool]: { + function: { + name: mcpTool, + description: 'List tables', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://mcp.example.com/{{LIBRECHAT_OPENID_ACCESS_TOKEN}}/mcp', + source: 'yaml', + }); + mockGetMCPServerTools.mockResolvedValue(null); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: serverTools ? Object.keys(serverTools) : [], + toolRegistry: new Map([[mcpTool, { name: mcpTool }]]), + hasDeferredTools: false, + }; + }); + reinitMCPServer.mockResolvedValue({ availableTools }); + + const result = await loadAgentTools({ + req, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([mcpTool]); + expect(result.mcpAvailableTools).toEqual({ [serverName]: availableTools }); + expect(mockGetMCPServerTools).not.toHaveBeenCalled(); + }); + it('should preserve pending-flow expiry for OAuth URLs captured during discovery', async () => { const serverName = 'Google-Workspace'; const authorizationUrl = 'https://auth.example.com/Google-Workspace'; @@ -921,6 +966,49 @@ describe('ToolService - Action Capability Gating', () => { expect(result.configurable.ptcToolMap.size).toBe(0); }); + it('passes run-scoped MCP tool definitions into PTC execution loading', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.programmatic_tools, + AgentCapabilities.execute_code, + ]; + const req = createMockReq(capabilities); + const serverName = 'ClickHouse'; + const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`; + const mcpAvailableTools = { + [serverName]: { + [mcpTool]: { + function: { + name: mcpTool, + description: 'List tables', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }; + const toolRegistry = new Map([[mcpTool, { name: mcpTool }]]); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + await loadToolsForExecution({ + req, + res: {}, + agent: { id: 'agent_ptc', tools: [Tools.execute_code] }, + toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING], + toolRegistry, + mcpAvailableTools, + actionsEnabled: false, + }); + + expect(mockLoadToolsUtil).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [mcpTool], + options: expect.objectContaining({ + mcpAvailableTools, + }), + }), + ); + }); + it('does not load PTC when programmatic tools capability is disabled', async () => { const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code]; const req = createMockReq(capabilities); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index a6150fccb8..82ca0e4ad3 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -26,6 +26,7 @@ import type { IMongoFile } from '@librechat/data-schemas'; import type { InitializeResultBase, ServerRequest, EndpointDbMethods } from '~/types'; import type { ResolvedManualSkill, ResolvedAlwaysApplySkill } from './skills'; import type { TFilterFilesByAgentAccess } from './resources'; +import type { LCAvailableTools } from '../mcp/types'; import { injectSkillCatalog, resolveManualSkills, @@ -241,6 +242,8 @@ export type InitializedAgent = Agent & { toolMap?: ToolMap; /** Tool registry for PTC and tool search (only present when MCP tools with env classification exist) */ toolRegistry?: LCToolRegistry; + /** Run-scoped MCP tool definitions for request-scoped servers. */ + mcpAvailableTools?: Record; /** Serializable tool definitions for event-driven execution */ toolDefinitions?: LCTool[]; /** Precomputed flag indicating if any tools have defer_loading enabled (for efficient runtime checks) */ @@ -344,6 +347,7 @@ export interface InitializeAgentParams { dynamicToolContextMap?: Record; userMCPAuthMap?: Record>; toolRegistry?: LCToolRegistry; + mcpAvailableTools?: Record; /** Serializable tool definitions for event-driven mode */ toolDefinitions?: LCTool[]; hasDeferredTools?: boolean; @@ -875,6 +879,7 @@ export async function initializeAgent( dynamicToolContextMap, userMCPAuthMap, toolDefinitions: loadedToolDefinitions, + mcpAvailableTools, hasDeferredTools, actionsEnabled, tools: structuredTools, @@ -885,6 +890,7 @@ export async function initializeAgent( dynamicToolContextMap: {}, userMCPAuthMap: undefined, toolRegistry: undefined, + mcpAvailableTools: undefined, toolDefinitions: [], hasDeferredTools: false, actionsEnabled: undefined, @@ -1189,6 +1195,7 @@ export async function initializeAgent( ...agent, resendFiles, toolRegistry, + mcpAvailableTools, tool_resources, userMCPAuthMap, toolDefinitions,