mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
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:
parent
4481e80111
commit
f2f3c18ca4
15 changed files with 309 additions and 203 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ?? ''}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './useMCPSelect';
|
||||
export { useMCPAppCallbacks } from './useMCPAppCallbacks';
|
||||
export { useAppBridge } from './useAppBridge';
|
||||
export * from './useVisibleTools';
|
||||
export * from './useMCPServerManager';
|
||||
export * from './useMCPConnectionStatus';
|
||||
|
|
|
|||
102
client/src/hooks/MCP/useAppBridge.ts
Normal file
102
client/src/hooks/MCP/useAppBridge.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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
17
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue