diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index f451954c5b..679dc3dbbc 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -887,6 +887,20 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo return; } + /* Live-artifact allowlist: a create_file authoring call may declare + * `mcp_tools` for an HTML file. Persist it onto that file's record + * (`metadata.mcpTools`) so the live-artifact bridge can authorize tool + * calls. Scope to the authored HTML file only — never other outputs. */ + const authoredMcpTools = Array.isArray(output.artifact.mcp_tools) + ? output.artifact.mcp_tools + : null; + const authoredPath = typeof output.artifact.path === 'string' ? output.artifact.path : null; + /* Normalize sandbox paths so a nested authored file (e.g. + * `/mnt/data/reports/dash.html` vs a reported `reports/dash.html`) still + * matches and keeps its allowlist. */ + const stripSandboxRoot = (p) => (p || '').replace(/^\/?mnt\/data\//, '').replace(/^\.?\//, ''); + const authoredNorm = authoredPath ? stripSandboxRoot(authoredPath) : null; + for (const file of output.artifact.files) { /* `inherited` files are unchanged passthroughs of inputs the caller * already owns (skill files, prior session inputs, inherited @@ -899,6 +913,13 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo } const { id, name } = file; const toolCallId = output.tool_call_id; + const mcpTools = + authoredMcpTools && + authoredNorm && + /\.html?$/i.test(name) && + stripSandboxRoot(name) === authoredNorm + ? authoredMcpTools + : undefined; artifactPromises.push( (async () => { const result = await processCodeOutput({ @@ -926,6 +947,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo * ids. */ session_id: file.storage_session_id ?? output.artifact.session_id, + mcpTools, }); const fileMetadata = result?.file ?? null; const finalize = result?.finalize; @@ -1209,6 +1231,20 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) return; } + /* Live-artifact allowlist: a create_file authoring call may declare + * `mcp_tools` for an HTML file. Persist it onto that file's record + * (`metadata.mcpTools`) so the live-artifact bridge can authorize tool + * calls. Scope to the authored HTML file only — never other outputs. */ + const authoredMcpTools = Array.isArray(output.artifact.mcp_tools) + ? output.artifact.mcp_tools + : null; + const authoredPath = typeof output.artifact.path === 'string' ? output.artifact.path : null; + /* Normalize sandbox paths so a nested authored file (e.g. + * `/mnt/data/reports/dash.html` vs a reported `reports/dash.html`) still + * matches and keeps its allowlist. */ + const stripSandboxRoot = (p) => (p || '').replace(/^\/?mnt\/data\//, '').replace(/^\.?\//, ''); + const authoredNorm = authoredPath ? stripSandboxRoot(authoredPath) : null; + for (const file of output.artifact.files) { /* `inherited` files are unchanged passthroughs of inputs the caller * already owns (skill files, prior session inputs, inherited @@ -1221,6 +1257,13 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) } const { id, name } = file; const toolCallId = output.tool_call_id; + const mcpTools = + authoredMcpTools && + authoredNorm && + /\.html?$/i.test(name) && + stripSandboxRoot(name) === authoredNorm + ? authoredMcpTools + : undefined; artifactPromises.push( (async () => { const result = await processCodeOutput({ @@ -1248,6 +1291,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) * ids. */ session_id: file.storage_session_id ?? output.artifact.session_id, + mcpTools, }); const fileMetadata = result?.file ?? null; const finalize = result?.finalize; diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js index 4551adf617..087f3e387d 100644 --- a/api/server/controllers/tools.js +++ b/api/server/controllers/tools.js @@ -1,14 +1,26 @@ const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { checkAccess, loadWebSearchAuth } = require('@librechat/api'); +const { + checkAccess, + loadWebSearchAuth, + normalizeServerName, + authorizeArtifactToolCall, +} = require('@librechat/api'); const { Tools, AuthType, + Constants, Permissions, ToolCallTypes, PermissionTypes, } = require('librechat-data-provider'); -const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models'); +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'); @@ -241,6 +253,205 @@ const callTool = async (req, res) => { } }; +/** + * 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} + */ +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; @@ -256,4 +467,5 @@ module.exports = { callTool, getToolCalls, verifyToolAuth, + callArtifactTool, }; diff --git a/api/server/routes/agents/tools.js b/api/server/routes/agents/tools.js index ca512e98c2..60cd4ad707 100644 --- a/api/server/routes/agents/tools.js +++ b/api/server/routes/agents/tools.js @@ -1,5 +1,10 @@ const express = require('express'); -const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools'); +const { + callTool, + getToolCalls, + verifyToolAuth, + callArtifactTool, +} = require('~/server/controllers/tools'); const { getAvailableTools } = require('~/server/controllers/PluginController'); const { toolCallLimiter } = require('~/server/middleware'); @@ -27,6 +32,15 @@ router.get('/calls', getToolCalls); */ router.get('/:toolId/auth', verifyToolAuth); +/** + * Dispatch an MCP tool call from a live artifact's bridge. + * Registered before `/:toolId/call` so the literal path wins over the param. + * @route POST /agents/tools/mcp/call + * @param {object} req.body - { tool, identifier, messageId, conversationId, args } + * @returns {object} { result, artifact } + */ +router.post('/mcp/call', toolCallLimiter, callArtifactTool); + /** * Execute code for a specific tool * @route POST /agents/tools/:toolId/call diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 67c2bf9fec..3fc43dae1a 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -325,6 +325,7 @@ const processCodeOutput = async ({ session_id, agentId, freshClaimAfter, + mcpTools, }) => { const appConfig = req.config; const currentDate = new Date(); @@ -457,6 +458,19 @@ const processCodeOutput = async ({ return null; } + /* Live-artifact allowlist for HTML authored via `create_file`. An array + * (incl. empty) is authoritative — a create_file overwrite with an empty + * or omitted list revokes prior permissions. `undefined` (edit_file / bash + * content update) preserves the existing list so an edit doesn't strip it. */ + const resolvedMcpTools = Array.isArray(mcpTools) ? mcpTools : claimed?.metadata?.mcpTools; + const fileMetadata = { + codeEnvRef, + sourceDispatchedAt, + ...(Array.isArray(resolvedMcpTools) && resolvedMcpTools.length > 0 + ? { mcpTools: resolvedMcpTools } + : {}), + }; + if (isUpdate) { logger.debug( `[processCodeOutput] Updating existing file "${safeName}" (${file_id}) instead of creating duplicate`, @@ -530,7 +544,7 @@ const processCodeOutput = async ({ updatedAt: formattedDate, source: appConfig.fileStrategy, context: FileContext.execute_code, - metadata: { codeEnvRef, sourceDispatchedAt }, + metadata: fileMetadata, ...(await getRetentionExpiry(req)), }; if (!(await commitCodeFile(file))) { @@ -632,7 +646,7 @@ const processCodeOutput = async ({ tenantId: req.user.tenantId, bytes: buffer.length, updatedAt: formattedDate, - metadata: { codeEnvRef, sourceDispatchedAt }, + metadata: fileMetadata, source: appConfig.fileStrategy, context: FileContext.execute_code, usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1, diff --git a/client/src/common/artifacts.ts b/client/src/common/artifacts.ts index 3630ac3f24..d7cf3fbe02 100644 --- a/client/src/common/artifacts.ts +++ b/client/src/common/artifacts.ts @@ -29,6 +29,12 @@ export interface Artifact { title?: string; type?: string; download?: ArtifactDownload; + /** Source file id for file-based artifacts; the live-artifact bridge key. */ + fileId?: string; + /** Conversation the source file belongs to; recorded with live tool calls. */ + conversationId?: string; + /** MCP tool keys a live HTML artifact may call (from `file.metadata.mcpTools`). */ + tools?: string[]; } export type ArtifactFiles = diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index 3ebc98a366..fc0cdc0ce0 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -7,6 +7,8 @@ import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider' import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { useCodeState } from '~/Providers/EditorContext'; +import LiveArtifactPreview from './LiveArtifactPreview'; +import { isLiveArtifact } from '~/utils/liveArtifact'; import { ArtifactPreview } from './ArtifactPreview'; import { useShareContext } from '~/Providers'; @@ -39,6 +41,9 @@ export default function ArtifactTabs({ }, [setCurrentCode, artifact.id]); const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + // Require a real fileId: the bridge keys off it, so a live render without one + // would 400 every tool call. Fall back to the static Sandpack preview instead. + const live = isLiveArtifact(artifact.type, artifact.tools) && Boolean(artifact.fileId); return (
@@ -56,15 +61,24 @@ export default function ArtifactTabs({ className="h-full w-full flex-grow overflow-hidden" tabIndex={-1} > - + {live ? ( + + ) : ( + + )}
); diff --git a/client/src/components/Artifacts/LiveArtifactPreview.tsx b/client/src/components/Artifacts/LiveArtifactPreview.tsx new file mode 100644 index 0000000000..c1baec4c9a --- /dev/null +++ b/client/src/components/Artifacts/LiveArtifactPreview.tsx @@ -0,0 +1,235 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { RotateCw } from 'lucide-react'; +import { Button } from '@librechat/client'; +import { buildLiveArtifactDocument, splitMcpToolKey } from '~/utils/liveArtifact'; +import { useArtifactToolCallMutation } from '~/data-provider'; +import { useLocalize } from '~/hooks'; + +type ToolRequest = { + id: string; + name: string; + args: Record; + /** The port that issued this request; results go back only here. */ + port: MessagePort; +}; + +/** Per-render secret embedded in the shim; gates the bridge-port handshake. */ +const makeHandshakeToken = (): string => { + const c = globalThis.crypto; + if (c?.randomUUID) { + return c.randomUUID(); + } + const bytes = new Uint8Array(16); + c?.getRandomValues?.(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +}; + +/** + * Renders a live HTML artifact in an opaque-origin sandboxed iframe and bridges + * its `window.librechat.callMcpTool` calls to the server, gated by + * consent-on-first-call. The injected CSP blocks scriptable network egress + * (`connect-src 'none'`) and the pixel/font/form/navigation exfil channels, so + * the bridge is the only intended way data leaves the iframe; every tool call + * flows through this relay. + */ +export default function LiveArtifactPreview({ + content, + fileId, + messageId, + conversationId, +}: { + content: string; + fileId: string; + messageId?: string; + conversationId?: string; +}) { + const localize = useLocalize(); + const callArtifactTool = useArtifactToolCallMutation(); + + const iframeRef = useRef(null); + const portRef = useRef(null); + const grantsRef = useRef>(new Set()); + const queueRef = useRef([]); + const [pending, setPending] = useState(null); + const [reloadNonce, setReloadNonce] = useState(0); + + // Fresh token + document per content render — only the shim we injected knows + // the token, so a navigated/attacker doc can't claim the bridge. (A reload + // remounts the iframe and re-handshakes with the same token, which is fine.) + const { token, srcDocument } = useMemo(() => { + const handshakeToken = makeHandshakeToken(); + return { + token: handshakeToken, + srcDocument: buildLiveArtifactDocument(content, handshakeToken), + }; + }, [content]); + + // React reuses this instance when switching between live artifacts, and the + // same fileId can be reused across turns with new content. Reset consent + + // pending state on fileId OR content change so a grant approved for one + // version never carries over to a different one. + useEffect(() => { + grantsRef.current.clear(); + queueRef.current = []; + setPending(null); + }, [fileId, content]); + + const dispatch = useCallback( + async (request: ToolRequest) => { + try { + const { result } = await callArtifactTool.mutateAsync({ + file_id: fileId, + messageId, + conversationId, + tool: request.name, + args: request.args, + }); + request.port.postMessage({ type: 'tool-result', id: request.id, result }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Tool call failed'; + request.port.postMessage({ type: 'tool-result', id: request.id, error: message }); + } + }, + [callArtifactTool, conversationId, fileId, messageId], + ); + + const showNextConsent = useCallback(() => { + setPending(queueRef.current.shift() ?? null); + }, []); + + const requestTool = useCallback( + (request: ToolRequest) => { + if (grantsRef.current.has(request.name)) { + dispatch(request); + return; + } + queueRef.current.push(request); + setPending((current) => current ?? queueRef.current.shift() ?? null); + }, + [dispatch], + ); + + const handleAllow = useCallback(() => { + if (!pending) { + return; + } + grantsRef.current.add(pending.name); + dispatch(pending); + showNextConsent(); + }, [dispatch, pending, showNextConsent]); + + const handleDeny = useCallback(() => { + if (!pending) { + return; + } + pending.port.postMessage({ type: 'tool-result', id: pending.id, error: 'Permission denied' }); + showNextConsent(); + }, [pending, showNextConsent]); + + // Token handshake: only transfer the bridge port to the iframe document that + // proves it knows our per-render token (i.e. ran our injected shim), and only + // honor tool calls once that document acks over the port. A self-navigated + // page can't produce the token, so it can never drive the bridge. + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const frame = iframeRef.current; + if (!frame || event.source !== frame.contentWindow) { + return; + } + if (event.data?.type !== 'librechat:ready' || event.data.token !== token) { + return; + } + portRef.current?.close(); + const channel = new MessageChannel(); + portRef.current = channel.port1; + let verified = false; + channel.port1.onmessage = (e: MessageEvent) => { + const data = e.data; + if (data?.type === 'librechat:ack' && data.token === token) { + verified = true; + return; + } + if (!verified) { + return; + } + if ( + data?.type === 'tool-call' && + typeof data.id === 'string' && + typeof data.name === 'string' + ) { + requestTool({ id: data.id, name: data.name, args: data.args ?? {}, port: channel.port1 }); + } + }; + frame.contentWindow.postMessage({ type: 'librechat:init', token }, '*', [channel.port2]); + }; + window.addEventListener('message', onMessage); + return () => { + window.removeEventListener('message', onMessage); + portRef.current?.close(); + portRef.current = null; + }; + }, [token, requestTool]); + + const handleReload = useCallback(() => { + portRef.current?.close(); + portRef.current = null; + setReloadNonce((nonce) => nonce + 1); + }, []); + + const consentLabels = pending ? splitMcpToolKey(pending.name) : null; + + return ( +
+
+ +
+ +