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.
This commit is contained in:
Danny Avila 2026-08-03 17:31:48 -04:00
parent 120ee2afa6
commit 9bcc2572d6
23 changed files with 1152 additions and 17 deletions

View file

@ -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;

View file

@ -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<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;
@ -256,4 +467,5 @@ module.exports = {
callTool,
getToolCalls,
verifyToolAuth,
callArtifactTool,
};

View file

@ -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

View file

@ -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,

View file

@ -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 =

View file

@ -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 (
<div className="flex h-full w-full flex-col">
@ -56,15 +61,24 @@ export default function ArtifactTabs({
className="h-full w-full flex-grow overflow-hidden"
tabIndex={-1}
>
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}
startupConfig={resolvedStartupConfig}
/>
{live ? (
<LiveArtifactPreview
content={currentCode ?? artifact.content ?? ''}
fileId={artifact.fileId ?? ''}
messageId={artifact.messageId}
conversationId={artifact.conversationId}
/>
) : (
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}
startupConfig={resolvedStartupConfig}
/>
)}
</Tabs.Content>
</div>
);

View file

@ -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<string, unknown>;
/** 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<HTMLIFrameElement | null>(null);
const portRef = useRef<MessagePort | null>(null);
const grantsRef = useRef<Set<string>>(new Set());
const queueRef = useRef<ToolRequest[]>([]);
const [pending, setPending] = useState<ToolRequest | null>(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 (
<div className="relative h-full w-full">
<div className="absolute right-2 top-2 z-10">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={handleReload}
aria-label={localize('com_ui_live_artifact_reload')}
>
<RotateCw size={16} aria-hidden="true" />
</Button>
</div>
<iframe
key={`${fileId}:${reloadNonce}`}
ref={iframeRef}
srcDoc={srcDocument}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
title={localize('com_ui_live_artifact_frame_title')}
className="h-full w-full border-0 bg-white"
/>
{pending && consentLabels && (
<div
role="alertdialog"
aria-modal="true"
aria-label={localize('com_ui_live_artifact_consent_title')}
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
>
<div className="w-full max-w-sm rounded-xl bg-surface-primary p-5 shadow-xl">
<h2 className="mb-2 text-base font-semibold text-text-primary">
{localize('com_ui_live_artifact_consent_title')}
</h2>
<p className="mb-4 text-sm text-text-secondary">
{localize('com_ui_live_artifact_consent_message', {
tool: consentLabels.toolName,
server: consentLabels.serverName,
})}
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={handleDeny}>
{localize('com_ui_deny')}
</Button>
<Button variant="submit" onClick={handleAllow}>
{localize('com_ui_allow')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,8 +1,18 @@
import { dataService, QueryKeys, Tools } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, QueryKeys, Tools } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
export const useArtifactToolCallMutation = (
options?: t.ArtifactToolCallMutationOptions,
): UseMutationResult<t.ArtifactToolCallResponse, Error, t.ArtifactToolCallParams> => {
return useMutation((params: t.ArtifactToolCallParams) => dataService.callArtifactTool(params), {
onMutate: (variables) => options?.onMutate?.(variables),
onError: (error, variables, context) => options?.onError?.(error, variables, context),
onSuccess: (response, variables, context) => options?.onSuccess?.(response, variables, context),
});
};
export const useToolCallMutation = <T extends t.ToolId>(
toolId: T,
options?: t.ToolCallMutationOptions<T>,

View file

@ -821,6 +821,7 @@
"com_ui_all_projects": "All projects",
"com_ui_all_proper": "All",
"com_ui_all_turns": "All turns",
"com_ui_allow": "Allow",
"com_ui_always_interrupt": "Always interrupt instead",
"com_ui_analyzing": "Analyzing",
"com_ui_analyzing_finished": "Finished analyzing",
@ -1124,6 +1125,7 @@
"com_ui_deleted": "Deleted",
"com_ui_deleting": "Deleting...",
"com_ui_deleting_file": "Deleting file...",
"com_ui_deny": "Deny",
"com_ui_deploy": "Deploy",
"com_ui_descending": "Desc",
"com_ui_description": "Description",
@ -1355,6 +1357,10 @@
"com_ui_link_copied": "Link copied",
"com_ui_link_refreshed": "Link refreshed",
"com_ui_live": "live",
"com_ui_live_artifact_consent_message": "This artifact wants to call \"{{tool}}\" on {{server}} using your connected account.",
"com_ui_live_artifact_consent_title": "Allow live data access?",
"com_ui_live_artifact_frame_title": "Live artifact preview",
"com_ui_live_artifact_reload": "Reload artifact",
"com_ui_load_more": "Load more",
"com_ui_loading": "Loading...",
"com_ui_locked": "Locked",

View file

@ -840,6 +840,8 @@ export function fileToArtifact(
| 'type'
| 'text'
| 'textFormat'
| 'metadata'
| 'conversationId'
| 'updatedAt'
| 'createdAt'
| 'source'
@ -894,6 +896,11 @@ export function fileToArtifact(
// placeholder, matching "no extraction has run yet."
content: attachment.text ?? options?.placeholder ?? '',
language,
fileId: attachment.file_id,
conversationId: attachment.conversationId ?? undefined,
// Live-artifact allowlist: present only for HTML files whose author
// declared callable MCP tools. Drives `isLiveArtifact` detection.
tools: attachment.metadata?.mcpTools,
messageId: attachment.messageId ?? undefined,
lastUpdateTime: toLastUpdate(attachment),
/* Preserve the original-file download coordinates so the panel's

View file

@ -0,0 +1,75 @@
import { isLiveArtifact, splitMcpToolKey, buildLiveArtifactDocument } from './liveArtifact';
describe('isLiveArtifact', () => {
it('is true only for HTML with a non-empty tools allowlist', () => {
expect(isLiveArtifact('text/html', ['list_prs_mcp_github'])).toBe(true);
expect(isLiveArtifact('text/html', [])).toBe(false);
expect(isLiveArtifact('text/html', undefined)).toBe(false);
expect(isLiveArtifact('application/vnd.react', ['list_prs_mcp_github'])).toBe(false);
});
});
describe('splitMcpToolKey', () => {
it('splits a tool key into tool and server', () => {
expect(splitMcpToolKey('list_prs_mcp_github')).toEqual({
toolName: 'list_prs',
serverName: 'github',
});
});
it('returns the whole key as tool with empty server for non-MCP keys', () => {
expect(splitMcpToolKey('execute_code')).toEqual({ toolName: 'execute_code', serverName: '' });
});
});
describe('buildLiveArtifactDocument', () => {
it('wraps a bare fragment in a full document with CSP and the bridge first', () => {
const doc = buildLiveArtifactDocument('<h1>hi</h1>', 'tok-1');
expect(doc.startsWith('<!doctype html>')).toBe(true);
expect(doc).toContain("connect-src 'none'");
expect(doc).toContain('window.librechat');
expect(doc).toContain('<h1>hi</h1>');
// CSP + shim are injected before the fragment body
expect(doc.indexOf('window.librechat')).toBeLessThan(doc.indexOf('<h1>hi</h1>'));
});
it('embeds the handshake token in a self-removing shim', () => {
const doc = buildLiveArtifactDocument('<h1>hi</h1>', 'secret-tok');
expect(doc).toContain('secret-tok');
expect(doc).toContain('librechat:ready');
expect(doc).toContain('librechat:ack');
// The shim removes its own element so the token can't be read from the DOM.
expect(doc).toContain('document.currentScript');
expect(doc).toMatch(/removeChild\(self\)/);
});
it('puts CSP + shim before authored markup even when content has a pre-<head> prefix', () => {
// A model prefix before <head> must NOT execute before the policy.
const html =
'<script>steal()</script><html><head><title>x</title></head><body><p>y</p></body></html>';
const doc = buildLiveArtifactDocument(html, 'tok-2');
expect(doc).toContain('<title>x</title>');
expect(doc).toContain('<p>y</p>');
expect(doc.indexOf('Content-Security-Policy')).toBeLessThan(doc.indexOf('<script>steal()'));
expect(doc.indexOf('window.librechat')).toBeLessThan(doc.indexOf('<script>steal()'));
});
it('nests an <html>-without-<head> document under the policy', () => {
const doc = buildLiveArtifactDocument('<html><body><p>z</p></body></html>', 'tok-3');
expect(doc).toContain('Content-Security-Policy');
expect(doc).toContain('window.librechat');
expect(doc.indexOf('window.librechat')).toBeLessThan(doc.indexOf('<p>z</p>'));
});
it('blocks all network egress: no remote script/style, no img/font hosts', () => {
const doc = buildLiveArtifactDocument('<div></div>', 'tok-4');
expect(doc).toContain("default-src 'none'");
expect(doc).toContain("script-src 'unsafe-inline'");
expect(doc).toContain("connect-src 'none'");
expect(doc).toContain("form-action 'none'");
expect(doc).toContain("base-uri 'none'");
// No remote origins anywhere in the CSP (bridge-only egress).
const csp = doc.slice(doc.indexOf('Content-Security-Policy'), doc.indexOf('">'));
expect(csp).not.toMatch(/https?:\/\//);
});
});

View file

@ -0,0 +1,132 @@
import { Constants } from 'librechat-data-provider';
/** A live artifact is an HTML file whose record declares an MCP tool allowlist. */
export const isLiveArtifact = (type?: string, tools?: string[]): boolean =>
type === 'text/html' && Array.isArray(tools) && tools.length > 0;
/** Split an MCP tool key (`<tool>_mcp_<server>`) for display in the consent prompt. */
export const splitMcpToolKey = (tool: string): { toolName: string; serverName: string } => {
const delimiter = Constants.mcp_delimiter as string;
const index = tool.indexOf(delimiter);
if (index === -1) {
return { toolName: tool, serverName: '' };
}
return { toolName: tool.slice(0, index), serverName: tool.slice(index + delimiter.length) };
};
/**
* Strict CSP injected into every live artifact. Live artifacts receive private
* MCP tool results, so the contract is hard: the consented bridge is the ONLY
* egress. No directive may permit an outbound request to any host.
*
* - `default-src 'none'` denies everything not explicitly allowed.
* - `script-src 'unsafe-inline'` / `style-src 'unsafe-inline'` allow only
* *inline* code NO remote origins (a `<script src=cdn/…data>` is an egress
* channel even to an allowlisted host, so all JS/CSS must be inlined).
* - `img-src data:` / `font-src data:` block pixel/`@font-face` URL exfil.
* - `connect-src 'none'` blocks fetch/XHR/WebSocket/EventSource/sendBeacon.
* - `form-action 'none'` blocks form-POST exfil; `navigate-to 'none'` blocks
* self-navigation exfil where the engine supports it.
*
* Note: a `<meta>` CSP cannot carry `frame-ancestors`/`sandbox`/`report-uri`
* (enforced by the iframe element's attributes), and `navigate-to` has partial
* engine support so self-navigation is also defended in the host by refusing
* to hand the bridge port to a navigated document.
*/
const CONTENT_SECURITY_POLICY = [
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
'img-src data:',
'font-src data:',
"connect-src 'none'",
"form-action 'none'",
"navigate-to 'none'",
"base-uri 'none'",
].join('; ');
/**
* Bridge shim, injected as the first script in the document. A per-render
* `token` (a secret kept inside this IIFE page scripts can't read it) gates a
* handshake: the shim announces readiness with the token, the host transfers a
* private `MessagePort` only in response, and the shim `ack`s with the token
* over the port to prove THIS document (not a navigated/attacker page that
* lacks the token) holds it. Then `window.librechat.callMcpTool(name, args)`
* round-trips over the port and resolves with the tool result the only egress.
*/
const buildBridgeShim = (token: string): string => `
(function () {
// Remove this script element before any model-authored script can run, so the
// token literal in its source can't be read out of the DOM (e.g. via
// document.scripts[...].textContent) and replayed after a self-navigation.
var self = document.currentScript;
if (self && self.parentNode) self.parentNode.removeChild(self);
var TOKEN = ${JSON.stringify(token)};
var pending = {};
var seq = 0;
var resolvePort;
var portReady = new Promise(function (r) { resolvePort = r; });
function settle(data) {
var entry = pending[data.id];
if (!entry) return;
delete pending[data.id];
if (data.error) entry.reject(new Error(data.error));
else entry.resolve(data.result);
}
window.addEventListener('message', function (event) {
if (!event.data || event.data.type !== 'librechat:init') return;
if (event.data.token !== TOKEN || !event.ports[0]) return;
var port = event.ports[0];
port.onmessage = function (e) {
if (e.data && e.data.type === 'tool-result') settle(e.data);
};
// Prove to the host that the original (token-bearing) document holds the port.
port.postMessage({ type: 'librechat:ack', token: TOKEN });
resolvePort(port);
});
function callMcpTool(name, args) {
return portReady.then(function (port) {
return new Promise(function (resolve, reject) {
var id = 'c' + (++seq);
pending[id] = { resolve: resolve, reject: reject };
port.postMessage({ type: 'tool-call', id: id, name: name, args: args || {} });
setTimeout(function () {
if (pending[id]) { delete pending[id]; reject(new Error('Tool call timed out')); }
}, 60000);
});
});
}
window.librechat = { callMcpTool: callMcpTool };
// Announce readiness so the host hands the bridge port to THIS document.
if (window.parent !== window) {
window.parent.postMessage({ type: 'librechat:ready', token: TOKEN }, '*');
}
})();
`;
const buildHead = (token: string): string =>
`<meta http-equiv="Content-Security-Policy" content="${CONTENT_SECURITY_POLICY}">` +
`<meta charset="utf-8">` +
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
`<script>${buildBridgeShim(token)}</script>`;
/**
* Wrap model-authored HTML so the CSP + bridge shim are the very first things
* the parser sees. We ALWAYS nest the authored markup inside our own
* `<body>` rather than injecting into the model's `<head>` injecting after an
* existing `<head>` would let any markup the model placed *before* that tag
* (e.g. `<script>…</script><html><head>`) execute before the policy is active.
* Nesting a full document inside the body is tolerated by parsers (the inner
* doctype/html/head tags are ignored; scripts/styles still run) and guarantees
* the CSP governs everything.
*
* `token` is a per-render secret embedded in the shim; the host transfers the
* bridge port only to a document that proves it knows this token.
*/
export const buildLiveArtifactDocument = (html: string, token: string): string =>
`<!doctype html><html><head>${buildHead(token)}</head><body>${html}</body></html>`;

View file

@ -2193,6 +2193,11 @@ describe('createToolExecuteHandler', () => {
accessibleSkillIds: [],
skillAuthoringAvailable: false,
fileAuthoringToolNames: new Set(['create_file', 'edit_file']),
// Agent's resolved tools — the authoring-time allowlist is filtered to these.
toolRegistry: new Map([
['list_prs_mcp_github', { name: 'list_prs_mcp_github' }],
['send_msg_mcp_slack', { name: 'send_msg_mcp_slack' }],
]),
},
}));
return createToolExecuteHandler({
@ -2247,6 +2252,91 @@ describe('createToolExecuteHandler', () => {
});
});
it('attaches sanitized mcp_tools to the artifact for an HTML file', async () => {
const readSandboxFile = jest.fn(async () => {
throw new Error('cat: /mnt/data/dash.html: No such file or directory');
});
const writeSandboxFile = jest.fn(async () => ({
stdout: 'WROTE 11 bytes to /mnt/data/dash.html\n',
session_id: 'sess-h',
files: [{ id: 'file-h', name: 'dash.html', storage_session_id: 'sess-h' }],
}));
const handler = makeSandboxAuthoringHandler({ readSandboxFile, writeSandboxFile });
const [result] = await invokeHandler(handler, [
{
id: 'call_create_html',
name: 'create_file',
args: {
file_path: '/mnt/data/dash.html',
content: '<h1>hi</h1>',
mcp_tools: ['list_prs_mcp_github', 'not-an-mcp-tool', 'send_msg_mcp_slack'],
},
} as unknown as ToolCallRequest,
]);
expect(result.status).toBe('success');
expect(result.artifact).toMatchObject({
path: '/mnt/data/dash.html',
mcp_tools: ['list_prs_mcp_github', 'send_msg_mcp_slack'],
});
});
it('drops declared mcp_tools the agent does not expose', async () => {
const readSandboxFile = jest.fn(async () => {
throw new Error('cat: /mnt/data/dash.html: No such file or directory');
});
const writeSandboxFile = jest.fn(async () => ({
stdout: 'WROTE 11 bytes to /mnt/data/dash.html\n',
session_id: 'sess-h',
files: [{ id: 'file-h', name: 'dash.html', storage_session_id: 'sess-h' }],
}));
const handler = makeSandboxAuthoringHandler({ readSandboxFile, writeSandboxFile });
const [result] = await invokeHandler(handler, [
{
id: 'call_create_escalate',
name: 'create_file',
args: {
file_path: '/mnt/data/dash.html',
content: '<h1>hi</h1>',
// delete_repo_mcp_github is not in the agent's toolRegistry → dropped.
mcp_tools: ['list_prs_mcp_github', 'delete_repo_mcp_github'],
},
} as unknown as ToolCallRequest,
]);
expect(result.status).toBe('success');
expect(result.artifact).toMatchObject({ mcp_tools: ['list_prs_mcp_github'] });
});
it('ignores mcp_tools for a non-HTML file', async () => {
const readSandboxFile = jest.fn(async () => {
throw new Error('cat: /mnt/data/data.txt: No such file or directory');
});
const writeSandboxFile = jest.fn(async () => ({
stdout: 'WROTE 5 bytes to /mnt/data/data.txt\n',
session_id: 'sess-t',
files: [{ id: 'file-t', name: 'data.txt', storage_session_id: 'sess-t' }],
}));
const handler = makeSandboxAuthoringHandler({ readSandboxFile, writeSandboxFile });
const [result] = await invokeHandler(handler, [
{
id: 'call_create_txt',
name: 'create_file',
args: {
file_path: '/mnt/data/data.txt',
content: 'plain',
mcp_tools: ['list_prs_mcp_github'],
},
} as unknown as ToolCallRequest,
]);
expect(result.status).toBe('success');
expect(result.artifact).not.toHaveProperty('mcp_tools');
});
it('refuses to overwrite an existing sandbox file without overwrite: true', async () => {
const writeSandboxFile = jest.fn();
const handler = makeSandboxAuthoringHandler({

View file

@ -45,6 +45,7 @@ import {
} from './intent';
import { logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils';
import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills';
import { sanitizeMcpToolList } from '../artifacts/tools';
import { parseFrontmatter } from '../skills/import';
import { cleanCodeToolOutput } from './cleanup';
import { primeSkillFiles } from './skillFiles';
@ -1737,6 +1738,37 @@ async function loadSandboxTextForAuthoring({
}
}
/** True for paths the live-artifact renderer treats as HTML. */
function isHtmlAuthoringPath(filePath: string): boolean {
return /\.html?$/i.test(filePath);
}
/**
* Keep only the tools the agent's own resolved registry exposes, so a
* model-authored page can't self-allow a tool outside the agent's configured
* subset. Fail closed: no registry no allowlist (the agent has no MCP tools
* to grant in the first place).
*/
function filterToAgentMcpTools(
tools: string[],
mergedConfigurable: Record<string, unknown>,
): string[] {
if (tools.length === 0) {
return tools;
}
const registry = mergedConfigurable?.toolRegistry as LCToolRegistry | undefined;
if (!registry || typeof registry.values !== 'function') {
return [];
}
const available = new Set<string>();
for (const tool of registry.values()) {
if (tool?.name) {
available.add(tool.name);
}
}
return tools.filter((tool) => available.has(tool));
}
async function writeSandboxTextForAuthoring({
tc,
options,
@ -1745,6 +1777,7 @@ async function writeSandboxTextForAuthoring({
content,
oldContent,
created,
mcpTools,
sandboxContext,
}: {
tc: ToolCallRequest;
@ -1754,6 +1787,7 @@ async function writeSandboxTextForAuthoring({
content: string;
oldContent?: string;
created: boolean;
mcpTools?: string[];
sandboxContext?: SandboxSessionContext;
}): AuthoringResult {
if (!options.writeSandboxFile) {
@ -1795,6 +1829,7 @@ async function writeSandboxTextForAuthoring({
bytes_written: Buffer.byteLength(content, 'utf8'),
created,
...(diff ? { diff } : {}),
...(Array.isArray(mcpTools) ? { mcp_tools: mcpTools } : {}),
...(writeResult.session_id ? { session_id: writeResult.session_id } : {}),
...(writeResult.files ? { files: writeResult.files } : {}),
});
@ -2527,6 +2562,7 @@ async function handleSandboxCreateFileCall({
filePath,
content,
overwrite,
mcpTools,
sandboxContext,
}: {
tc: ToolCallRequest;
@ -2535,6 +2571,7 @@ async function handleSandboxCreateFileCall({
filePath: string;
content: string;
overwrite: boolean;
mcpTools?: string[];
sandboxContext?: SandboxSessionContext;
}): AuthoringResult {
const pathError = invalidSandboxAuthoringPath(filePath);
@ -2564,6 +2601,7 @@ async function handleSandboxCreateFileCall({
content,
oldContent: current.status === 'loaded' ? current.content : undefined,
created: current.status === 'missing',
mcpTools,
sandboxContext,
});
}
@ -2641,7 +2679,12 @@ async function handleCreateFileCall(
sourceConfigurable?: Record<string, unknown>,
sandboxContext?: SandboxSessionContext,
): AuthoringResult {
const args = tc.args as { path?: unknown; content?: unknown; overwrite?: unknown };
const args = tc.args as {
path?: unknown;
content?: unknown;
overwrite?: unknown;
mcp_tools?: unknown;
};
if (typeof args.path !== 'string' || args.path.length === 0) {
return errorResult(tc, 'path is required');
}
@ -2658,6 +2701,13 @@ async function handleCreateFileCall(
}
const overwrite = args.overwrite === true;
/** Live-artifact allowlist: HTML files only; well-formed keys the agent
* actually exposes (so a page can't self-allow a tool outside its subset).
* An array (incl. empty) is authoritative for HTML create_file empty
* revokes; `undefined` (non-HTML / edit_file) means "preserve existing". */
const mcpTools = isHtmlAuthoringPath(args.path)
? filterToAgentMcpTools(sanitizeMcpToolList(args.mcp_tools), mergedConfigurable)
: undefined;
if (!args.path.startsWith(SKILL_FILE_PREFIX)) {
if (mergedConfigurable?.codeEnvAvailable !== true) {
return errorResult(
@ -2672,6 +2722,7 @@ async function handleCreateFileCall(
filePath: args.path,
content: args.content,
overwrite,
mcpTools,
sandboxContext,
});
}

View file

@ -209,6 +209,12 @@ const CODE_CREATE_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({
description: 'Must be true to replace an existing file. Refuses otherwise.',
default: false,
},
mcp_tools: {
type: 'array',
items: { type: 'string' },
description:
'For an HTML file only: MCP tool keys (format "<tool>_mcp_<server>") this page may call live via window.librechat.callMcpTool(name, args), which returns a Promise of the tool result. Declaring tools turns the file into a live artifact that fetches fresh data on open. The page is sandboxed with a strict CSP: inline ALL CSS/JS (no external <script src>/<link>/CDN), images must be data: URIs, and the only network access is window.librechat.callMcpTool. Omit for static files.',
},
},
required: ['path', 'content'],
}) as LCTool['parameters'];
@ -295,7 +301,9 @@ const CODE_CREATE_FILE_DESCRIPTION = `Create a new file, or overwrite an existin
Use for new files and full rewrites where the change is larger than half the file. Requires overwrite: true to replace existing files. Refuses otherwise.
Targets code-execution sandbox paths. Prefer /mnt/data/{file} for files that should remain available to later sandbox calls.`;
Targets code-execution sandbox paths. Prefer /mnt/data/{file} for files that should remain available to later sandbox calls.
For an HTML file, pass mcp_tools to make it a live artifact: the rendered page may call those MCP tools via window.librechat.callMcpTool(name, args) to fetch fresh data on open.`;
const SKILL_EDIT_FILE_DESCRIPTION = `Apply targeted text replacements to an existing file.

View file

@ -1 +1,2 @@
export * from './update';
export * from './tools';

View file

@ -0,0 +1,93 @@
import {
isToolAllowed,
isMcpToolKey,
parseMcpToolKey,
sanitizeMcpToolList,
authorizeArtifactToolCall,
} from './tools';
describe('isToolAllowed', () => {
it('matches exact tool keys only', () => {
const allow = ['list_prs_mcp_github'];
expect(isToolAllowed(allow, 'list_prs_mcp_github')).toBe(true);
expect(isToolAllowed(allow, 'delete_repo_mcp_github')).toBe(false);
});
it('is false for an undefined or empty allowlist', () => {
expect(isToolAllowed(undefined, 'list_prs_mcp_github')).toBe(false);
expect(isToolAllowed([], 'list_prs_mcp_github')).toBe(false);
});
});
describe('parseMcpToolKey', () => {
it('splits a valid MCP tool key', () => {
expect(parseMcpToolKey('list_prs_mcp_github')).toEqual({
toolName: 'list_prs',
serverName: 'github',
});
});
it('returns null for non-MCP keys', () => {
expect(parseMcpToolKey('execute_code')).toBeNull();
expect(isMcpToolKey('execute_code')).toBe(false);
expect(isMcpToolKey('list_prs_mcp_github')).toBe(true);
});
});
describe('sanitizeMcpToolList', () => {
it('keeps only well-formed MCP keys, deduped, in order', () => {
expect(
sanitizeMcpToolList([
'list_prs_mcp_github',
'list_prs_mcp_github',
'execute_code',
42,
'send_msg_mcp_slack',
]),
).toEqual(['list_prs_mcp_github', 'send_msg_mcp_slack']);
});
it('drops malformed MCP keys with an empty tool or server segment', () => {
expect(sanitizeMcpToolList(['_mcp_github', 'tool_mcp_', 'list_prs_mcp_github'])).toEqual([
'list_prs_mcp_github',
]);
});
it('returns [] for non-arrays', () => {
expect(sanitizeMcpToolList(undefined)).toEqual([]);
expect(sanitizeMcpToolList('list_prs_mcp_github')).toEqual([]);
});
});
describe('authorizeArtifactToolCall', () => {
const allowlist = ['list_prs_mcp_github', 'send_msg_mcp_slack'];
it('authorizes an allowlisted MCP tool and resolves its server', () => {
expect(authorizeArtifactToolCall(allowlist, 'list_prs_mcp_github')).toEqual({
allowed: true,
serverName: 'github',
toolName: 'list_prs',
});
});
it('rejects a tool outside the file allowlist', () => {
expect(authorizeArtifactToolCall(allowlist, 'delete_repo_mcp_github')).toEqual({
allowed: false,
reason: 'not_allowed',
});
});
it('rejects when the file declares no allowlist', () => {
expect(authorizeArtifactToolCall(undefined, 'list_prs_mcp_github')).toEqual({
allowed: false,
reason: 'not_allowed',
});
});
it('rejects an allowlisted non-MCP tool', () => {
expect(authorizeArtifactToolCall(['execute_code'], 'execute_code')).toEqual({
allowed: false,
reason: 'not_mcp',
});
});
});

View file

@ -0,0 +1,75 @@
import { Constants } from 'librechat-data-provider';
/** Outcome of authorizing a live-artifact tool call against its file allowlist. */
export type ArtifactToolAuthorization =
| { allowed: true; serverName: string; toolName: string }
| { allowed: false; reason: 'not_allowed' | 'not_mcp' };
/** True when a tool key follows the MCP convention `<tool>_mcp_<server>`. */
export const isMcpToolKey = (tool: string): boolean =>
tool.includes(Constants.mcp_delimiter as string);
/** Split an MCP tool key into its tool and server parts, or null if malformed. */
export const parseMcpToolKey = (tool: string): { toolName: string; serverName: string } | null => {
const delimiter = Constants.mcp_delimiter as string;
const index = tool.indexOf(delimiter);
if (index === -1) {
return null;
}
const toolName = tool.slice(0, index);
const serverName = tool.slice(index + delimiter.length);
if (!toolName || !serverName) {
return null;
}
return { toolName, serverName };
};
export const isToolAllowed = (allowlist: string[] | undefined, tool: string): boolean =>
Array.isArray(allowlist) && allowlist.includes(tool);
/**
* Normalize a model-supplied `mcp_tools` value into a deduped list of
* well-formed MCP tool keys. Drops non-strings and non-MCP entries the
* authoring-time gate that keeps `file.metadata.mcpTools` clean. Returns `[]`
* for anything that isn't a usable allowlist.
*/
export const sanitizeMcpToolList = (value: unknown): string[] => {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const tools: string[] = [];
for (const entry of value) {
// `parseMcpToolKey` (not `isMcpToolKey`) so malformed keys like
// `_mcp_github` or `tool_mcp_` — which would always fail authorization —
// never reach `file.metadata.mcpTools`.
if (typeof entry === 'string' && parseMcpToolKey(entry) !== null && !seen.has(entry)) {
seen.add(entry);
tools.push(entry);
}
}
return tools;
};
/**
* Decide whether a live artifact may call `tool`. The allowlist is the
* `mcpTools` array stored on the artifact's file record
* (`file.metadata.mcpTools`) server-stored, so a tampered client cannot widen
* it. Kept pure so it is exhaustively testable.
*
* - `not_allowed`: the tool is absent from the file's allowlist.
* - `not_mcp`: the tool is allowlisted but is not an MCP tool key.
*/
export const authorizeArtifactToolCall = (
allowlist: string[] | undefined,
tool: string,
): ArtifactToolAuthorization => {
if (!isToolAllowed(allowlist, tool)) {
return { allowed: false, reason: 'not_allowed' };
}
const parsed = parseMcpToolKey(tool);
if (!parsed) {
return { allowed: false, reason: 'not_mcp' };
}
return { allowed: true, serverName: parsed.serverName, toolName: parsed.toolName };
};

View file

@ -425,6 +425,17 @@ export const callTool = <T extends m.ToolId>({
);
};
export const callArtifactTool = (
params: m.ArtifactToolCallParams,
): Promise<m.ArtifactToolCallResponse> => {
return request.post(
endpoints.agents({
path: 'tools/mcp/call',
}),
params,
);
};
export const getToolCalls = (params: q.GetToolCallParams): Promise<q.ToolCallResults> => {
return request.get(
endpoints.agents({

View file

@ -159,6 +159,12 @@ export type TFile = {
* resolve via `resolveCodeEnvRef`.
*/
codeEnvRef?: CodeEnvRef;
/**
* MCP tool keys (`<tool>_mcp_<server>`) a live HTML artifact may
* call from its sandboxed iframe. Server-stored allowlist that the
* live-artifact bridge re-validates against on every call.
*/
mcpTools?: string[];
};
createdAt?: string | Date;
updatedAt?: string | Date;

View file

@ -464,6 +464,24 @@ export type ToolCallMutationOptions<T extends ToolId> = MutationOptions<
ToolParams<T>
>;
/** A single MCP tool call dispatched from a live artifact's bridge. */
export type ArtifactToolCallParams = {
/** LibreChat MCP tool key, e.g. `list_prs_mcp_github`. */
tool: string;
/** Artifact source file; its `metadata.mcpTools` allowlist authorizes the call. */
file_id: string;
messageId?: string;
conversationId?: string;
partIndex?: number;
blockIndex?: number;
args?: Record<string, unknown>;
};
export type ArtifactToolCallResponse = { result: unknown; artifact?: unknown };
export type ArtifactToolCallMutationOptions = MutationOptions<
ArtifactToolCallResponse,
ArtifactToolCallParams
>;
export type TDeleteSharedLinkResponse = {
success: boolean;
shareId: string;

View file

@ -143,6 +143,13 @@ const file: Schema<IMongoFile> = new Schema(
type: Number,
default: undefined,
},
/* MCP tool keys (`<tool>_mcp_<server>`) a live HTML artifact is permitted
* to call from its sandboxed iframe. Server-stored allowlist the bridge
* re-validates each call against this, so a tampered client cannot widen it. */
mcpTools: {
type: [String],
default: undefined,
},
},
expiresAt: {
/* Short-lived upload TTL managed by MongoDB. This is separate from

View file

@ -70,6 +70,12 @@ export interface IMongoFile extends Omit<Document, 'model'> {
* derive the sessionKey explicitly.
*/
codeEnvRef?: CodeEnvRef;
/**
* MCP tool keys (`<tool>_mcp_<server>`) a live HTML artifact may
* call from its sandboxed iframe. Server-stored allowlist for the
* live-artifact bridge.
*/
mcpTools?: string[];
};
expiresAt?: Date;
expiredAt?: Date | null;