mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 11:53:55 +00:00
Grant allow-same-origin to the sandbox inner frame only when the sandbox runs on a dedicated origin (parentOrigin differs from the sandbox origin), matching the spec dedicated-origin model so storage-backed apps work there while same-origin deployments stay isolated from the host. Push host-context updates to a live app: a MutationObserver on the document theme class sends sendHostContextChange with the new theme and derived style tokens when the user toggles light or dark while an app is open. Provide the standardized MCP Apps CSS theme variables (a mapped subset of LibreChat tokens) in the initial hostContext and on theme change. Add an opt-in strict CSP (VITE_MCP_SANDBOX_STRICT_CSP) that drops unsafe-eval, wasm-unsafe-eval, blob:, and data: from the sandbox script-src, threaded to the sandbox via a strictCsp query param.
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
import { request, apiBaseUrl } from 'librechat-data-provider';
|
|
import type { UIResource } from 'librechat-data-provider';
|
|
|
|
export type AppToolResult = {
|
|
content: unknown[];
|
|
structuredContent?: Record<string, unknown>;
|
|
isError?: boolean;
|
|
_meta?: Record<string, unknown>;
|
|
};
|
|
|
|
/**
|
|
* An MCP App resource is server-bound and declares the MCP Apps HTML profile
|
|
* (`text/html;profile=mcp-app`). Only these run the App Bridge handshake; plain `text/html`
|
|
* resources are static and must render through a srcDoc iframe instead.
|
|
*/
|
|
export function isMcpAppResource(resource: UIResource): boolean {
|
|
return (
|
|
!!resource.toolName &&
|
|
!!resource.serverName &&
|
|
(resource.mimeType ?? '').includes('profile=mcp-app')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Builds the App Bridge tool result from a UI resource. App-backed resources (toolName +
|
|
* serverName) always produce a result so the app's ontoolresult fires even for empty output,
|
|
* and the tool result's _meta is forwarded for apps that hydrate from it.
|
|
*/
|
|
export function buildAppToolResult(resource: UIResource): AppToolResult | undefined {
|
|
const sc = resource.structuredContent as Record<string, unknown> | undefined | null;
|
|
const content = (resource.content as unknown[] | undefined) ?? [];
|
|
const meta = resource.resultMeta as Record<string, unknown> | undefined;
|
|
const hasStructured = !!sc && typeof sc === 'object' && !Array.isArray(sc);
|
|
const isAppBacked = !!(resource.toolName && resource.serverName);
|
|
if (
|
|
!hasStructured &&
|
|
content.length === 0 &&
|
|
meta == null &&
|
|
resource.isError !== true &&
|
|
!isAppBacked
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
content,
|
|
...(hasStructured ? { structuredContent: sc } : {}),
|
|
...(resource.isError === true ? { isError: true } : {}),
|
|
...(meta != null ? { _meta: meta } : {}),
|
|
};
|
|
}
|
|
|
|
export function getMCPSandboxUrl(): string {
|
|
const env = import.meta.env as Record<string, string | undefined>;
|
|
const base = env.VITE_MCP_SANDBOX_URL ?? `${apiBaseUrl()}/api/mcp/sandbox`;
|
|
const strictCsp =
|
|
env.VITE_MCP_SANDBOX_STRICT_CSP === 'true' || env.VITE_MCP_SANDBOX_STRICT_CSP === '1';
|
|
try {
|
|
const url = new URL(base, window.location.origin);
|
|
url.searchParams.set('parentOrigin', window.location.origin);
|
|
if (strictCsp) {
|
|
url.searchParams.set('strictCsp', '1');
|
|
}
|
|
return url.toString();
|
|
} catch {
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export async function callMCPAppTool(
|
|
serverName: string,
|
|
toolName: string,
|
|
args: Record<string, unknown>,
|
|
) {
|
|
return request.post(`${apiBaseUrl()}/api/mcp/app-tool-call`, {
|
|
serverName,
|
|
toolName,
|
|
arguments: args,
|
|
});
|
|
}
|
|
|
|
export async function readMCPResource(serverName: string, uri: string) {
|
|
return request.post(`${apiBaseUrl()}/api/mcp/resources/read`, { serverName, uri });
|
|
}
|
|
|
|
export async function listMCPResources(serverName: string, cursor?: string) {
|
|
return request.post(`${apiBaseUrl()}/api/mcp/resources/list`, { serverName, cursor });
|
|
}
|
|
|
|
export async function listMCPResourceTemplates(serverName: string, cursor?: string) {
|
|
return request.post(`${apiBaseUrl()}/api/mcp/resources/templates/list`, { serverName, cursor });
|
|
}
|
|
|
|
type ResourceUiMeta = {
|
|
csp?: {
|
|
connectDomains?: string[];
|
|
resourceDomains?: string[];
|
|
frameDomains?: string[];
|
|
baseUriDomains?: string[];
|
|
};
|
|
permissions?: {
|
|
camera?: Record<string, never>;
|
|
microphone?: Record<string, never>;
|
|
geolocation?: Record<string, never>;
|
|
clipboardWrite?: Record<string, never>;
|
|
};
|
|
};
|
|
|
|
export async function fetchMCPResourceHtml(
|
|
serverName: string,
|
|
uri: string,
|
|
): Promise<{
|
|
html: string;
|
|
csp?: ResourceUiMeta['csp'];
|
|
permissions?: ResourceUiMeta['permissions'];
|
|
}> {
|
|
const result = (await readMCPResource(serverName, uri)) as {
|
|
contents?: Array<{ text?: string; blob?: string; _meta?: { ui?: ResourceUiMeta } }>;
|
|
};
|
|
const item = result?.contents?.[0];
|
|
const uiMeta = item?._meta?.ui;
|
|
let html = item?.text ?? '';
|
|
if (!html && typeof item?.blob === 'string' && item.blob) {
|
|
try {
|
|
// Decode base64 as UTF-8 so non-ASCII HTML (localized text, inline JSON) is not mojibake;
|
|
// atob alone yields a Latin-1 string.
|
|
const bytes = Uint8Array.from(atob(item.blob), (char) => char.charCodeAt(0));
|
|
html = new TextDecoder('utf-8').decode(bytes);
|
|
} catch {
|
|
html = '';
|
|
}
|
|
}
|
|
return {
|
|
html,
|
|
csp: uiMeta?.csp,
|
|
permissions: uiMeta?.permissions,
|
|
};
|
|
}
|