refactor: replace @mcp-ui/client with @modelcontextprotocol/ext-apps/app-bridge

Switch from the community-maintained @mcp-ui/client to the official Anthropic SDK for MCP app rendering.
Introduces useAppBridge hook that drives AppBridge + PostMessageTransport directly, giving us full
control over the spec protocol and proper propagation of _meta.ui.csp and _meta.ui.permissions from
tool definitions to the sandbox iframe.

Removes useMCPAppCallbacks and the AppRenderer dependency; all three render sites (ToolCall, UIResourceCarousel,
MCPUIResource) now use a plain iframe with useAppBridge. The existing mcp-sandbox.html proxy is unchanged.
This commit is contained in:
Dustin Healy 2026-06-23 13:55:56 -07:00
parent 4481e80111
commit f2f3c18ca4
15 changed files with 309 additions and 203 deletions

View file

@ -38,7 +38,7 @@
"@hyperdx/browser": "^0.24.0",
"@librechat/client": "*",
"@marsidev/react-turnstile": "^1.1.0",
"@mcp-ui/client": "^7.0.0",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2",

View file

@ -1,8 +1,7 @@
import React, { useMemo, useState, useEffect, useCallback, useContext } from 'react';
import React, { useRef, useMemo, useState, useEffect, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Button } from '@librechat/client';
import { TriangleAlert } from 'lucide-react';
import { AppRenderer } from '@mcp-ui/client';
import { Button, ThemeContext, isDark } from '@librechat/client';
import {
Constants,
Tools,
@ -13,8 +12,8 @@ import {
import type { TAttachment, UIResource } from 'librechat-data-provider';
import { useLocalize, useProgress, useExpandCollapse } from '~/hooks';
import { ToolIcon, getToolIconType, isError } from './ToolOutput';
import { useMCPIconMap, useMCPAppCallbacks } from '~/hooks/MCP';
import { getMCPSandboxConfig } from '~/utils/mcpApps';
import { useMCPIconMap, useAppBridge } from '~/hooks/MCP';
import { getMCPSandboxUrl } from '~/utils/mcpApps';
import { AttachmentGroup } from './Parts';
import ToolCallInfo from './ToolCallInfo';
import ProgressText from './ProgressText';
@ -26,15 +25,14 @@ const SPINNER_TIMEOUT_MS = 10_000;
const MCPAppView = React.memo(function MCPAppView({
app,
args,
themeMode,
}: {
app: UIResource;
args: string | Record<string, unknown>;
themeMode: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState<number | undefined>(undefined);
const [loaded, setLoaded] = useState(false);
const callbacks = useMCPAppCallbacks((app.serverName as string | undefined) ?? '');
const sandboxUrl = useMemo(() => getMCPSandboxUrl(), []);
useEffect(() => {
if (loaded) return;
@ -42,13 +40,7 @@ const MCPAppView = React.memo(function MCPAppView({
return () => clearTimeout(timer);
}, [loaded]);
const toolResult = useMemo(() => {
const sc = app.structuredContent as Record<string, unknown> | undefined | null;
if (!sc || typeof sc !== 'object' || Array.isArray(sc)) return undefined;
return { content: [], structuredContent: sc };
}, [app.structuredContent]);
const toolInput = useMemo(() => {
const toolArgs = useMemo(() => {
try {
return typeof args === 'string' ? JSON.parse(args) : args;
} catch {
@ -56,10 +48,11 @@ const MCPAppView = React.memo(function MCPAppView({
}
}, [args]);
const hostContext = useMemo(
() => ({ theme: isDark(themeMode) ? ('dark' as const) : ('light' as const) }),
[themeMode],
);
const toolResult = useMemo(() => {
const sc = app.structuredContent as Record<string, unknown> | undefined | null;
if (!sc || typeof sc !== 'object' || Array.isArray(sc)) return undefined;
return { content: [] as [], structuredContent: sc };
}, [app.structuredContent]);
const handleSizeChanged = useCallback((params: { height?: number; width?: number }) => {
if (params.height && params.height > 0) {
@ -68,7 +61,7 @@ const MCPAppView = React.memo(function MCPAppView({
}
}, []);
const handleError = useCallback((err: Error) => logger.error('[MCP App]', err), []);
useAppBridge(iframeRef, app, toolArgs, toolResult, handleSizeChanged);
return (
<div className="my-2" style={height ? { height } : { minHeight: 100 }}>
@ -92,18 +85,17 @@ const MCPAppView = React.memo(function MCPAppView({
Loading interactive view...
</div>
)}
<AppRenderer
toolName={(app.toolName as string | undefined) ?? ''}
sandbox={getMCPSandboxConfig()}
toolResourceUri={app.uri}
toolResult={toolResult}
toolInput={toolInput}
hostContext={hostContext}
onCallTool={callbacks.onCallTool}
onReadResource={callbacks.onReadResource}
onOpenLink={callbacks.onOpenLink}
onSizeChanged={handleSizeChanged}
onError={handleError}
<iframe
ref={iframeRef}
data-sandbox-url={sandboxUrl}
sandbox="allow-scripts allow-forms"
style={{
width: '100%',
height: '100%',
border: 'none',
display: loaded ? 'block' : 'none',
}}
title={`MCP App: ${(app.toolName as string | undefined) ?? ''}`}
/>
</div>
);
@ -133,7 +125,6 @@ export default function ToolCall({
onExpand?: () => void;
}) {
const localize = useLocalize();
const { theme: themeMode } = useContext(ThemeContext);
const autoExpand = useRecoilValue(store.autoExpandTools);
const hasOutput = (output?.length ?? 0) > 0;
const [showInfo, setShowInfo] = useState(() => autoExpand && hasOutput);
@ -371,9 +362,7 @@ export default function ToolCall({
{!hideAttachments && attachments && attachments.length > 0 && (
<AttachmentGroup attachments={attachments} />
)}
{mcpApp && hasOutput && (
<MCPAppView key={mcpApp.resourceId} app={mcpApp} args={_args} themeMode={themeMode} />
)}
{mcpApp && hasOutput && <MCPAppView key={mcpApp.resourceId} app={mcpApp} args={_args} />}
</>
);
}

View file

@ -1,30 +1,52 @@
import React, { useState } from 'react';
import { AppRenderer } from '@mcp-ui/client';
import type { UIResource } from 'librechat-data-provider';
import { getMCPSandboxConfig } from '~/utils/mcpApps';
import { useMCPAppCallbacks } from '~/hooks/MCP';
import { logger } from '~/utils';
import { getMCPSandboxUrl } from '~/utils/mcpApps';
import { useAppBridge } from '~/hooks/MCP';
interface UIResourceCarouselProps {
uiResources: UIResource[];
}
function MCPAppCard({ resource }: { resource: UIResource }) {
const callbacks = useMCPAppCallbacks((resource.serverName as string | undefined) ?? '');
const handleError = React.useCallback((err: Error) => logger.error('[MCP App]', err), []);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
const [loaded, setLoaded] = useState(false);
const sandboxUrl = React.useMemo(() => getMCPSandboxUrl(), []);
const toolResult = React.useMemo(() => {
const sc = resource.structuredContent as Record<string, unknown> | undefined | null;
if (!sc || typeof sc !== 'object' || Array.isArray(sc)) return undefined;
return { content: [] as [], structuredContent: sc };
}, [resource.structuredContent]);
const handleSizeChanged = React.useCallback((params: { height?: number; width?: number }) => {
if (params.height && params.height > 0) {
setLoaded(true);
}
}, []);
useAppBridge(iframeRef, resource, undefined, toolResult, handleSizeChanged);
if (resource.toolName && resource.serverName) {
return (
<AppRenderer
toolName={(resource.toolName as string | undefined) ?? ''}
sandbox={getMCPSandboxConfig()}
html={resource.text}
toolResourceUri={resource.uri}
onCallTool={callbacks.onCallTool}
onReadResource={callbacks.onReadResource}
onOpenLink={callbacks.onOpenLink}
onError={handleError}
/>
<>
{!loaded && (
<div className="flex h-full items-center justify-center rounded-lg border border-border-light bg-surface-secondary text-sm text-text-secondary">
Loading interactive view...
</div>
)}
<iframe
ref={iframeRef}
data-sandbox-url={sandboxUrl}
sandbox="allow-scripts allow-forms"
style={{
width: '100%',
height: '100%',
border: 'none',
display: loaded ? 'block' : 'none',
}}
title={`MCP App: ${(resource.toolName as string | undefined) ?? ''}`}
/>
</>
);
}

View file

@ -1,17 +1,17 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import Markdown from '../Markdown';
import MarkdownLite from '../MarkdownLite';
import { RecoilRoot } from 'recoil';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { render, screen } from '@testing-library/react';
import {
useMessageContext,
useOptionalMessagesConversation,
useOptionalMessagesOperations,
} from '~/Providers';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { UI_RESOURCE_MARKER } from '~/components/MCPUIResource/plugin';
import { useGetMessagesByConvoId } from '~/data-provider';
import MarkdownLite from '../MarkdownLite';
import { useLocalize } from '~/hooks';
import Markdown from '../Markdown';
jest.mock('~/Providers', () => ({
...jest.requireActual('~/Providers'),
@ -23,28 +23,15 @@ jest.mock('~/data-provider');
jest.mock('~/hooks');
jest.mock('~/hooks/Messages/useConversationUIResources');
jest.mock('@mcp-ui/client', () => ({
AppRenderer: ({ toolName, toolResourceUri }: { toolName: string; toolResourceUri: string }) => (
<div
data-testid="ui-resource-renderer"
data-resource-uri={toolResourceUri}
data-tool-name={toolName}
/>
),
}));
jest.mock('~/utils/mcpApps', () => ({
getMCPSandboxConfig: () => ({ url: new URL('http://localhost/sandbox') }),
getMCPSandboxUrl: () => 'http://localhost/sandbox',
callMCPAppTool: jest.fn(),
readMCPResource: jest.fn(),
fetchMCPResourceHtml: jest.fn(),
}));
jest.mock('~/hooks/MCP', () => ({
useMCPAppCallbacks: () => ({
onCallTool: jest.fn(),
onReadResource: jest.fn(),
onOpenLink: jest.fn(),
}),
useAppBridge: jest.fn(),
useMCPIconMap: () => new Map(),
}));
@ -136,10 +123,8 @@ describe('Markdown with MCP UI markers (resource IDs)', () => {
</RecoilRoot>,
);
const renderers = screen.getAllByTestId('ui-resource-renderer');
expect(renderers).toHaveLength(2);
expect(renderers[0]).toHaveAttribute('data-resource-uri', 'ui://weather/paris');
expect(renderers[1]).toHaveAttribute('data-resource-uri', 'ui://weather/nyc');
const iframes = document.querySelectorAll('iframe[data-sandbox-url]');
expect(iframes).toHaveLength(2);
});
});

View file

@ -4,27 +4,14 @@ import type { UIResource } from 'librechat-data-provider';
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
jest.mock('~/hooks/MCP', () => ({
useMCPAppCallbacks: () => ({
onCallTool: jest.fn(),
onReadResource: jest.fn(),
onOpenLink: jest.fn(),
}),
}));
jest.mock('@mcp-ui/client', () => ({
AppRenderer: ({ toolResourceUri }: { toolResourceUri: string }) => (
<div data-testid="ui-resource-renderer" data-uri={toolResourceUri} />
),
useAppBridge: jest.fn(),
}));
jest.mock('~/utils/mcpApps', () => ({
getMCPSandboxConfig: () => ({ url: new URL('http://localhost/sandbox') }),
getMCPSandboxUrl: () => 'http://localhost/sandbox',
callMCPAppTool: jest.fn(),
readMCPResource: jest.fn(),
}));
jest.mock('~/utils', () => ({
logger: { error: jest.fn() },
fetchMCPResourceHtml: jest.fn(),
}));
const mockScrollTo = jest.fn();
@ -61,15 +48,17 @@ describe('UIResourceCarousel', () => {
});
it('renders all UI resources', () => {
render(<UIResourceCarousel uiResources={mockUIResources} />);
expect(screen.getAllByTestId('ui-resource-renderer')).toHaveLength(5);
const { container } = render(<UIResourceCarousel uiResources={mockUIResources} />);
expect(container.querySelectorAll('iframe[data-sandbox-url]')).toHaveLength(5);
});
it('renders AppRenderer with correct uri for each resource', () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
const renderers = screen.getAllByTestId('ui-resource-renderer');
expect(renderers[0]).toHaveAttribute('data-uri', 'resource1');
expect(renderers[1]).toHaveAttribute('data-uri', 'resource2');
it('renders bridge iframe for each bound resource', () => {
const { container } = render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
const iframes = container.querySelectorAll('iframe[data-sandbox-url]');
expect(iframes).toHaveLength(2);
iframes.forEach((iframe) => {
expect(iframe).toHaveAttribute('data-sandbox-url', 'http://localhost/sandbox');
});
});
it('falls back to iframe for inline resources without toolName', () => {
@ -142,10 +131,10 @@ describe('UIResourceCarousel', () => {
});
it('applies correct dimensions to resource containers', () => {
render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
const renderers = screen.getAllByTestId('ui-resource-renderer');
renderers.forEach((el, index) => {
const card = el.parentElement?.parentElement;
const { container } = render(<UIResourceCarousel uiResources={mockUIResources.slice(0, 2)} />);
const iframes = container.querySelectorAll('iframe[data-sandbox-url]');
iframes.forEach((iframe, index) => {
const card = iframe.parentElement?.parentElement;
expect(card).toHaveStyle({
width: '230px',
minHeight: '360px',

View file

@ -1,9 +1,8 @@
import React from 'react';
import { AppRenderer } from '@mcp-ui/client';
import React, { useRef, useState, useMemo, useCallback } from 'react';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useOptionalMessagesConversation } from '~/Providers';
import { getMCPSandboxConfig } from '~/utils/mcpApps';
import { useMCPAppCallbacks } from '~/hooks/MCP';
import { getMCPSandboxUrl } from '~/utils/mcpApps';
import { useAppBridge } from '~/hooks/MCP';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';
@ -15,13 +14,32 @@ interface MCPUIResourceProps {
};
}
const EMPTY_RESOURCE = { resourceId: '', uri: '' };
export function MCPUIResource(props: MCPUIResourceProps) {
const { resourceId } = props.node.properties;
const localize = useLocalize();
const { conversationId } = useOptionalMessagesConversation();
const conversationResourceMap = useConversationUIResources(conversationId ?? undefined);
const uiResource = conversationResourceMap.get(resourceId ?? '');
const callbacks = useMCPAppCallbacks((uiResource?.serverName as string | undefined) ?? '');
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loaded, setLoaded] = useState(false);
const sandboxUrl = useMemo(() => getMCPSandboxUrl(), []);
const toolResult = useMemo(() => {
const sc = uiResource?.structuredContent as Record<string, unknown> | undefined | null;
if (!sc || typeof sc !== 'object' || Array.isArray(sc)) return undefined;
return { content: [] as [], structuredContent: sc };
}, [uiResource?.structuredContent]);
const handleSizeChanged = useCallback((params: { height?: number; width?: number }) => {
if (params.height && params.height > 0) {
setLoaded(true);
}
}, []);
useAppBridge(iframeRef, uiResource ?? EMPTY_RESOURCE, undefined, toolResult, handleSizeChanged);
if (!uiResource) {
return (
@ -37,14 +55,22 @@ export function MCPUIResource(props: MCPUIResourceProps) {
if (uiResource.toolName && uiResource.serverName && !uiResource.text) {
return (
<span className="mx-1 inline-block w-full align-middle">
<AppRenderer
toolName={(uiResource.toolName as string | undefined) ?? ''}
sandbox={getMCPSandboxConfig()}
toolResourceUri={uiResource.uri}
onCallTool={callbacks.onCallTool}
onReadResource={callbacks.onReadResource}
onOpenLink={callbacks.onOpenLink}
onError={(err) => logger.error('[MCP App]', err)}
{!loaded && (
<div className="flex items-center gap-2 rounded-lg border border-border-light bg-surface-secondary px-4 py-3 text-sm text-text-secondary">
Loading interactive view...
</div>
)}
<iframe
ref={iframeRef}
data-sandbox-url={sandboxUrl}
sandbox="allow-scripts allow-forms"
style={{
width: '100%',
minHeight: '200px',
border: 'none',
display: loaded ? 'block' : 'none',
}}
title={`MCP App: ${(uiResource.toolName as string | undefined) ?? ''}`}
/>
</span>
);

View file

@ -1,11 +1,11 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { MCPUIResource } from '../MCPUIResource';
import { useOptionalMessagesConversation } from '~/Providers';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useLocalize } from '~/hooks';
import { render, screen } from '@testing-library/react';
import type { UIResource } from 'librechat-data-provider';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { useOptionalMessagesConversation } from '~/Providers';
import { MCPUIResource } from '../MCPUIResource';
import { useLocalize } from '~/hooks';
jest.mock('~/Providers', () => ({
useOptionalMessagesConversation: jest.fn(),
@ -15,27 +15,14 @@ jest.mock('~/hooks', () => ({
useLocalize: jest.fn(),
}));
jest.mock('~/hooks/MCP', () => ({
useMCPAppCallbacks: () => ({
onCallTool: jest.fn(),
onReadResource: jest.fn(),
onOpenLink: jest.fn(),
}),
}));
jest.mock('@mcp-ui/client', () => ({
AppRenderer: ({ toolName, toolResourceUri }: { toolName: string; toolResourceUri: string }) => (
<div
data-testid="ui-resource-renderer"
data-resource-uri={toolResourceUri}
data-tool-name={toolName}
/>
),
useAppBridge: jest.fn(),
}));
jest.mock('~/utils/mcpApps', () => ({
getMCPSandboxConfig: () => ({ url: new URL('http://localhost/sandbox') }),
getMCPSandboxUrl: () => 'http://localhost/sandbox',
callMCPAppTool: jest.fn(),
readMCPResource: jest.fn(),
fetchMCPResourceHtml: jest.fn(),
}));
jest.mock('~/utils', () => ({
@ -81,16 +68,16 @@ describe('MCPUIResource', () => {
});
describe('rendering', () => {
it('renders AppRenderer for resources with toolName and serverName', () => {
it('renders bridge iframe for resources with toolName and serverName', () => {
const resource = makeResource();
mockUseConversationUIResources.mockReturnValue(new Map([['resource-1', resource]]));
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'resource-1' } }} />);
const renderer = screen.getByTestId('ui-resource-renderer');
expect(renderer).toBeInTheDocument();
expect(renderer).toHaveAttribute('data-resource-uri', 'ui://test/resource');
expect(renderer).toHaveAttribute('data-tool-name', 'test-tool');
const iframe = document.querySelector('iframe[data-sandbox-url]');
expect(iframe).toBeInTheDocument();
expect(iframe?.getAttribute('sandbox')).toBe('allow-scripts allow-forms');
expect(iframe?.getAttribute('title')).toBe('MCP App: test-tool');
});
it('renders inline iframe for resources with html text and no server binding', () => {
@ -131,7 +118,7 @@ describe('MCPUIResource', () => {
renderWithRecoil(<MCPUIResource node={{ properties: { resourceId: 'nonexistent-id' } }} />);
expect(screen.getByText('UI resource nonexistent-id not found')).toBeInTheDocument();
expect(screen.queryByTestId('ui-resource-renderer')).not.toBeInTheDocument();
expect(screen.queryByRole('iframe')).not.toBeInTheDocument();
});
it('shows not-found badge when conversationId is absent', () => {

View file

@ -1,5 +1,5 @@
export * from './useMCPSelect';
export { useMCPAppCallbacks } from './useMCPAppCallbacks';
export { useAppBridge } from './useAppBridge';
export * from './useVisibleTools';
export * from './useMCPServerManager';
export * from './useMCPConnectionStatus';

View file

@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react';
import {
AppBridge,
PostMessageTransport,
buildAllowAttribute,
} from '@modelcontextprotocol/ext-apps/app-bridge';
import type { UIResource } from 'librechat-data-provider';
import { callMCPAppTool, fetchMCPResourceHtml } from '~/utils/mcpApps';
import { logger } from '~/utils';
type SizeParams = { width?: number; height?: number };
export function useAppBridge(
iframeRef: React.RefObject<HTMLIFrameElement | null>,
resource: UIResource,
toolArgs: Record<string, unknown> | undefined,
toolResult: { content: []; structuredContent?: Record<string, unknown> } | undefined,
onSizeChanged: (params: SizeParams) => void,
) {
const bridgeRef = useRef<AppBridge | null>(null);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !resource.serverName) return;
let bridge: AppBridge | null = null;
const handleLoad = async () => {
if (!iframe.contentWindow) return;
const transport = new PostMessageTransport(iframe.contentWindow, iframe.contentWindow);
bridge = new AppBridge(
null,
{ name: 'LibreChat', version: '1.0.0' },
{ openLinks: {}, serverTools: {}, logging: {} },
);
bridge.oncalltool = async (params) =>
callMCPAppTool(
resource.serverName as string,
params.name,
(params.arguments as Record<string, unknown>) ?? {},
) as never;
bridge.onopenlink = async ({ url }) => {
window.open(url, '_blank', 'noopener,noreferrer');
return {};
};
bridge.addEventListener('sandboxready', async () => {
try {
const html = await fetchMCPResourceHtml(resource.serverName as string, resource.uri);
const allowAttr = buildAllowAttribute(
resource.permissions as Parameters<typeof buildAllowAttribute>[0],
);
const sandboxTokens = ['allow-scripts', 'allow-forms'];
if (allowAttr) sandboxTokens.push(allowAttr);
await bridge!.sendSandboxResourceReady({
html,
csp: resource.csp as never,
permissions: resource.permissions as never,
sandbox: sandboxTokens.join(' '),
});
} catch (err) {
logger.error('[MCP App] Failed to send sandbox resource', err);
}
});
bridge.oninitialized = async () => {
if (toolArgs) {
await bridge!
.sendToolInput({ arguments: toolArgs })
.catch((err: unknown) => logger.error('[MCP App] sendToolInput failed', err));
}
if (toolResult) {
await bridge!
.sendToolResult(toolResult as never)
.catch((err: unknown) => logger.error('[MCP App] sendToolResult failed', err));
}
};
bridge.addEventListener('sizechange', onSizeChanged);
await bridge
.connect(transport)
.catch((err: unknown) => logger.error('[MCP App] bridge.connect failed', err));
bridgeRef.current = bridge;
};
iframe.addEventListener('load', handleLoad, { once: true });
iframe.src = iframe.getAttribute('data-sandbox-url') ?? '';
return () => {
bridgeRef.current?.teardownResource({}).catch(() => {});
bridgeRef.current?.close();
bridgeRef.current = null;
bridge = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resource.resourceId]);
}

View file

@ -1,22 +0,0 @@
import { useCallback } from 'react';
import { callMCPAppTool, readMCPResource } from '~/utils/mcpApps';
export function useMCPAppCallbacks(serverName: string) {
const onCallTool = useCallback(
(params: { name: string; arguments?: unknown }) =>
callMCPAppTool(serverName, params.name, (params.arguments as Record<string, unknown>) ?? {}),
[serverName],
);
const onReadResource = useCallback(
(params: { uri: string }) => readMCPResource(serverName, params.uri),
[serverName],
);
const onOpenLink = useCallback(async ({ url }: { url: string }) => {
window.open(url, '_blank', 'noopener,noreferrer');
return {};
}, []);
return { onCallTool, onReadResource, onOpenLink };
}

View file

@ -1,12 +1,9 @@
import { request } from 'librechat-data-provider';
let sandboxUrl: URL | null = null;
export function getMCPSandboxConfig() {
if (!sandboxUrl) {
sandboxUrl = new URL('/api/mcp/sandbox', window.location.origin);
}
return { url: sandboxUrl };
export function getMCPSandboxUrl(): string {
const configured = (import.meta.env as Record<string, string | undefined>).VITE_MCP_SANDBOX_URL;
if (configured) return configured;
return `${window.location.origin}/api/mcp/sandbox`;
}
export async function callMCPAppTool(
@ -48,3 +45,10 @@ export async function readMCPResource(serverName: string, uri: string) {
promise.catch(() => resourceCache.delete(key));
return promise;
}
export async function fetchMCPResourceHtml(serverName: string, uri: string): Promise<string> {
const result = (await readMCPResource(serverName, uri)) as {
contents?: Array<{ text?: string }>;
};
return result?.contents?.[0]?.text ?? '';
}

17
package-lock.json generated
View file

@ -422,7 +422,7 @@
"@hyperdx/browser": "^0.24.0",
"@librechat/client": "*",
"@marsidev/react-turnstile": "^1.1.0",
"@mcp-ui/client": "^7.0.0",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "1.0.2",
@ -10042,21 +10042,6 @@
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
}
},
"node_modules/@mcp-ui/client": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@mcp-ui/client/-/client-7.1.1.tgz",
"integrity": "sha512-Yy0q3YFl6WmcHRW0pRwD2F+Fs9Y/TFm1xpBpkuqvS1IRBaGGgc7PvkB0nvrKTqO6QZm+MufObfQM+oxo8mmLFw==",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.2.0",
"@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.23.8"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",

View file

@ -1,6 +1,7 @@
import pick from 'lodash/pick';
import { logger } from '@librechat/data-schemas';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { getToolUiResourceUri } from '@modelcontextprotocol/ext-apps/app-bridge';
import {
CallToolResultSchema,
ReadResourceResultSchema,
@ -9,6 +10,7 @@ import {
} from '@modelcontextprotocol/sdk/types.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { UIResource } from 'librechat-data-provider';
import type { OboTokenResolver, OboTrustChecker } from '~/mcp/oauth/obo';
import type { GraphTokenResolver } from '~/utils/graph';
import type { FlowStateManager } from '~/flow/manager';
@ -57,7 +59,10 @@ function createOboToolCallErrorMessage(
*/
export class MCPManager extends UserConnectionManager {
private static instance: MCPManager | null;
private readonly resourceUriCache = new Map<string, Map<string, string>>();
private readonly resourceUriCache = new Map<
string,
Map<string, { uri: string; csp?: UIResource['csp']; permissions?: UIResource['permissions'] }>
>();
/** Creates and initializes the singleton MCPManager instance */
public static async createInstance(configs: t.MCPServers): Promise<MCPManager> {
@ -344,20 +349,28 @@ Please follow these instructions when using tools from the respective MCP server
}
}
private async getResourceUri(
private async getResourceMeta(
connection: MCPConnection,
serverName: string,
toolName: string,
): Promise<string | undefined> {
): Promise<
{ uri: string; csp?: UIResource['csp']; permissions?: UIResource['permissions'] } | undefined
> {
let serverMap = this.resourceUriCache.get(serverName);
if (!serverMap) {
const tools = await connection.fetchTools();
serverMap = new Map<string, string>();
serverMap = new Map();
for (const tool of tools) {
const meta = tool._meta as { ui?: { resourceUri?: string } } | undefined;
const uri = meta?.ui?.resourceUri;
const uri = getToolUiResourceUri(tool);
if (uri) {
serverMap.set(tool.name, uri);
const meta = tool._meta as
| { ui?: { csp?: UIResource['csp']; permissions?: UIResource['permissions'] } }
| undefined;
serverMap.set(tool.name, {
uri,
csp: meta?.ui?.csp,
permissions: meta?.ui?.permissions,
});
}
}
this.resourceUriCache.set(serverName, serverMap);
@ -567,11 +580,13 @@ Please follow these instructions when using tools from the respective MCP server
this.updateUserLastActivity(userId);
}
this.checkIdleConnections();
let resourceUri: string | undefined;
let resourceMeta:
| { uri: string; csp?: UIResource['csp']; permissions?: UIResource['permissions'] }
| undefined;
try {
resourceUri = await this.getResourceUri(connection, serverName, toolName);
if (resourceUri) {
logger.debug(`[MCP][${serverName}][${toolName}] Found resourceUri: ${resourceUri}`);
resourceMeta = await this.getResourceMeta(connection, serverName, toolName);
if (resourceMeta) {
logger.debug(`[MCP][${serverName}][${toolName}] Found resourceUri: ${resourceMeta.uri}`);
}
} catch {
// Non-critical -- tools render without the app UI
@ -580,7 +595,9 @@ Please follow these instructions when using tools from the respective MCP server
return formatToolContent(result as t.MCPToolCallResponse, provider, {
serverName,
toolName,
resourceUri,
resourceUri: resourceMeta?.uri,
csp: resourceMeta?.csp,
permissions: resourceMeta?.permissions,
});
} catch (error) {
// Log with context and re-throw or handle as needed

View file

@ -141,7 +141,13 @@ function parseAsString(result: t.MCPToolCallResponse): string {
export function formatToolContent(
result: t.MCPToolCallResponse,
provider: t.Provider,
metadata?: { serverName?: string; toolName?: string; resourceUri?: string },
metadata?: {
serverName?: string;
toolName?: string;
resourceUri?: string;
csp?: UIResource['csp'];
permissions?: UIResource['permissions'];
},
): t.FormattedContentResult {
if (!RECOGNIZED_PROVIDERS.has(provider)) {
return [parseAsString(result), undefined];
@ -195,6 +201,8 @@ export function formatToolContent(
resourceId,
serverName: metadata?.serverName,
toolName: metadata?.toolName,
csp: metadata?.csp,
permissions: metadata?.permissions,
};
uiResources.push(uiResource);
resourceText.push(`UI Resource ID: ${resourceId}`);
@ -242,6 +250,8 @@ export function formatToolContent(
serverName: metadata.serverName,
toolName: metadata.toolName,
structuredContent: result?.structuredContent,
csp: metadata.csp,
permissions: metadata.permissions,
});
}

View file

@ -839,6 +839,18 @@ export type UIResource = {
serverName?: string;
toolName?: string;
structuredContent?: Record<string, unknown>;
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>;
};
[key: string]: unknown;
};