mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
120ee2afa6
commit
9bcc2572d6
23 changed files with 1152 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
235
client/src/components/Artifacts/LiveArtifactPreview.tsx
Normal file
235
client/src/components/Artifacts/LiveArtifactPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
client/src/utils/liveArtifact.spec.ts
Normal file
75
client/src/utils/liveArtifact.spec.ts
Normal 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?:\/\//);
|
||||
});
|
||||
});
|
||||
132
client/src/utils/liveArtifact.ts
Normal file
132
client/src/utils/liveArtifact.ts
Normal 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>`;
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './update';
|
||||
export * from './tools';
|
||||
|
|
|
|||
93
packages/api/src/artifacts/tools.spec.ts
Normal file
93
packages/api/src/artifacts/tools.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
75
packages/api/src/artifacts/tools.ts
Normal file
75
packages/api/src/artifacts/tools.ts
Normal 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 };
|
||||
};
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue