mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🎒 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:
parent
919a46312b
commit
65bca95023
9 changed files with 172 additions and 4 deletions
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue