mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
* fix: resolve MCP tool-name delimiter collision at invocation time
MCP tool keys are identified internally as `${rawToolName}${mcp_delimiter}${serverName}`
(delimiter `_mcp_`). Several call sites parsed this back apart with a naive
`toolKey.split(Constants.mcp_delimiter)`, assuming the delimiter occurs exactly once.
When the raw upstream tool name itself contains the delimiter substring - which
happens whenever it's exposed through a gateway that prefixes aggregated tool names by
server (e.g. a gateway's own "gitlab-get_mcp_server_version" for GitLab's
"get_mcp_server_version" tool) - the combined key has the delimiter more than once.
`.split()` then produces more than two segments, and destructuring
`[toolName, serverName]` silently keeps only the first two, yielding a bogus server
name that matches no configured server. Tool listing still worked (a different code
path builds keys directly without re-splitting), but invocation failed with
`Tool {name} not found`, and `filterAuthorizedTools` rejected such keys outright as
malformed.
Add `splitMCPToolKey`, which splits on the *last* occurrence of the delimiter instead:
the server-name half is always LibreChat's own normalized suffix (guaranteed not to
contain the delimiter), while the raw tool-name half is untrusted and may legitimately
contain it. This matches `.split()`'s result whenever the delimiter occurs once, and
correctly resolves the collision case. Update the four call sites that parsed this
manually (`handleTools.js`, `MCP.js`, `mcp.js` controller, `filterAuthorizedTools` in
`v1.js`) plus one in the client (`useVisibleTools.ts`) to use it.
Fixes #14440
* fix: resolve MCP tool-key boundary against configured server names
splitMCPToolKey moves to librechat-data-provider so the client and backend
share one parser, and takes the configured server names when the caller has
them: the longest name the key actually ends with wins, which is exact.
Position alone cannot identify the boundary because both halves may contain
the delimiter. lastIndexOf alone fixes gateway-prefixed tool names but
regresses servers whose own name contains it, which ToolService.spec.js
already covered; the last-delimiter path now only serves as the fallback for
callers with no configured set.
Also converts the remaining first-occurrence parsers that the delimiter fix
missed - mcp/auth.ts (custom user vars silently unresolved), mcp/oauth/events.ts,
agents/initialize.ts, and the three client parsers that labelled tool calls
with the wrong server.
* fix: keep client tool-call labels on first-delimiter parsing
The three client parsers had deliberate, tested first-delimiter semantics
(ToolCall.test.tsx asserts the full server name for 'foo_mcp_bar' and the
synthetic 'oauth_mcp_server' call), and the client has no configured server
list in scope to resolve the boundary exactly, so they are left as they were.
Threads the configured names into the event-driven definition loader so it
resolves the same boundary as the authorization filter that admits the key,
and documents the one case that stays undecidable without provenance.
* fix: resolve tool-key boundary against all configured servers
resolveConfigServers only returns lazily-initialized config overrides -
ensureConfigServers skips unmodified YAML servers - so on a stock deployment
the known-name list was empty and suffix resolution never engaged. Adds
resolveMcpServerNames, which keeps every configured server in the normalized
form tool keys carry, and uses it at the loading, auth-map and definition
sites.
Background-tool eligibility now resolves against all configured names before
testing ephemeral membership, so a non-ephemeral server whose name ends in an
ephemeral one is no longer misclassified, and useVisibleTools resolves against
the server map it already receives.
* fix: use resolved server provenance and one app-config read
createMCPTool now uses the serverName loadTools already resolved for the key
and only parses as a fallback, so an unmodified YAML server whose name
contains the delimiter no longer resolves to the wrong server for auth,
reconnection and callTool.
resolveMcpServerContext derives config servers and all configured names from
a single getAppConfigForRequest, replacing two independent lookups on the
chat startup path, and degrades to empty like resolveConfigServers instead of
aborting tool loading when the config lookup fails.
* chore: drop unused resolveConfigServers import
* fix: forward server provenance on the all-tools path and read config once
createMCPTools builds each toolKey from the server name it already has but did
not forward it, so the sys__all__sys path re-derived it by parsing and bound
an unmodified YAML server whose name contains the delimiter to the wrong auth
and invocation context.
loadAgentTools now resolves the MCP server context once and threads it into
loadTools, replacing the second app-config read it had introduced on the
non-event-driven chat startup path.
* fix: carry resolved MCP server name through tool classification
definitions.ts resolves the server for each key and then dropped it when
building loadedTools, so buildToolClassification re-derived it with a
last-segment split and recorded 'Workspace' for a server configured as
'Google_mcp_Workspace'. The resolved name now rides along on the tool
instance and classification prefers it over re-parsing.
* fix: consume carried server name when extracting MCP servers
extractMCPServers re-derived the name with a last-segment split, so a server
configured as Google_mcp_Workspace resolved to Workspace and its instructions
were silently omitted. Prefers the name carried on the tool definition
instance, falling back to the split.
* fix: fail closed on ambiguous MCP keys when persisting server names
Persisted mcpServerNames grant agent-scoped access to a DB server by name
(ServerConfigsDB.getAccessibleServers), so a wrong guess exposes an unrelated
server to everyone who can view the agent. The last-segment split turned
search_mcp_Google_mcp_workspace into 'workspace'; such keys were previously
rejected outright at agent save, so admitting them opened this path.
Derives a name only from unambiguous single-delimiter keys. This is #12250's
guard moved to the boundary it was actually protecting, instead of blocking
tool admission.
* fix: keep DB server access for multi-delimiter tool keys
The fail-closed guard was wrong for the case this PR exists to fix. This index
only grants DB-backed servers, and DB names are slugs that cannot contain the
delimiter (generateServerNameFromTitle strips underscores), so the trailing
segment is always the real server for them - dropping it cost every consumer
of a gateway-prefixed tool their shared-agent access.
Also gates the MCP server-context lookup on the filtered MCP set, so an agent
with no MCP tools no longer pays an app-config read on startup.
* fix: resolve tool-call display names without breaking OAuth calls
The display parsers could not use the shared boundary parser because their
tested behavior depends on first-delimiter semantics. That constraint only
applies to synthetic MCP OAuth calls, whose tool half is always exactly
'oauth', so everything after the first delimiter is the server even when the
server name carries one.
splitToolCallName special-cases that form and defers to splitMCPToolKey for
real tool keys, so a gateway-prefixed tool now renders its own name and
server while oauth_mcp_foo_mcp_bar still resolves to foo_mcp_bar.
* fix: persist resolved MCP server provenance on agents
Deriving mcpServerNames from the tool key cannot tell a config server's
trailing segment from a real DB server name, so a config server named
a_mcp_b indexed an unrelated DB server b and shared the agent's viewers into
it. Neither string rule works: the suffix guess exposes, and failing closed
drops legitimate DB access for gateway-prefixed tools.
filterAuthorizedTools already resolves each tool's server against the merged
registry config, so it now collects those names and create, update and
duplicate persist them. No extra registry queries: the update path unions the
newly resolved names with what the agent already had, and duplicate replaces
the copied list rather than inheriting the source's servers.
Display parsing also takes the configured names, so a real tool call on a
delimiter-bearing server renders the right server and icon.
* test: teach MCP hook mocks about useMCPServerNames
Three specs mock ~/hooks/MCP with a hand-listed factory, so adding the hook
to ToolCall made useMCPServerNames undefined under test and every render
threw. Returns a stable array so the mock cannot perturb render counts.
* fix: rebuild agent MCP server index from surviving tools
Unioning the prior names kept a server indexed after its last tool was
detached, so viewers of a shared agent retained agent-scoped access to it.
The index is now rebuilt from the tools that survive the edit: a prior name
carries forward only while some retained tool still resolves to it, using the
agent's own persisted names as the candidate set, and the rebuild runs on any
tool change rather than only when a new MCP tool is added.
* fix: keep duplicate indexes on registry fallback and harden the oauth split
Duplication blanked mcpServerNames when the registry was unavailable, because
filterAuthorizedTools grandfathers the source's tools without resolving them -
the copy kept tools it could no longer resolve. Source names now carry forward
for the tools that still point at them.
splitToolCallName also treated any oauth_mcp_ prefix as a synthetic OAuth
call, so a genuine upstream tool by that name resolved to the wrong server. A
configured server name now decides when one matches, since a real key always
ends in its server, and the prefix only breaks ties for unconfigured servers.
* fix: thread configured server names through display parsing
parseToolName and getMCPServerName resolved context-free, so a configured
server whose name contains the delimiter showed the wrong server in grouped
tool summaries and subagent tool labels, and stacked icons missed its entry in
the icon map. Both take the configured names now, supplied by the components
that render them.
Adds the hook to SubagentCall's mock factory: the spec renders the real
component, so an unmocked useMCPServerNames would reach the query with no
provider.
* test: cover the auth-map boundary, server provenance and context fallback
Adds regression coverage for three behaviors this PR changed that no test
exercised: customUserVars resolving under the right plugin key for a
gateway-prefixed tool name (the failure that made these tools loadable but
unusable), the resolved server name reaching createMCPTool instead of being
re-parsed, and resolveMcpServerContext degrading to empty rather than
aborting tool loading when the config lookup fails.
Each was checked against a mutated source to confirm it fails when the
behavior is broken.
* fix: normalize server-name candidates and cover the boundary guard
Tool keys embed normalizeServerName's output while the config is keyed by the
raw name, so callers passing raw keys never matched a server whose name needs
normalizing and silently fell back to the last delimiter. filterAuthorizedTools
now maps normalized names back to their config key, and createMCPTool
normalizes its candidates.
Adds the cases an audit found surviving mutation: a configured name that is a
bare but not delimiter-aligned suffix must not match, an empty candidate list
behaves as no list, and splitToolCallName still falls back to the oauth prefix
when a list is supplied but nothing in it matches.
* fix: keep resolved server names when a non-owner retains MCP tools
The shared-agent path keeps an agent's existing MCP tools verbatim but supplied
no mcpServerNames, so persistence re-derived them and reduced a configured
server like Google_mcp_Workspace to Workspace - which ServerConfigsDB then
treats as a DB server, granting the agent's viewers access to an unrelated one.
Carries the existing resolved names across instead, and clears the index on the
owner path where every MCP tool is removed.
* fix: preserve resolved MCP names for every tools update
extractMCPServerNames was reachable from any caller that writes tools without
mcpServerNames - the Action edit path does exactly that - so a configured
Google_mcp_Workspace was reindexed as Workspace and ServerConfigsDB granted
shared-agent viewers an unrelated DB server by that name.
updateAgent now rebuilds the index from the agent's own resolved names: one
carries forward while a retained tool still resolves to it, and only keys
matching none of them fall back to derivation. Callers are safe by default
rather than by remembering to pass the set.
normalizeServerName moves to librechat-data-provider so the client can match
its candidates against tool keys, which embed the normalized form; the icon map
is keyed the same way since it is looked up with a parsed server name.
* refactor: move MCP context resolution into packages/api
New backend logic belongs in the TypeScript workspace per CLAUDE.md, with /api
kept to a thin wrapper. resolveMCPServerContext now lives in
packages/api/src/mcp/context.ts and takes ensureConfigServers by injection,
since the registry accessor is still legacy-only; the /api function is reduced
to loading the request app config and translating failures into the empty
degrade it already promised.
* test: teach the MCP service mock about resolveMCPServerContext
The spec mocks @librechat/api with a hand-listed factory, so moving the
resolver into that package left it undefined and the wrapper degraded into its
own catch, returning empty config servers. The stub mirrors the real resolver
so these tests still cover what the wrapper owns - loading the request config
and degrading on failure - while the resolution logic is unit-tested in
packages/api.
* fix: only persist an authoritative MCP server index on update
Assigning the resolved set unconditionally pinned the index to [] whenever
nothing authoritative was available - a legacy agent holding MCP tools with no
stored mcpServerNames - which suppressed updateAgent's derivation and stripped
agent-scoped access to its DB-backed server.
The field is now supplied only when the result is authoritative: names were
resolved, or no MCP tool survives so the index genuinely is empty. The
retained-tools branch likewise leaves it unset when the agent has none stored.
---------
Co-authored-by: Jens Schumann <schumajs@gmail.com>
1868 lines
61 KiB
JavaScript
1868 lines
61 KiB
JavaScript
const { logger, redactMessage } = 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,
|
|
ASK_USER_QUESTION_TOOL_NAME,
|
|
} = 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, resolveMcpServerContext } = require('~/server/services/MCP');
|
|
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
|
|
const { recordUsage } = require('~/server/services/Threads');
|
|
const { loadTools } = require('~/app/clients/tools/util');
|
|
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,
|
|
Tools.memory,
|
|
]);
|
|
|
|
/** 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
|
|
* @param {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events
|
|
* @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,
|
|
jobCreatedAt,
|
|
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 (tool === Tools.memory) {
|
|
return checkCapability(AgentCapabilities.memory);
|
|
}
|
|
if (tool === ASK_USER_QUESTION_TOOL_NAME) {
|
|
return checkCapability(AgentCapabilities.ask_user_question);
|
|
}
|
|
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: [] };
|
|
}
|
|
|
|
/** Only MCP tool keys need the server context; a purely non-MCP agent should not
|
|
* pay an app-config lookup on startup. */
|
|
const hasFilteredMCPTools = filteredTools.some((t) => t.includes(Constants.mcp_delimiter));
|
|
const { configServers, serverNames: mcpServerNames } = hasFilteredMCPTools
|
|
? await resolveMcpServerContext(req)
|
|
: { configServers: {}, serverNames: [] };
|
|
|
|
/** @type {Record<string, Record<string, string>>} */
|
|
let userMCPAuthMap;
|
|
if (hasFilteredMCPTools) {
|
|
userMCPAuthMap = await getUserMCPAuthMap({
|
|
tools: filteredTools,
|
|
userId: req.user.id,
|
|
serverNames: mcpServerNames,
|
|
findPluginAuthsByKeys,
|
|
});
|
|
}
|
|
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
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, {
|
|
expectedCreatedAt: jobCreatedAt,
|
|
});
|
|
await GenerationJobManager.emitChunk(streamId, runStepDeltaEvent, {
|
|
expectedCreatedAt: jobCreatedAt,
|
|
});
|
|
} 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, {
|
|
expectedCreatedAt: jobCreatedAt,
|
|
});
|
|
} 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,
|
|
mcpServerNames,
|
|
},
|
|
{
|
|
isBuiltInTool,
|
|
getOrFetchMCPServerTools,
|
|
getActionToolDefinitions,
|
|
},
|
|
);
|
|
|
|
for (const serverName of getMCPServerNamesFromTools(filteredTools, mcpServerNames)) {
|
|
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,
|
|
mcpServerNames,
|
|
},
|
|
{
|
|
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 {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events
|
|
* @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,
|
|
jobCreatedAt,
|
|
definitionsOnly = true,
|
|
}) {
|
|
if (definitionsOnly) {
|
|
return loadToolDefinitionsWrapper({
|
|
req,
|
|
res,
|
|
agent,
|
|
streamId,
|
|
jobCreatedAt,
|
|
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 (tool === Tools.memory) {
|
|
return checkCapability(AgentCapabilities.memory);
|
|
} else if (tool === ASK_USER_QUESTION_TOOL_NAME) {
|
|
return checkCapability(AgentCapabilities.ask_user_question);
|
|
} 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, jobCreatedAt);
|
|
}
|
|
|
|
/** Resolved once and threaded into `loadTools` so the request app config is read once. */
|
|
const mcpServerContext = _agentTools?.some((t) => t.includes(Constants.mcp_delimiter))
|
|
? await resolveMcpServerContext(req)
|
|
: undefined;
|
|
|
|
/** @type {Record<string, Record<string, string>>} */
|
|
let userMCPAuthMap;
|
|
if (mcpServerContext) {
|
|
userMCPAuthMap = await getUserMCPAuthMap({
|
|
tools: _agentTools,
|
|
userId: req.user.id,
|
|
serverNames: mcpServerContext.serverNames,
|
|
findPluginAuthsByKeys,
|
|
});
|
|
}
|
|
|
|
const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({
|
|
agent,
|
|
signal,
|
|
userMCPAuthMap,
|
|
functions: true,
|
|
user: req.user.id,
|
|
tools: _agentTools,
|
|
options: {
|
|
req,
|
|
res,
|
|
mcpServerContext,
|
|
jobCreatedAt,
|
|
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,
|
|
provider: agent.provider,
|
|
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,
|
|
jobCreatedAt,
|
|
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 {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events
|
|
* @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,
|
|
backgroundToolNames,
|
|
mcpAvailableTools,
|
|
requestScopedConnections,
|
|
userMCPAuthMap,
|
|
tool_resources,
|
|
streamId = null,
|
|
jobCreatedAt,
|
|
actionsEnabled,
|
|
}) {
|
|
const appConfig = req.config;
|
|
const allLoadedTools = [];
|
|
const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res);
|
|
const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections };
|
|
/** Per-agent set of tools that received the injected `run_in_background`
|
|
* param; the event-driven executor gates background dispatch and the
|
|
* `check_background_task` poll tool on this reliable per-agent channel. */
|
|
if (backgroundToolNames?.length) {
|
|
configurable.backgroundToolNames = backgroundToolNames;
|
|
}
|
|
|
|
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;
|
|
const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL);
|
|
const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code);
|
|
const isCodeExecutionToolRequested = isBashToolRequested || isLegacyExecuteCodeRequested;
|
|
|
|
let enabledCapabilities;
|
|
if (actionsEnabled === undefined || isPTCRequested || isCodeExecutionToolRequested) {
|
|
enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id);
|
|
}
|
|
if (actionsEnabled === undefined) {
|
|
actionsEnabled = enabledCapabilities.has(AgentCapabilities.actions);
|
|
}
|
|
const codeExecutionEnabled =
|
|
enabledCapabilities?.has(AgentCapabilities.execute_code) === true &&
|
|
agent?.tools?.includes(Tools.execute_code) === true;
|
|
|
|
/**
|
|
* Opt bash_tool into the hedged stateful-session description. Gated on code
|
|
* execution being enabled AND the admin `stateful_code_sessions` capability
|
|
* AND the agent's own builder opt-in; off by default. Sets prompt text only
|
|
* (the wire hint is set at run config). PTC keeps its stateless prompt in
|
|
* v1. Older @librechat/agents ignore the param.
|
|
*/
|
|
const statefulCodeSessions =
|
|
codeExecutionEnabled &&
|
|
enabledCapabilities?.has(AgentCapabilities.stateful_code_sessions) === true &&
|
|
agent?.stateful_code_sessions === true;
|
|
|
|
const isPTC =
|
|
isPTCRequested &&
|
|
enabledCapabilities.has(AgentCapabilities.programmatic_tools) &&
|
|
codeExecutionEnabled;
|
|
|
|
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 =
|
|
isBashToolRequested &&
|
|
codeExecutionEnabled &&
|
|
toolRegistry?.has(AgentConstants.BASH_TOOL) === true;
|
|
if (isBashToolRequested && !isBashTool) {
|
|
logger.warn(
|
|
`[loadToolsForExecution] Skipping unregistered or unauthorized ${AgentConstants.BASH_TOOL}. ` +
|
|
`User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`,
|
|
);
|
|
}
|
|
if (isBashTool) {
|
|
try {
|
|
const bashTool = createBashExecutionTool({
|
|
authHeaders: () => getCodeApiAuthHeaders(req),
|
|
statefulSessions: statefulCodeSessions,
|
|
});
|
|
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 allowedNonSpecialToolNames = requestedNonSpecialToolNames.filter((name) => {
|
|
if (name !== Tools.execute_code) {
|
|
return true;
|
|
}
|
|
const allowed = codeExecutionEnabled && toolRegistry?.has(Tools.execute_code) === true;
|
|
if (!allowed) {
|
|
logger.warn(
|
|
`[loadToolsForExecution] Skipping unregistered or unauthorized ${Tools.execute_code}. ` +
|
|
`User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`,
|
|
);
|
|
}
|
|
return allowed;
|
|
});
|
|
const allToolNamesToLoad = isPTC
|
|
? [...new Set([...allowedNonSpecialToolNames, ...ptcOrchestratedToolNames])]
|
|
: allowedNonSpecialToolNames;
|
|
|
|
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, jobCreatedAt)
|
|
: undefined;
|
|
|
|
const { loadedTools } = await loadTools({
|
|
agent,
|
|
signal,
|
|
userMCPAuthMap,
|
|
functions: true,
|
|
tools: regularToolNames,
|
|
user: req.user.id,
|
|
options: {
|
|
req,
|
|
res,
|
|
jobCreatedAt,
|
|
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,
|
|
jobCreatedAt,
|
|
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 {number} [params.jobCreatedAt] - The generation epoch that owns emitted tool events
|
|
* @param {string[]} params.actionToolNames - Action tool names to load
|
|
* @returns {Promise<Array>} Loaded action tools
|
|
*/
|
|
async function loadActionToolsForExecution({
|
|
req,
|
|
res,
|
|
agent,
|
|
appConfig,
|
|
streamId,
|
|
jobCreatedAt,
|
|
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,
|
|
jobCreatedAt,
|
|
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,
|
|
};
|