LibreChat/api/server/controllers/tools.js
Danny Avila 9bcc2572d6 feat: live artifacts — sandboxed HTML that calls MCP tools
Squashed rebase of #13540 (11 commits incl. 8 Codex review rounds) onto
current main. Conflict resolutions of note:

- create_file arg renamed `file_path` -> `path` upstream; live-artifact
  allowlist gating adapted to the new name.
- processCodeOutput `fileMetadata` now carries `sourceDispatchedAt`
  alongside `codeEnvRef` + `mcpTools` so the background stale-output
  guard keeps working.
- ArtifactTabs passes `resolvedStartupConfig` (shared-link aware) to the
  non-live Sandpack preview.
2026-08-03 17:31:48 -04:00

471 lines
17 KiB
JavaScript

const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const {
checkAccess,
loadWebSearchAuth,
normalizeServerName,
authorizeArtifactToolCall,
} = require('@librechat/api');
const {
Tools,
AuthType,
Constants,
Permissions,
ToolCallTypes,
PermissionTypes,
} = require('librechat-data-provider');
const {
getFiles,
getMessage,
getRoleByName,
createToolCall,
getToolCallsByConvo,
} = require('~/models');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { getRetentionExpiry } = require('~/server/services/Files/retention');
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util');
/**
* Tools that are callable directly via `POST /tools/:toolId/call`.
* `execute_code` is the only entry today; the tool runs server-side via
* the agents library / sandbox service without any per-user credential.
*/
const directCallableTools = new Set([Tools.execute_code]);
const toolAccessPermType = {
[Tools.execute_code]: PermissionTypes.RUN_CODE,
};
/**
* Verifies web search authentication, ensuring each category has at least
* one fully authenticated service.
*
* @param {ServerRequest} req - The request object
* @param {ServerResponse} res - The response object
* @returns {Promise<void>} A promise that resolves when the function has completed
*/
const verifyWebSearchAuth = async (req, res) => {
try {
const appConfig = req.config;
const userId = req.user.id;
/** @type {TCustomConfig['webSearch']} */
const webSearchConfig = appConfig?.webSearch || {};
const result = await loadWebSearchAuth({
userId,
loadAuthValues,
webSearchConfig,
throwError: false,
});
return res.status(200).json({
authenticated: result.authenticated,
authTypes: result.authTypes,
});
} catch (error) {
console.error('Error in verifyWebSearchAuth:', error);
return res.status(500).json({ message: error.message });
}
};
/**
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const verifyToolAuth = async (req, res) => {
try {
const { toolId } = req.params;
if (toolId === Tools.web_search) {
return await verifyWebSearchAuth(req, res);
}
if (!directCallableTools.has(toolId)) {
res.status(404).json({ message: 'Tool not found' });
return;
}
/**
* `execute_code` no longer requires a per-user credential — sandbox
* auth is handled server-side by the agents library. Always report
* system-authenticated so the client proceeds straight to the call
* without a key-entry dialog.
*
* Deployment contract: reachability of the sandbox service is the
* admin's responsibility. This endpoint does not probe the service
* (a per-auth-check network hop would be too expensive for what is
* a UI-gate query). If the sandbox is unreachable, the call path
* surfaces the error at execution time instead of here.
*/
res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
/**
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
* @param {NextFunction} next - The next middleware function to call.
* @returns {Promise<void>} A promise that resolves when the function has completed.
*/
const callTool = async (req, res) => {
try {
const appConfig = req.config;
const { toolId = '' } = req.params;
if (!directCallableTools.has(toolId)) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
res.status(404).json({ message: 'Tool not found' });
return;
}
const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
if (!messageId) {
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
res.status(400).json({ message: 'Message ID required' });
return;
}
const message = await getMessage({ user: req.user.id, messageId });
if (!message) {
logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
res.status(404).json({ message: 'Message not found' });
return;
}
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true;
if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
}
if (!hasAccess) {
logger.warn(
`[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
}
const { loadedTools } = await loadTools({
user: req.user.id,
tools: [toolId],
functions: true,
options: {
req,
returnMetadata: true,
processFileURL,
uploadImageBuffer,
},
webSearch: appConfig.webSearch,
fileStrategy: appConfig.fileStrategy,
imageOutputType: appConfig.imageOutputType,
});
const tool = loadedTools[0];
const toolCallId = `${req.user.id}_${nanoid()}`;
const result = await tool.invoke({
args,
name: toolId,
id: toolCallId,
type: ToolCallTypes.TOOL_CALL,
});
const { content, artifact } = result;
const toolCallData = {
toolId,
messageId,
partIndex,
blockIndex,
conversationId,
result: content,
user: req.user.id,
...(await getRetentionExpiry(req)),
};
if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);
});
return res.status(200).json({
result: content,
});
}
const artifactPromises = [];
for (const file of artifact.files) {
/* Files flagged `inherited` by codeapi are unchanged passthroughs of
* inputs the caller already owns (skill files, prior downloaded inputs,
* inherited .dirkeep markers). Re-downloading them is wasted work and
* 403s when the file is scoped to a different entity (e.g. skill
* entity_id) than the user's session key. They remain available for
* subsequent tool calls via primeInvokedSkills / session inheritance. */
if (file.inherited) {
continue;
}
const { id, name } = file;
artifactPromises.push(
(async () => {
const result = await processCodeOutput({
req,
id,
name,
messageId,
toolCallId,
conversationId,
session_id: artifact.session_id,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
if (!fileMetadata) {
return null;
}
/* This endpoint is non-streaming and its contract is "give
* me the artifacts" — return the persisted record immediately
* (with `status: 'pending'` for office buckets) and run the
* preview render in the background. The client polls
* `/api/files/:file_id/preview` for the resolved record.
* No `onResolved` — there's no live stream to write to here. */
runPreviewFinalize({
finalize,
fileId: fileMetadata.file_id,
previewRevision: result?.previewRevision,
});
return fileMetadata;
})().catch((error) => {
logger.error('Error processing code output:', error);
return null;
}),
);
}
const attachments = await Promise.all(artifactPromises);
toolCallData.attachments = attachments;
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);
});
res.status(200).json({
result: content,
attachments,
});
} catch (error) {
logger.error('Error calling tool', error);
res.status(500).json({ message: 'Error calling tool' });
}
};
/**
* No-op response used as the captured `res` for live-artifact MCP tool
* instances. The bridge is a non-streaming JSON endpoint, so the OAuth/SSE
* emitters inside the tool must never write to the real response. We pre-check
* the connection and reject when a server isn't connected, so this stub is only
* a defense-in-depth guard against the interactive OAuth path.
*/
const createNoopEventSink = () => ({
headersSent: true,
write: () => true,
end: () => {},
flush: () => {},
on: () => {},
});
/**
* Dispatch a single MCP tool call originating from a live artifact's bridge.
*
* The artifact's permitted tools are stored on its file record at authoring time
* (`file.metadata.mcpTools`). That server-stored allowlist is re-validated here,
* so a tampered client cannot call tools the artifact never declared. Tools run
* with the user's live MCP credentials.
*
* @param {ServerRequest} req
* @param {ServerResponse} res
* @returns {Promise<void>}
*/
const callArtifactTool = async (req, res) => {
try {
/* Lazy-require: `~/server/services/MCP` pulls a heavy auth chain
* (Graph/OBO/openid) at load time. Requiring it here keeps the rest of
* this controller (and its tests) loadable without that chain. */
const {
createMCPTool,
getMCPSetupData,
userCanUseMCPServers,
getServerConnectionStatus,
createMCPPermissionContext,
} = require('~/server/services/MCP');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { getMCPManager } = require('~/config');
const { Providers } = require('@librechat/agents');
const { tool, file_id: fileId, messageId, conversationId, partIndex, blockIndex } = req.body;
const rawArgs = req.body.args;
const args = rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) ? rawArgs : {};
if (typeof tool !== 'string' || !tool || typeof fileId !== 'string' || !fileId) {
res.status(400).json({ message: 'tool and file_id are required' });
return;
}
const [file] = await getFiles({ file_id: fileId, user: req.user.id });
if (!file) {
res.status(404).json({ message: 'Artifact file not found' });
return;
}
const authorization = authorizeArtifactToolCall(file.metadata?.mcpTools, tool);
if (!authorization.allowed) {
if (authorization.reason === 'not_mcp') {
res.status(400).json({ message: 'Only MCP tools are callable from artifacts' });
return;
}
logger.warn(
`[artifact/tool] User ${req.user.id} attempted tool "${tool}" not in file "${fileId}" allowlist`,
);
res.status(403).json({ message: 'Tool not permitted for this artifact' });
return;
}
const { serverName: normalizedServerName, toolName } = authorization;
const hasAccess = await userCanUseMCPServers(req.user, req);
if (!hasAccess) {
logger.warn(`[artifact/tool] Forbidden: User ${req.user.id} lacks MCP server permissions`);
res.status(403).json({ message: 'Forbidden: Insufficient MCP server permissions' });
return;
}
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
req.user.id,
{ role: req.user.role, tenantId: req.user.tenantId },
);
/* Tool keys expose a NORMALIZED server name; `mcpConfig` is keyed by the raw
* configured name. Resolve back to the raw name so lookup/connection/tool
* resolution all use the same key the normal agent path uses. */
const rawServerName = mcpConfig[normalizedServerName]
? normalizedServerName
: Object.keys(mcpConfig).find((name) => normalizeServerName(name) === normalizedServerName);
const serverConfig = rawServerName ? mcpConfig[rawServerName] : undefined;
if (!serverConfig) {
res.status(404).json({ message: `MCP server "${normalizedServerName}" not found` });
return;
}
const { connectionState, requiresOAuth } = await getServerConnectionStatus(
req.user.id,
rawServerName,
serverConfig,
appConnections,
userConnections,
oauthServers,
);
if (connectionState !== 'connected') {
res.status(409).json({
message: `MCP server "${rawServerName}" is not connected`,
serverName: rawServerName,
connectionState,
requiresOAuth,
});
return;
}
/* Resolve the user's per-server custom variables (API keys, etc.) the same
* way the normal agent path does, so servers that template `{{USER_VAR}}`
* aren't invoked without the user's configured values. */
const pluginKey = `${Constants.mcp_prefix}${rawServerName}`;
const customUserVars = {};
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
for (const varName of Object.keys(serverConfig.customUserVars)) {
const value = await getUserPluginAuthValue(req.user.id, varName, false, pluginKey).catch(
() => null,
);
if (value) {
customUserVars[varName] = value;
}
}
}
const userMCPAuthMap =
Object.keys(customUserVars).length > 0 ? { [pluginKey]: customUserVars } : undefined;
/* Reuse the connected server's cached tool definitions so createMCPTool
* doesn't reconnect on every bridge call (the reconnect path is throttled
* per user/server and would otherwise return an unavailable stub). */
const availableTools = await getMCPManager(req.user.id).getServerToolFunctions(
req.user.id,
rawServerName,
);
const toolInstance = await createMCPTool({
res: createNoopEventSink(),
user: req.user,
// Rebuild the tool key with the RAW server name so createMCPTool resolves
// the connection/definition the same way the agent path does.
toolKey: `${toolName}${Constants.mcp_delimiter}${rawServerName}`,
config: serverConfig,
userMCPAuthMap,
availableTools,
// A recognized provider so content_and_artifact results format with their
// artifact (images/UI resources) instead of plain-string-only.
provider: Providers.OPENAI,
mcpPermissionContext: createMCPPermissionContext(req),
});
if (!toolInstance) {
res.status(404).json({ message: 'Tool unavailable' });
return;
}
const toolCallId = `${req.user.id}_${nanoid()}`;
const result = await toolInstance.invoke(
{ name: tool, args, id: toolCallId, type: ToolCallTypes.TOOL_CALL },
{
signal: req.abortController?.signal,
/* Stable flow identifiers so any OAuth path derives a unique flowId
* instead of `${serverName}:oauth_login:undefined:undefined`. */
metadata: { thread_id: conversationId ?? `artifact:${fileId}`, run_id: toolCallId },
configurable: {
user: req.user,
requestBody: { conversationId, messageId },
userMCPAuthMap,
},
},
);
const { content, artifact } = result ?? {};
/* Record only with both ids — the ToolCall schema requires conversationId,
* so persisting with messageId alone would hit the validation error path. */
if (messageId && conversationId) {
createToolCall({
toolId: tool,
messageId,
partIndex,
blockIndex,
conversationId,
result: content,
user: req.user.id,
...(await getRetentionExpiry(req)),
}).catch((error) => {
logger.error(`[artifact/tool] Error recording tool call: ${error.message}`);
});
}
res.status(200).json({ result: content, artifact });
} catch (error) {
logger.error('[artifact/tool] Error calling artifact tool', error);
res.status(500).json({ message: 'Error calling tool' });
}
};
const getToolCalls = async (req, res) => {
try {
const { conversationId } = req.query;
const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
res.status(200).json(toolCalls);
} catch (error) {
logger.error('Error getting tool calls', error);
res.status(500).json({ message: 'Error getting tool calls' });
}
};
module.exports = {
callTool,
getToolCalls,
verifyToolAuth,
callArtifactTool,
};