LibreChat/api/server/services/ToolService.js
Danny Avila 49859c04a2
🗄️ fix: Gate Request-Scoped MCP Servers Out of Persistent Tool Cache (#13672)
* 🗄️ fix: Gate Request-Scoped MCP Servers Out of Persistent Tool Cache

PR #13626 established that request-scoped MCP servers (runtime
OPENID/GRAPH/BODY placeholders) must not use the persistent 12h tool
cache, but only gated three of five touchpoints. The panel endpoint
still back-filled the cache and the OAuth callback still wrote to it,
while agent loading read those entries ungated — pinning ephemeral
model-spec/agent toolsets to stale definitions for up to 12h.

Centralize the invariant in createMCPToolCacheService: a getServerConfig
resolver dep gates both writers and a new service-owned getMCPServerTools
read, so every current and future caller is covered. Callers that already
hold the parsed config pass it to skip resolution; the per-call skipCache
flag and duplicated call-site gates are removed in favor of the single
config-based mechanism. Resolution failures fail open to preserve prior
behavior.

* 🩹 fix: Address Codex Review on Cache Gating

- Repair getCachedTools.spec.js, which destructured the relocated
  getMCPServerTools directly from the module; its coverage now lives in
  the service-level tools.spec.ts.
- Resolve the merged (Config-tier-aware) server config in the OAuth
  callback before writing tool definitions, so the cache gate detects
  request-scoped servers supplied via admin Config overlays that the
  base registry lookup cannot see.
- Discover tools actively for request-scoped servers in the panel
  endpoint via ephemeral reinitialization: such servers have no stored
  app/user connections, so the previous getServerToolFunctions fallback
  returned an empty toolset once the cache read was gated.

* 🧵 fix: Address Second Codex Review on Cache Gating

- Resolve the merged server config before the OAuth callback reconnects,
  so the connection itself uses Config-tier overlays rather than only
  the subsequent cache write.
- Pass Config-tier candidates into the panel's request-scoped discovery,
  matching the reinitialize route: reinitMCPServer forwards configServers
  (not the provided serverConfig) to its OAuth discovery fallback.
- Document the accepted read-path trade-off: the gate resolver sees base
  configs only, all writers pass merged configs, so a pre-gating or
  overlay-divergent entry survives at most one cache TTL.

* 🚏 chore: Rework Cache Gating for BODY-Only Request Scoping

After #13673 narrowed requiresEphemeralUserConnection to BODY
placeholders, the central gate follows the predicate unchanged, but the
panel's active discovery no longer serves a purpose: the only remaining
request-scoped class cannot connect outside a chat turn, so the
reinitialization attempt would always fail at the missing-body check.
Remove that path; OpenID/Graph servers are persistent user-scoped again
and flow through the stored-connection and cache lookups as before.

Flip test fixtures that used OPENID placeholders to denote
request-scoped configs over to BODY placeholders.

* 🪟 fix: Check Config Overlays in Agent-Loading Cache Reads

The cache service's registry resolver sees only base YAML/DB configs, so
a BODY placeholder introduced by a request-tier Config overlay was
invisible to the gate on the agent-loading read path: model-spec and
ephemeral-agent expansion could read a leftover persistent entry and pin
stale concrete tool names instead of the mcp_all fresh-discovery path.

Check the raw overlay candidate inline in loadEphemeralAgent and
loadAddedAgent — a pure placeholder scan with no extra IO — and skip the
cache read when the overlay makes the server request-scoped. Widen
UserScopedConnectionConfig so raw (pre-inspection) configs qualify for
the scoping predicates, which only check key presence.

* 🧪 test: Guard Run-Scoped MCP Definition Handoff Boundaries

The original ClickHouse breaker storm regressed precisely at field
pass-through boundaries that unit tests of each end could not see:
initializeAgent dropping mcpAvailableTools from its destructure, and the
agent tool context losing it on the way into ON_TOOL_EXECUTE. Add direct
guards on both hops: the loadTools result must surface on the
initialized agent, and the captured toolExecuteOptions closure must
forward it to loadToolsForExecution.
2026-06-13 11:26:49 -04:00

1755 lines
57 KiB
JavaScript

