🎒 fix: Carry Request-Scoped MCP Tools into PTC Execution (#13669)

* fix(mcp): preserve request-scoped tools for PTC execution

* fix(mcp): preserve run-scoped tools on initialized agents
This commit is contained in:
Danny Avila 2026-06-10 23:48:04 -04:00 committed by GitHub
parent 919a46312b
commit 65bca95023
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 172 additions and 4 deletions

View file

@ -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)) {

View file

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

View file

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

View file

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

View file

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

View file

@ -265,12 +265,14 @@ function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) {
* @param {object} params
* @param {object} params.agent
* @param {object} params.config
* @param {Record<string, import('@librechat/api').LCAvailableTools>} [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,

View file

@ -530,6 +530,7 @@ const isBuiltInTool = (toolName) =>
* @returns {Promise<{
* toolDefinitions?: import('@librechat/api').LCTool[];
* toolRegistry?: Map<string, import('@librechat/api').LCTool>;
* mcpAvailableTools?: Record<string, import('@librechat/api').LCAvailableTools>;
* userMCPAuthMap?: Record<string, Record<string, string>>;
* 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<string, import('@librechat/api').LCAvailableTools>} */
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<string, import('@librechat/api').LCAvailableTools>} [params.mcpAvailableTools] - Run-scoped MCP tool definitions
* @param {Record<string, Record<string, string>>} [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,

View file

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

View file

@ -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<string, LCAvailableTools>;
/** 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<string, unknown>;
userMCPAuthMap?: Record<string, Record<string, string>>;
toolRegistry?: LCToolRegistry;
mcpAvailableTools?: Record<string, LCAvailableTools>;
/** 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,