const { logger } = require('@librechat/data-schemas');
const { tool: toolFn, DynamicStructuredTool } = require('@librechat/agents/langchain/tools');
const {
sleep,
createToolSearch,
createBashExecutionTool,
Constants: AgentConstants,
createBashProgrammaticToolCallingTool,
} = require('@librechat/agents');
const {
sendEvent,
getToolkitKey,
getUserMCPAuthMap,
loadToolDefinitions,
GenerationJobManager,
isActionDomainAllowed,
buildWebSearchContext,
buildImageToolContext,
buildToolClassification,
getMissingCustomUserVars,
buildWebSearchDynamicContext,
getCodeApiAuthHeaders,
getReplayablePendingMCPOAuthStart,
getMCPServerNamesFromTools,
buildMCPAuthToolCall,
buildMCPAuthStepId,
buildMCPAuthRunStepEvent,
buildMCPAuthRunStepDeltaEvent,
buildMCPAuthRunStepCompletedEvent,
isFileAuthoringToolDefinition,
} = require('@librechat/api');
const {
Time,
Tools,
Constants,
CacheKeys,
ErrorTypes,
ContentTypes,
imageGenTools,
EModelEndpoint,
EToolResources,
isActionTool,
actionDelimiter,
ImageVisionTool,
openapiToFunction,
AgentCapabilities,
isEphemeralAgentId,
validateActionDomain,
actionDomainSeparator,
defaultAgentCapabilities,
validateAndParseOpenAPISpec,
} = require('librechat-data-provider');
const {
createActionTool,
legacyDomainEncode,
decryptMetadata,
loadActionSets,
domainParser,
} = require('./ActionService');
const {
getEndpointsConfig,
getMCPServerTools,
getCachedTools,
} = require('~/server/services/Config');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileSearch');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest');
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { createMCPPermissionContext, resolveConfigServers } = require('~/server/services/MCP');
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
const { findPluginAuthsByKeys } = require('~/models');
const { getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { getLogStores } = require('~/cache');
const domainSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
/**
* Collapse every `actionDomainSeparator` sequence in the encoded-domain
* suffix of a fully-qualified action tool name to an underscore. Agents
* can store tool names in the raw `domainParser(..., true)` output,
* which for short hostnames is a `---`-separated string (e.g.
* `medium---com`). The lookup maps below are always keyed with the
* `_`-collapsed domain, so every read must normalize that suffix or
* short-hostname tools silently fail to resolve.
*
* The operationId portion (everything before the last `actionDelimiter`)
* is deliberately left untouched: `openapiToFunction` preserves hyphens
* in generated operationIds, so two specs can legitimately produce
* operationIds that differ only in hyphens-vs-underscores (e.g.
* `get_foo---bar` vs `get_foo_bar`). Collapsing the operationId would
* merge those into a single map slot and silently drop one tool.
*/
const normalizeActionToolName = (toolName) => {
const delimiterIndex = toolName.lastIndexOf(actionDelimiter);
if (delimiterIndex === -1) {
return toolName;
}
const prefixEnd = delimiterIndex + actionDelimiter.length;
const encodedDomain = toolName.slice(prefixEnd);
return toolName.slice(0, prefixEnd) + encodedDomain.replace(domainSeparatorRegex, '_');
};
/**
* Populate a `toolToAction` map with one slot per fully-qualified tool
* name (`<operationId><actionDelimiter><encoded-domain>`). Both the new
* and the legacy encodings of the domain are registered for every
* function so agents whose stored tool names predate the current
* encoding still resolve correctly.
*
* Indexing on the full tool name instead of the encoded domain alone is
* what makes multi-action agents work when two actions share a hostname:
* the operationId disambiguates them, so neither overwrites the other.
*
* Two actions that additionally share the same operationId still
* collide (nothing in the key distinguishes them). That case is
* pathological — `sanitizeOperationId` plus OpenAPI's own uniqueness
* requirement make it very unlikely — but when it does happen we log
* a warning so the silent-overwrite mode from the original bug cannot
* reappear under a different disguise.
*/
const registerActionTools = ({
toolToAction,
functionSignatures,
normalizedDomain,
legacyNormalized,
makeEntry,
}) => {
const setKey = (key, entry) => {
if (toolToAction.has(key)) {
logger.warn(
`[Actions] operationId collision: "${key}" already registered; ` +
`action "${entry.action?.action_id}" overwrites the previous entry. ` +
`Two actions share both the operationId and the encoded hostname.`,
);
}
toolToAction.set(key, entry);
};
for (const sig of functionSignatures) {
const entry = makeEntry(sig);
// Use `sig.name` verbatim: `openapiToFunction` keeps hyphens in
// generated operationIds, so `get_foo---bar` and `get_foo_bar` are
// distinct operations on the same spec. `normalizeActionToolName`
// only touches the encoded-domain suffix at lookup time, so map
// keys and lookups stay consistent without merging distinct
// operationIds into the same slot.
setKey(`${sig.name}${actionDelimiter}${normalizedDomain}`, entry);
if (legacyNormalized !== normalizedDomain) {
setKey(`${sig.name}${actionDelimiter}${legacyNormalized}`, entry);
}
}
};
/**
* Resolves the set of enabled agent capabilities from endpoints config,
* falling back to app-level or default capabilities for ephemeral agents.
* @param {ServerRequest} req
* @param {Object} appConfig
* @param {string} agentId
* @returns {Promise<Set<string>>}
*/
async function resolveAgentCapabilities(req, appConfig, agentId) {
const endpointsConfig = await getEndpointsConfig(req);
let capabilities = new Set(endpointsConfig?.[EModelEndpoint.agents]?.capabilities ?? []);
if (capabilities.size === 0 && isEphemeralAgentId(agentId)) {
capabilities = new Set(
appConfig.endpoints?.[EModelEndpoint.agents]?.capabilities ?? defaultAgentCapabilities,
);
}
return capabilities;
}
/**
* Processes the required actions by calling the appropriate tools and returning the outputs.
* @param {OpenAIClient} client - OpenAI or StreamRunManager Client.
* @param {RequiredAction} requiredActions - The current required action.
* @returns {Promise<ToolOutput>} The outputs of the tools.
*/
const processVisionRequest = async (client, currentAction) => {
if (!client.visionPromise) {
return {
tool_call_id: currentAction.toolCallId,
output: 'No image details found.',
};
}
/** @type {ChatCompletion | undefined} */
const completion = await client.visionPromise;
if (completion && completion.usage) {
recordUsage({
user: client.req.user.id,
model: client.req.body.model,
conversationId: (client.responseMessage ?? client.finalMessage).conversationId,
...completion.usage,
});
}
const output = completion?.choices?.[0]?.message?.content ?? 'No image details found.';
return {
tool_call_id: currentAction.toolCallId,
output,
};
};
/**
* Processes return required actions from run.
* @param {OpenAIClient | StreamRunManager} client - OpenAI (legacy) or StreamRunManager Client.
* @param {RequiredAction[]} requiredActions - The required actions to submit outputs for.
* @returns {Promise<ToolOutputs>} The outputs of the tools.
*/
async function processRequiredActions(client, requiredActions) {
logger.debug(
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
requiredActions,
);
const appConfig = client.req.config;
const toolDefinitions = (await getCachedTools()) ?? {};
const seenToolkits = new Set();
const tools = requiredActions
.map((action) => {
const toolName = action.tool;
const toolDef = toolDefinitions[toolName];
if (toolDef && !manifestToolMap[toolName]) {
for (const toolkit of toolkits) {
if (seenToolkits.has(toolkit.pluginKey)) {
return;
} else if (toolName.startsWith(`${toolkit.pluginKey}_`)) {
seenToolkits.add(toolkit.pluginKey);
return toolkit.pluginKey;
}
}
}
return toolName;
})
.filter((toolName) => !!toolName);
const { loadedTools } = await loadTools({
user: client.req.user.id,
model: client.req.body.model ?? 'gpt-4o-mini',
tools,
functions: true,
endpoint: client.req.body.endpoint,
options: {
processFileURL,
req: client.req,
uploadImageBuffer,
openAIApiKey: client.apiKey,
returnMetadata: true,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
const ToolMap = loadedTools.reduce((map, tool) => {
map[tool.name] = tool;
return map;
}, {});
const promises = [];
let actionSetsData = null;
let isActionTool = false;
const ActionToolMap = {};
const ActionBuildersMap = {};
for (let i = 0; i < requiredActions.length; i++) {
const currentAction = requiredActions[i];
if (currentAction.tool === ImageVisionTool.function.name) {
promises.push(processVisionRequest(client, currentAction));
continue;
}
let tool = ToolMap[currentAction.tool] ?? ActionToolMap[currentAction.tool];
const handleToolOutput = async (output) => {
requiredActions[i].output = output;
/** @type {FunctionToolCall & PartMetadata} */
const toolCall = {
function: {
name: currentAction.tool,
arguments: JSON.stringify(currentAction.toolInput),
output,
},
id: currentAction.toolCallId,
type: 'function',
progress: 1,
action: isActionTool,
};
const toolCallIndex = client.mappedOrder.get(toolCall.id);
if (imageGenTools.has(currentAction.tool)) {
const imageOutput = output;
toolCall.function.output = `${currentAction.tool} displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.`;
// Streams the "Finished" state of the tool call in the UI
client.addContentData({
[ContentTypes.TOOL_CALL]: toolCall,
index: toolCallIndex,
type: ContentTypes.TOOL_CALL,
});
await sleep(500);
/** @type {ImageFile} */
const imageDetails = {
...imageOutput,
...currentAction.toolInput,
};
const image_file = {
[ContentTypes.IMAGE_FILE]: imageDetails,
type: ContentTypes.IMAGE_FILE,
// Replace the tool call output with Image file
index: toolCallIndex,
};
client.addContentData(image_file);
// Update the stored tool call
client.seenToolCalls && client.seenToolCalls.set(toolCall.id, toolCall);
return {
tool_call_id: currentAction.toolCallId,
output: toolCall.function.output,
};
}
client.seenToolCalls && client.seenToolCalls.set(toolCall.id, toolCall);
client.addContentData({
[ContentTypes.TOOL_CALL]: toolCall,
index: toolCallIndex,
type: ContentTypes.TOOL_CALL,
// TODO: to append tool properties to stream, pass metadata rest to addContentData
// result: tool.result,
});
return {
tool_call_id: currentAction.toolCallId,
output,
};
};
if (!tool) {
// throw new Error(`Tool ${currentAction.tool} not found.`);
if (!actionSetsData) {
/** @type {Action[]} */
const actionSets =
(await loadActionSets({
assistant_id: client.req.body.assistant_id,
})) ?? [];
// See registerActionTools for the key-shape rationale.
const toolToAction = new Map();
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
const legacyDomain = legacyDomainEncode(action.metadata.domain);
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
appConfig?.actions?.allowedDomains,
appConfig?.actions?.allowedAddresses,
);
if (!isDomainAllowed) {
continue;
}
// Validate and parse OpenAPI spec
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
throw new Error(
`Invalid spec: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
);
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: client.req.user.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
// Process the OpenAPI spec
const { requestBuilders, functionSignatures } = openapiToFunction(validationResult.spec);
// Store encrypted values for OAuth flow
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
// Decrypt metadata
const decryptedAction = { ...action };
decryptedAction.metadata = await decryptMetadata(action.metadata);
registerActionTools({
toolToAction,
functionSignatures,
normalizedDomain,
legacyNormalized,
makeEntry: (sig) => ({
action: decryptedAction,
requestBuilder: requestBuilders[sig.name],
encrypted,
}),
});
// Store builders for reuse
ActionBuildersMap[action.metadata.domain] = requestBuilders;
}
actionSetsData = toolToAction;
}
const entry = actionSetsData.get(normalizeActionToolName(currentAction.tool));
if (!entry) {
continue;
}
const { action, requestBuilder, encrypted } = entry;
// We've already decrypted the metadata, so we can pass it directly
const _allowedDomains = appConfig?.actions?.allowedDomains;
const _allowedAddresses = appConfig?.actions?.allowedAddresses;
tool = await createActionTool({
userId: client.req.user.id,
res: client.res,
action,
requestBuilder,
// Note: intentionally not passing zodSchema, name, and description for assistants API
encrypted, // Pass the encrypted values for OAuth flow
useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0,
allowedAddresses: _allowedAddresses,
});
if (!tool) {
logger.warn(
`Invalid action: user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id} | toolName: ${currentAction.tool}`,
);
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
}
isActionTool = !!tool;
ActionToolMap[currentAction.tool] = tool;
}
if (currentAction.tool === 'calculator') {
currentAction.toolInput = currentAction.toolInput.input;
}
const handleToolError = (error) => {
logger.error(
`tool_call_id: ${currentAction.toolCallId} | Error processing tool ${currentAction.tool}`,
error,
);
return {
tool_call_id: currentAction.toolCallId,
output: `Error processing tool ${currentAction.tool}: ${redactMessage(error.message, 256)}`,
};
};
try {
const promise = tool
._call(currentAction.toolInput)
.then(handleToolOutput)
.catch(handleToolError);
promises.push(promise);
} catch (error) {
const toolOutputError = handleToolError(error);
promises.push(Promise.resolve(toolOutputError));
}
}
return {
tool_outputs: await Promise.all(promises),
};
}
/**
* Processes the runtime tool calls and returns the tool classes.
* @param {Object} params - Run params containing user and request information.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The request object.
* @param {AbortSignal} params.signal
* @param {Pick<Agent, 'id' | 'provider' | 'model' | 'tools'} params.agent - The agent to load tools for.
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
* @returns {Promise<{
* tools?: StructuredTool[];
* toolContextMap?: Record<string, unknown>;
* dynamicToolContextMap?: Record<string, unknown>;
* userMCPAuthMap?: Record<string, Record<string, string>>;
* toolRegistry?: Map<string, import('~/utils/toolClassification').LCTool>;
* hasDeferredTools?: boolean;
* }>} The agent tools and registry.
*/
/** Native LibreChat tools that are not in the manifest */
const nativeTools = new Set([Tools.execute_code, Tools.file_search, Tools.web_search]);
/** Checks if a tool name is a known built-in tool */
const isBuiltInTool = (toolName) =>
Boolean(
manifestToolMap[toolName] ||
toolkits.some((t) => t.pluginKey === toolName) ||
nativeTools.has(toolName),
);
/**
* Loads only tool definitions without creating tool instances.
* This is the efficient path for event-driven mode where tools are loaded on-demand.
*
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} [params.res] - The response object for SSE events
* @param {Object} params.agent - The agent configuration
* @param {string|null} [params.streamId] - Stream ID for resumable mode
* @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;
* }>}
*/
async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, tool_resources }) {
if (!agent.tools || agent.tools.length === 0) {
return { toolDefinitions: [] };
}
if (
agent.tools.length === 1 &&
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
) {
return { toolDefinitions: [] };
}
const appConfig = req.config;
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
const checkCapability = (capability) => enabledCapabilities.has(capability);
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
const actionsEnabled = checkCapability(AgentCapabilities.actions);
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
const programmaticToolsEnabled = enabledCapabilities.has(AgentCapabilities.programmatic_tools);
const codeExecutionEnabled =
agent.tools?.includes(Tools.execute_code) === true &&
enabledCapabilities.has(AgentCapabilities.execute_code);
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
const mcpPermissionContext = createMCPPermissionContext(req);
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
const filteredTools = agent.tools?.filter((tool) => {
if (tool === Tools.file_search) {
return checkCapability(AgentCapabilities.file_search);
}
if (tool === Tools.execute_code) {
return checkCapability(AgentCapabilities.execute_code);
}
if (tool === Tools.web_search) {
return checkCapability(AgentCapabilities.web_search);
}
if (isActionTool(tool)) {
return actionsEnabled;
}
if (tool?.includes(Constants.mcp_delimiter)) {
return areToolsEnabled && canUseMCP;
}
if (!areToolsEnabled) {
return false;
}
return true;
});
if (!filteredTools || filteredTools.length === 0) {
return { toolDefinitions: [] };
}
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
if (filteredTools?.some((t) => t.includes(Constants.mcp_delimiter))) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: filteredTools,
userId: req.user.id,
findPluginAuthsByKeys,
});
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const configServers = await resolveConfigServers(req);
const pendingOAuthServers = new Set();
const pendingOAuthStarts = new Map();
const emittedOAuthStarts = new Map();
const oauthToolCallIds = new Map();
const oauthStepIndexes = new Map();
/** @type {Record<string, import('@librechat/api').LCAvailableTools>} */
const mcpAvailableTools = {};
const requestScopedConnections = getMCPRequestContext(req, res);
const rememberMCPAvailableTools = (serverName, availableTools) => {
if (!availableTools || Object.keys(availableTools).length === 0) {
return;
}
mcpAvailableTools[serverName] = availableTools;
};
const createOAuthEmitter = (serverName, index) => {
return async (authURL, options) => {
if (emittedOAuthStarts.get(serverName) === authURL) {
return;
}
emittedOAuthStarts.set(serverName, authURL);
const flowId =
oauthToolCallIds.get(serverName) ?? `${req.user.id}:${serverName}:${Date.now()}`;
const stepId = buildMCPAuthStepId(serverName);
oauthToolCallIds.set(serverName, flowId);
oauthStepIndexes.set(serverName, index);
const toolCall = buildMCPAuthToolCall({
id: flowId,
serverName,
});
const runStepEvent = buildMCPAuthRunStepEvent({ stepId, toolCall, index });
const runStepDeltaEvent = buildMCPAuthRunStepDeltaEvent({
authURL,
stepId,
toolCall,
options,
});
if (streamId) {
await GenerationJobManager.emitChunk(streamId, runStepEvent);
await GenerationJobManager.emitChunk(streamId, runStepDeltaEvent);
} else if (res && !res.writableEnded) {
sendEvent(res, runStepEvent);
sendEvent(res, runStepDeltaEvent);
} else {
logger.warn(
`[Tool Definitions] Cannot emit OAuth event for ${serverName}: no streamId and res not available`,
);
}
};
};
const createOAuthEndEmitter = (serverName) => {
return async () => {
const stepId = buildMCPAuthStepId(serverName);
const toolCall = buildMCPAuthToolCall({
id: oauthToolCallIds.get(serverName),
args: '',
output: 'OAuth authentication completed',
serverName,
type: 'tool_call',
});
const runStepCompletedEvent = buildMCPAuthRunStepCompletedEvent({
stepId,
toolCall,
index: oauthStepIndexes.get(serverName) ?? 0,
});
if (streamId) {
await GenerationJobManager.emitChunk(streamId, runStepCompletedEvent);
} else if (res && !res.writableEnded) {
sendEvent(res, runStepCompletedEvent);
} else {
logger.warn(
`[Tool Definitions] Cannot emit OAuth completion for ${serverName}: no streamId and res not available`,
);
}
};
};
const getPendingOAuthStartForEmit = async (serverName) => {
const cachedOAuthStart = pendingOAuthStarts.get(serverName);
if (cachedOAuthStart?.options?.expiresAt != null) {
return cachedOAuthStart;
}
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
flowManager,
userId: req.user.id,
serverName,
});
if (!pendingOAuthStart) {
return cachedOAuthStart;
}
if (!cachedOAuthStart || pendingOAuthStart.authURL === cachedOAuthStart.authURL) {
pendingOAuthStarts.set(serverName, pendingOAuthStart);
return pendingOAuthStart;
}
return cachedOAuthStart;
};
const getOrFetchMCPServerTools = async (userId, serverName) => {
const addPendingOAuthServer = async () => {
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
flowManager,
userId,
serverName,
});
if (!pendingOAuthStart) {
return false;
}
pendingOAuthServers.add(serverName);
pendingOAuthStarts.set(serverName, pendingOAuthStart);
return true;
};
let serverConfig;
try {
serverConfig =
configServers?.[serverName] ??
(await getMCPServersRegistry().getServerConfig(serverName, userId, configServers));
} catch (err) {
logger.warn(
`[Tool Definitions] MCP registry unavailable while resolving '${serverName}': ${
err?.message ?? err
}. Skipping MCP tool exposure for this lookup.`,
);
return null;
}
if (!serverConfig) {
logger.warn(
`[Tool Definitions] Skipping MCP server '${serverName}': no server config found (server may have been removed).`,
);
return null;
}
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const missingUserVars = getMissingCustomUserVars(serverConfig, customUserVars);
if (missingUserVars.length > 0) {
logger.warn(
`[Tool Definitions] Skipping MCP server '${serverName}': required user-provided variable(s) not set: ${missingUserVars.join(
', ',
)}. Tools will not be exposed until the user configures them.`,
);
return null;
}
if (mcpAvailableTools[serverName]) {
return mcpAvailableTools[serverName];
}
const cached = await getMCPServerTools(userId, serverName, serverConfig);
if (cached) {
rememberMCPAvailableTools(serverName, cached);
await addPendingOAuthServer();
return cached;
}
if (await addPendingOAuthServer()) {
return null;
}
const oauthStart = async (authURL, options) => {
pendingOAuthServers.add(serverName);
if (typeof authURL === 'string' && authURL.length > 0) {
pendingOAuthStarts.set(serverName, { authURL, options });
}
};
const result = await reinitMCPServer({
user: req.user,
oauthStart,
flowManager,
serverName,
configServers,
userMCPAuthMap,
requestBody: req.body,
requestScopedConnections,
});
rememberMCPAvailableTools(serverName, result?.availableTools);
return result?.availableTools || null;
};
const getActionToolDefinitions = async (agentId, actionToolNames) => {
const actionSets = (await loadActionSets({ agent_id: agentId })) ?? [];
if (actionSets.length === 0) {
return [];
}
const definitions = [];
const allowedDomains = appConfig?.actions?.allowedDomains;
const allowedAddresses = appConfig?.actions?.allowedAddresses;
const normalizedToolNames = new Set(
actionToolNames.map((n) => n.replace(domainSeparatorRegex, '_')),
);
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
const legacyDomain = legacyDomainEncode(action.metadata.domain);
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
allowedDomains,
allowedAddresses,
);
if (!isDomainAllowed) {
logger.warn(
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
`Add it to librechat.yaml actions.allowedDomains to enable this action.`,
);
continue;
}
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
logger.warn(`[Actions] Invalid OpenAPI spec for domain: ${domain}`);
continue;
}
const { functionSignatures } = openapiToFunction(validationResult.spec, true);
for (const sig of functionSignatures) {
const toolName = `${sig.name}${actionDelimiter}${normalizedDomain}`;
const legacyToolName = `${sig.name}${actionDelimiter}${legacyNormalized}`;
if (!normalizedToolNames.has(toolName) && !normalizedToolNames.has(legacyToolName)) {
continue;
}
definitions.push({
name: toolName,
description: sig.description,
parameters: sig.parameters,
});
}
}
return definitions;
};
let { toolDefinitions, toolRegistry, hasDeferredTools } = await loadToolDefinitions(
{
userId: req.user.id,
agentId: agent.id,
tools: filteredTools,
toolOptions: agent.tool_options,
deferredToolsEnabled,
programmaticToolsEnabled,
codeExecutionEnabled,
provider: agent.provider,
},
{
isBuiltInTool,
getOrFetchMCPServerTools,
getActionToolDefinitions,
},
);
for (const serverName of getMCPServerNamesFromTools(filteredTools)) {
if (pendingOAuthServers.has(serverName)) {
continue;
}
const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({
flowManager,
userId: req.user.id,
serverName,
});
if (pendingOAuthStart) {
pendingOAuthServers.add(serverName);
pendingOAuthStarts.set(serverName, pendingOAuthStart);
}
}
if (pendingOAuthServers.size > 0 && (res || streamId)) {
const serverNames = Array.from(pendingOAuthServers);
logger.info(
`[Tool Definitions] OAuth required for ${serverNames.length} server(s): ${serverNames.join(', ')}. Emitting events and waiting.`,
);
const oauthWaitPromises = serverNames.map(async (serverName, index) => {
try {
const pendingOAuthStart = await getPendingOAuthStartForEmit(serverName);
const oauthStart = createOAuthEmitter(serverName, index);
if (pendingOAuthStart) {
await oauthStart(pendingOAuthStart.authURL, pendingOAuthStart.options);
}
const result = await reinitMCPServer({
user: req.user,
serverName,
configServers,
userMCPAuthMap,
flowManager,
requestBody: req.body,
returnOnOAuth: false,
oauthStart,
oauthEnd: createOAuthEndEmitter(serverName),
connectionTimeout: Time.TWO_MINUTES,
});
if (result?.availableTools) {
rememberMCPAvailableTools(serverName, result.availableTools);
logger.info(`[Tool Definitions] OAuth completed for ${serverName}, tools available`);
return { serverName, success: true };
}
return { serverName, success: false };
} catch (error) {
logger.debug(`[Tool Definitions] OAuth wait failed for ${serverName}:`, error?.message);
return { serverName, success: false };
}
});
const results = await Promise.allSettled(oauthWaitPromises);
const successfulServers = results
.filter((r) => r.status === 'fulfilled' && r.value.success)
.map((r) => r.value.serverName);
if (successfulServers.length > 0) {
logger.info(
`[Tool Definitions] Reloading tools after OAuth for: ${successfulServers.join(', ')}`,
);
const reloadResult = await loadToolDefinitions(
{
userId: req.user.id,
agentId: agent.id,
tools: filteredTools,
toolOptions: agent.tool_options,
deferredToolsEnabled,
programmaticToolsEnabled,
codeExecutionEnabled,
provider: agent.provider,
},
{
isBuiltInTool,
getOrFetchMCPServerTools,
getActionToolDefinitions,
},
);
toolDefinitions = reloadResult.toolDefinitions;
toolRegistry = reloadResult.toolRegistry;
hasDeferredTools = reloadResult.hasDeferredTools;
}
}
/** @type {Record<string, string>} */
const toolContextMap = {};
/** @type {Record<string, string>} */
const dynamicToolContextMap = {};
const hasWebSearch = filteredTools.includes(Tools.web_search);
const hasFileSearch = filteredTools.includes(Tools.file_search);
const hasExecuteCode = filteredTools.includes(Tools.execute_code);
if (hasWebSearch) {
toolContextMap[Tools.web_search] = buildWebSearchContext();
dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext(
req.conversationCreatedAt,
);
}
/**
* `files` carry the upload session_ids; we surface them so client.js can
* seed `Graph.sessions[EXECUTE_CODE]` before run start. Without that seed,
* the agents-side `ToolNode.getCodeSessionContext` returns undefined on
* call #1, `_injected_files` is never set on the tool call, and the
* sandbox can't see the prior turn's generated artifacts on first read.
*/
let primedCodeFiles;
if (hasExecuteCode && tool_resources) {
try {
const { toolContext, files } = await primeCodeFiles({
req,
tool_resources,
agentId: agent.id,
});
if (toolContext) {
dynamicToolContextMap[Tools.execute_code] = toolContext;
}
if (files?.length) {
primedCodeFiles = files;
}
} catch (error) {
logger.error('[loadToolDefinitionsWrapper] Error priming code files:', error);
}
}
if (hasFileSearch && tool_resources) {
try {
const { toolContext } = await primeSearchFiles({
req,
tool_resources,
agentId: agent.id,
});
if (toolContext) {
dynamicToolContextMap[Tools.file_search] = toolContext;
}
} catch (error) {
logger.error('[loadToolDefinitionsWrapper] Error priming search files:', error);
}
}
const imageFiles = tool_resources?.[EToolResources.image_edit]?.files ?? [];
if (imageFiles.length > 0) {
const hasOaiImageGen = filteredTools.includes('image_gen_oai');
const hasGeminiImageGen = filteredTools.includes('gemini_image_gen');
if (hasOaiImageGen) {
const toolContext = buildImageToolContext({
imageFiles,
toolName: `${EToolResources.image_edit}_oai`,
contextDescription: 'image editing',
});
if (toolContext) {
dynamicToolContextMap.image_edit_oai = toolContext;
}
}
if (hasGeminiImageGen) {
const toolContext = buildImageToolContext({
imageFiles,
toolName: 'gemini_image_gen',
contextDescription: 'image context',
});
if (toolContext) {
dynamicToolContextMap.gemini_image_gen = toolContext;
}
}
}
return {
toolRegistry,
mcpAvailableTools,
requestScopedConnections,
userMCPAuthMap,
toolContextMap,
dynamicToolContextMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
primedCodeFiles,
};
}
/**
* Loads agent tools for initialization or execution.
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {Object} params.agent - The agent configuration
* @param {AbortSignal} [params.signal] - Abort signal
* @param {Object} [params.tool_resources] - Tool resources
* @param {string} [params.openAIApiKey] - OpenAI API key
* @param {string|null} [params.streamId] - Stream ID for resumable mode
* @param {boolean} [params.definitionsOnly=true] - When true, returns only serializable
* tool definitions without creating full tool instances. Use for event-driven mode
* where tools are loaded on-demand during execution.
*/
async function loadAgentTools({
req,
res,
agent,
signal,
tool_resources,
openAIApiKey,
streamId = null,
definitionsOnly = true,
}) {
if (definitionsOnly) {
return loadToolDefinitionsWrapper({ req, res, agent, streamId, tool_resources });
}
if (!agent.tools || agent.tools.length === 0) {
return { toolDefinitions: [] };
} else if (
agent.tools &&
agent.tools.length === 1 &&
/** Legacy handling for `ocr` as may still exist in existing Agents */
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
) {
return { toolDefinitions: [] };
}
const appConfig = req.config;
const enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent.id);
const checkCapability = (capability) => {
const enabled = enabledCapabilities.has(capability);
if (!enabled) {
const isToolCapability = [
AgentCapabilities.file_search,
AgentCapabilities.execute_code,
AgentCapabilities.web_search,
].includes(capability);
const suffix = isToolCapability ? ' despite configured tool.' : '.';
logger.warn(
`Capability "${capability}" disabled${suffix} User: ${req.user.id} | Agent: ${agent.id}`,
);
}
return enabled;
};
const areToolsEnabled = checkCapability(AgentCapabilities.tools);
const actionsEnabled = checkCapability(AgentCapabilities.actions);
const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter));
const mcpPermissionContext = createMCPPermissionContext(req);
const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true;
let includesWebSearch = false;
const _agentTools = agent.tools?.filter((tool) => {
if (tool === Tools.file_search) {
return checkCapability(AgentCapabilities.file_search);
} else if (tool === Tools.execute_code) {
return checkCapability(AgentCapabilities.execute_code);
} else if (tool === Tools.web_search) {
includesWebSearch = checkCapability(AgentCapabilities.web_search);
return includesWebSearch;
} else if (isActionTool(tool)) {
return actionsEnabled;
} else if (tool?.includes(Constants.mcp_delimiter)) {
return areToolsEnabled && canUseMCP;
} else if (!areToolsEnabled) {
return false;
}
return true;
});
if (!_agentTools || _agentTools.length === 0) {
return {};
}
/** @type {ReturnType<typeof createOnSearchResults>} */
let webSearchCallbacks;
if (includesWebSearch) {
webSearchCallbacks = createOnSearchResults(res, streamId);
}
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
if (_agentTools?.some((t) => t.includes(Constants.mcp_delimiter))) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: _agentTools,
userId: req.user.id,
findPluginAuthsByKeys,
});
}
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
agent,
signal,
userMCPAuthMap,
functions: true,
user: req.user.id,
tools: _agentTools,
options: {
req,
res,
openAIApiKey,
tool_resources,
processFileURL,
uploadImageBuffer,
returnMetadata: true,
mcpPermissionContext,
requestScopedConnections: getMCPRequestContext(req, res),
[Tools.web_search]: webSearchCallbacks,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
/** Build tool registry from MCP tools and create PTC/tool search tools if configured */
const deferredToolsEnabled = checkCapability(AgentCapabilities.deferred_tools);
const programmaticToolsEnabled = enabledCapabilities.has(AgentCapabilities.programmatic_tools);
const codeExecutionEnabled =
agent.tools?.includes(Tools.execute_code) === true &&
enabledCapabilities.has(AgentCapabilities.execute_code);
const { toolRegistry, toolDefinitions, additionalTools, hasDeferredTools } =
await buildToolClassification({
loadedTools,
userId: req.user.id,
agentId: agent.id,
agentToolOptions: agent.tool_options,
deferredToolsEnabled,
programmaticToolsEnabled,
codeExecutionEnabled,
authHeaders: () => getCodeApiAuthHeaders(req),
});
const agentTools = [];
for (let i = 0; i < loadedTools.length; i++) {
const tool = loadedTools[i];
if (tool.name && (tool.name === Tools.execute_code || tool.name === Tools.file_search)) {
agentTools.push(tool);
continue;
}
if (!areToolsEnabled) {
continue;
}
if (tool.mcp === true) {
agentTools.push(tool);
continue;
}
if (tool instanceof DynamicStructuredTool) {
agentTools.push(tool);
continue;
}
const toolDefinition = {
name: tool.name,
schema: tool.schema,
description: tool.description,
};
if (imageGenTools.has(tool.name)) {
toolDefinition.responseFormat = 'content_and_artifact';
}
const toolInstance = toolFn(async (...args) => {
return tool['_call'](...args);
}, toolDefinition);
agentTools.push(toolInstance);
}
const ToolMap = loadedTools.reduce((map, tool) => {
map[tool.name] = tool;
return map;
}, {});
agentTools.push(...additionalTools);
const hasActionTools = _agentTools.some((t) => isActionTool(t));
if (!hasActionTools) {
return {
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
userMCPAuthMap,
toolContextMap,
dynamicToolContextMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
tools: agentTools,
primedCodeFiles,
};
}
const actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
if (actionSets.length === 0) {
if (_agentTools.length > 0 && agentTools.length === 0) {
logger.warn(`No tools found for the specified tool calls: ${_agentTools.join(', ')}`);
}
return {
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
userMCPAuthMap,
toolContextMap,
dynamicToolContextMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
tools: agentTools,
primedCodeFiles,
};
}
// See registerActionTools for the key-shape rationale.
const toolToAction = new Map();
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
const legacyDomain = legacyDomainEncode(action.metadata.domain);
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
appConfig?.actions?.allowedDomains,
appConfig?.actions?.allowedAddresses,
);
if (!isDomainAllowed) {
continue;
}
// Validate and parse OpenAPI spec once per action set
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
continue;
}
// SECURITY: Validate the domain from the spec matches the stored domain
// This is defense-in-depth to prevent any stored malicious actions
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue; // Skip this action rather than failing the entire request
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
// Decrypt metadata once per action set
const decryptedAction = { ...action };
decryptedAction.metadata = await decryptMetadata(action.metadata);
// Process the OpenAPI spec once per action set
const { requestBuilders, functionSignatures, zodSchemas } = openapiToFunction(
validationResult.spec,
true,
);
registerActionTools({
toolToAction,
functionSignatures,
normalizedDomain,
legacyNormalized,
makeEntry: (sig) => ({
action: decryptedAction,
requestBuilder: requestBuilders[sig.name],
zodSchema: zodSchemas[sig.name],
functionSignature: sig,
encrypted,
}),
});
}
// Now map tools to the processed action sets
const ActionToolMap = {};
for (const toolName of _agentTools) {
if (ToolMap[toolName]) {
continue;
}
const entry = toolToAction.get(normalizeActionToolName(toolName));
if (!entry) {
continue;
}
const { action, encrypted, zodSchema, requestBuilder, functionSignature } = entry;
const _allowedDomains = appConfig?.actions?.allowedDomains;
const _allowedAddresses = appConfig?.actions?.allowedAddresses;
const tool = await createActionTool({
userId: req.user.id,
res,
action,
requestBuilder,
zodSchema,
encrypted,
name: toolName,
description: functionSignature.description,
streamId,
useSSRFProtection: !Array.isArray(_allowedDomains) || _allowedDomains.length === 0,
allowedAddresses: _allowedAddresses,
});
if (!tool) {
logger.warn(
`Invalid action: user: ${req.user.id} | agent_id: ${agent.id} | toolName: ${toolName}`,
);
throw new Error(`{"type":"${ErrorTypes.INVALID_ACTION}"}`);
}
agentTools.push(tool);
ActionToolMap[toolName] = tool;
}
if (_agentTools.length > 0 && agentTools.length === 0) {
logger.warn(`No tools found for the specified tool calls: ${_agentTools.join(', ')}`);
return {};
}
return {
toolRegistry,
requestScopedConnections: getMCPRequestContext(req, res),
toolContextMap,
dynamicToolContextMap,
userMCPAuthMap,
toolDefinitions,
hasDeferredTools,
actionsEnabled,
tools: agentTools,
primedCodeFiles,
};
}
/**
* Loads tools for event-driven execution (ON_TOOL_EXECUTE handler).
* This function encapsulates all dependencies needed for tool loading,
* so callers don't need to import processFileURL, uploadImageBuffer, etc.
*
* Handles both regular tools (MCP, built-in) and action tools.
*
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {AbortSignal} [params.signal] - Abort signal
* @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 {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] - Run-scoped MCP connections
* @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
* @param {boolean} [params.actionsEnabled] - Whether the actions capability is enabled
* @returns {Promise<{ loadedTools: Array, configurable: Object }>}
*/
async function loadToolsForExecution({
req,
res,
signal,
agent,
toolNames,
toolRegistry,
mcpAvailableTools,
requestScopedConnections,
userMCPAuthMap,
tool_resources,
streamId = null,
actionsEnabled,
}) {
const appConfig = req.config;
const allLoadedTools = [];
const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res);
const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections };
const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH);
const ptcToolNames = [
AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING,
AgentConstants.PROGRAMMATIC_TOOL_CALLING,
].filter((name) => toolNames.includes(name));
const isPTCRequested = ptcToolNames.length > 0;
let enabledCapabilities;
if (actionsEnabled === undefined || isPTCRequested) {
enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id);
}
if (actionsEnabled === undefined) {
actionsEnabled = enabledCapabilities.has(AgentCapabilities.actions);
}
const isPTC =
isPTCRequested &&
enabledCapabilities.has(AgentCapabilities.programmatic_tools) &&
enabledCapabilities.has(AgentCapabilities.execute_code) &&
agent?.tools?.includes(Tools.execute_code) === true;
logger.debug(
`[loadToolsForExecution] isToolSearch: ${isToolSearch}, toolRegistry: ${toolRegistry?.size ?? 'undefined'}`,
);
if (isToolSearch && toolRegistry) {
const toolSearchTool = createToolSearch({
mode: 'local',
toolRegistry,
});
allLoadedTools.push(toolSearchTool);
configurable.toolRegistry = toolRegistry;
}
if (isPTC && toolRegistry) {
configurable.toolRegistry = toolRegistry;
try {
/**
* LibreChat threads per-request Code API auth through the agents
* library so PTC calls share the same managed auth context.
*/
for (const name of ptcToolNames) {
const ptcTool = createBashProgrammaticToolCallingTool({
authHeaders: () => getCodeApiAuthHeaders(req),
});
ptcTool.name = name;
allLoadedTools.push(ptcTool);
}
} catch (error) {
logger.error('[loadToolsForExecution] Error creating PTC tool:', error);
}
}
const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL);
if (isBashTool) {
try {
const bashTool = createBashExecutionTool({
authHeaders: () => getCodeApiAuthHeaders(req),
});
allLoadedTools.push(bashTool);
} catch (error) {
logger.error('[loadToolsForExecution] Failed to create bash_tool', error);
}
}
const fileAuthoringToolNames = new Set(
toolRegistry
? Array.from(toolRegistry.values())
.filter((definition) => isFileAuthoringToolDefinition(definition))
.map((definition) => definition.name)
: [],
);
const specialToolNames = new Set([
AgentConstants.TOOL_SEARCH,
AgentConstants.PROGRAMMATIC_TOOL_CALLING,
AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING,
AgentConstants.BASH_TOOL,
AgentConstants.SKILL_TOOL,
AgentConstants.READ_FILE,
...fileAuthoringToolNames,
]);
let ptcOrchestratedToolNames = [];
if (isPTC && toolRegistry) {
ptcOrchestratedToolNames = Array.from(toolRegistry.keys()).filter(
(name) => !specialToolNames.has(name),
);
}
const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name));
const allToolNamesToLoad = isPTC
? [...new Set([...requestedNonSpecialToolNames, ...ptcOrchestratedToolNames])]
: requestedNonSpecialToolNames;
const actionToolNames = [];
const regularToolNames = [];
for (const name of allToolNamesToLoad) {
(isActionTool(name) ? actionToolNames : regularToolNames).push(name);
}
if (regularToolNames.length > 0) {
const includesWebSearch = regularToolNames.includes(Tools.web_search);
const webSearchCallbacks = includesWebSearch ? createOnSearchResults(res, streamId) : undefined;
const { loadedTools } = await loadTools({
agent,
signal,
userMCPAuthMap,
functions: true,
tools: regularToolNames,
user: req.user.id,
options: {
req,
res,
tool_resources,
processFileURL,
uploadImageBuffer,
returnMetadata: true,
mcpAvailableTools,
requestScopedConnections: mcpRequestScopedConnections,
[Tools.web_search]: webSearchCallbacks,
},
webSearch: appConfig?.webSearch,
fileStrategy: appConfig?.fileStrategy,
imageOutputType: appConfig?.imageOutputType,
});
if (loadedTools) {
allLoadedTools.push(...loadedTools);
}
}
if (actionToolNames.length > 0 && agent && actionsEnabled) {
const actionTools = await loadActionToolsForExecution({
req,
res,
agent,
appConfig,
streamId,
actionToolNames,
});
allLoadedTools.push(...actionTools);
} else if (actionToolNames.length > 0 && agent && !actionsEnabled) {
logger.warn(
`[loadToolsForExecution] Capability "${AgentCapabilities.actions}" disabled. ` +
`Skipping action tool execution. User: ${req.user.id} | Agent: ${agent.id} | Tools: ${actionToolNames.join(', ')}`,
);
}
if (isPTC && allLoadedTools.length > 0) {
const ptcToolMap = new Map();
for (const tool of allLoadedTools) {
if (
tool.name &&
tool.name !== AgentConstants.PROGRAMMATIC_TOOL_CALLING &&
tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING
) {
ptcToolMap.set(tool.name, tool);
}
}
configurable.ptcToolMap = ptcToolMap;
}
return {
configurable,
loadedTools: allLoadedTools,
};
}
/**
* Loads action tools for event-driven execution.
* @param {Object} params
* @param {ServerRequest} params.req - The request object
* @param {ServerResponse} params.res - The response object
* @param {Object} params.agent - The agent object
* @param {Object} params.appConfig - App configuration
* @param {string|null} params.streamId - Stream ID
* @param {string[]} params.actionToolNames - Action tool names to load
* @returns {Promise<Array>} Loaded action tools
*/
async function loadActionToolsForExecution({
req,
res,
agent,
appConfig,
streamId,
actionToolNames,
}) {
const loadedActionTools = [];
const actionSets = (await loadActionSets({ agent_id: agent.id })) ?? [];
if (actionSets.length === 0) {
return loadedActionTools;
}
// See registerActionTools for the key-shape rationale.
const toolToAction = new Map();
const allowedDomains = appConfig?.actions?.allowedDomains;
const allowedAddresses = appConfig?.actions?.allowedAddresses;
for (const action of actionSets) {
const domain = await domainParser(action.metadata.domain, true);
const normalizedDomain = domain.replace(domainSeparatorRegex, '_');
const legacyDomain = legacyDomainEncode(action.metadata.domain);
const legacyNormalized = legacyDomain.replace(domainSeparatorRegex, '_');
const isDomainAllowed = await isActionDomainAllowed(
action.metadata.domain,
allowedDomains,
allowedAddresses,
);
if (!isDomainAllowed) {
logger.warn(
`[Actions] Domain "${action.metadata.domain}" not in allowedDomains. ` +
`Add it to librechat.yaml actions.allowedDomains to enable this action.`,
);
continue;
}
const validationResult = validateAndParseOpenAPISpec(action.metadata.raw_spec);
if (!validationResult.spec || !validationResult.serverUrl) {
logger.warn(`[Actions] Invalid OpenAPI spec for domain: ${domain}`);
continue;
}
const domainValidation = validateActionDomain(
action.metadata.domain,
validationResult.serverUrl,
);
if (!domainValidation.isValid) {
logger.error(`Domain mismatch in stored action: ${domainValidation.message}`, {
userId: req.user.id,
agent_id: agent.id,
action_id: action.action_id,
});
continue;
}
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
const decryptedAction = { ...action };
decryptedAction.metadata = await decryptMetadata(action.metadata);
const { requestBuilders, functionSignatures, zodSchemas } = openapiToFunction(
validationResult.spec,
true,
);
registerActionTools({
toolToAction,
functionSignatures,
normalizedDomain,
legacyNormalized,
makeEntry: (sig) => ({
action: decryptedAction,
requestBuilder: requestBuilders[sig.name],
zodSchema: zodSchemas[sig.name],
functionSignature: sig,
encrypted,
}),
});
}
for (const toolName of actionToolNames) {
const entry = toolToAction.get(normalizeActionToolName(toolName));
if (!entry) {
continue;
}
const { action, encrypted, zodSchema, requestBuilder, functionSignature } = entry;
const tool = await createActionTool({
userId: req.user.id,
res,
action,
streamId,
zodSchema,
encrypted,
requestBuilder,
name: toolName,
description: functionSignature.description,
useSSRFProtection: !Array.isArray(allowedDomains) || allowedDomains.length === 0,
allowedAddresses,
});
if (!tool) {
logger.warn(`[Actions] Failed to create action tool: ${toolName}`);
continue;
}
loadedActionTools.push(tool);
}
return loadedActionTools;
}
module.exports = {
loadTools,
isBuiltInTool,
getToolkitKey,
loadAgentTools,
loadToolsForExecution,
processRequiredActions,
resolveAgentCapabilities,
};