diff --git a/client/public/mcp-sandbox.html b/client/public/mcp-sandbox.html index 254a0df60f..a6e87b0122 100644 --- a/client/public/mcp-sandbox.html +++ b/client/public/mcp-sandbox.html @@ -18,9 +18,18 @@ let readyInterval = null; const SANDBOX_PREFIX = 'ui/notifications/sandbox-'; - // The sandbox is always served same-origin with LibreChat, so window.location.origin - // is the exact expected parent origin. No referrer fallback or lazy-set needed. - const trustedOrigin = window.location.origin; + // Default to same-origin. When the sandbox is served from a dedicated origin, the parent + // passes its origin via the parentOrigin query param so the handshake targets LibreChat + // rather than the sandbox's own origin. + const trustedOrigin = (function () { + try { + const param = new URLSearchParams(window.location.search).get('parentOrigin'); + if (param) { + return new URL(param).origin; + } + } catch (e) {} + return window.location.origin; + })(); function notifyReady() { window.parent.postMessage( diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 02cc767a0a..ba134f96c7 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -267,12 +267,15 @@ export default function ToolCall({ [args, output], ); - const mcpApp = useMemo(() => { + const mcpApps = useMemo(() => { const uiResources: UIResource[] = attachments ?.filter((a) => a.type === Tools.ui_resources) .flatMap((a) => (a[Tools.ui_resources] ?? []) as UIResource[]) ?? []; - return uiResources.find((r) => r.toolName && r.serverName && !r.text) ?? null; + return uiResources.filter( + (r) => + (r.toolName && r.serverName) || (r.text && (r.mimeType ?? 'text/html').includes('html')), + ); }, [attachments]); const authDomain = useMemo(() => { @@ -388,7 +391,8 @@ export default function ToolCall({ {!hideAttachments && attachments && attachments.length > 0 && ( )} - {mcpApp && hasOutput && } + {hasOutput && + mcpApps.map((app) => )} ); } diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx index 8b7184babc..c519198365 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCall.test.tsx @@ -35,6 +35,7 @@ jest.mock('~/hooks', () => ({ jest.mock('~/hooks/MCP', () => ({ useMCPIconMap: () => new Map(), + useAppBridge: jest.fn(), })); jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({ @@ -175,6 +176,35 @@ describe('ToolCall', () => { const attachmentGroup = screen.getByTestId('attachment-group'); expect(JSON.parse(attachmentGroup.textContent!)).toEqual(attachments); }); + + it('renders an iframe for an inline ui:// text resource attached to the tool call', () => { + const attachments = [ + { + type: Tools.ui_resources, + messageId: 'msg1', + toolCallId: 'tool1', + conversationId: 'conv1', + [Tools.ui_resources]: [ + { + uri: 'ui://test-server/inline.html', + mimeType: 'text/html', + text: '

inline resource

', + resourceId: 'inline-1', + toolName: 'test-tool', + serverName: 'test-server', + }, + ], + }, + ]; + + const { container } = renderWithRecoil( + , + ); + + const iframe = container.querySelector('iframe[srcdoc]'); + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('srcdoc', '

inline resource

'); + }); }); describe('attachment group rendering', () => { diff --git a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx index d26b7f85ff..082ee66d3d 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/UIResourceCarousel.test.tsx @@ -23,7 +23,6 @@ Object.defineProperty(HTMLElement.prototype, 'scrollTo', { const makeResource = (n: number): UIResource => ({ uri: `resource${n}`, mimeType: 'text/html', - text: `Resource ${n}`, resourceId: `r${n}`, toolName: 'test-tool', serverName: 'test-server', diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index e2aac894ff..ba98be4c80 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -6,10 +6,13 @@ import { buildAllowAttribute, } from '@modelcontextprotocol/ext-apps/app-bridge'; import type { UIResource } from 'librechat-data-provider'; -import { callMCPAppTool, fetchMCPResourceHtml } from '~/utils/mcpApps'; +import { callMCPAppTool, fetchMCPResourceHtml, readMCPResource } from '~/utils/mcpApps'; +import { useOptionalMessagesOperations } from '~/Providers'; import { logger } from '~/utils'; import store from '~/store'; +type MessageContentBlock = { type?: string; text?: string }; + type SizeParams = { width?: number; height?: number }; export function useAppBridge( @@ -20,7 +23,12 @@ export function useAppBridge( onSizeChanged: (params: SizeParams) => void, ) { const user = useRecoilValue(store.user); + const { ask } = useOptionalMessagesOperations(); const bridgeRef = useRef(null); + const askRef = useRef(ask); + useEffect(() => { + askRef.current = ask; + }); // Refs keep latest values accessible inside the stable effect closure without triggering remount. // The bridge mounts once per resourceId; toolArgs/toolResult/onSizeChanged are captured at @@ -56,7 +64,7 @@ export function useAppBridge( bridge = new AppBridge( null, { name: 'LibreChat', version: '1.0.0' }, - { openLinks: {}, serverTools: {}, logging: {} }, + { openLinks: {}, serverTools: {}, serverResources: {}, logging: {} }, { hostContext: { theme, @@ -81,6 +89,20 @@ export function useAppBridge( return {}; }; + bridge.onreadresource = async (params) => + readMCPResource(resource.serverName as string, params.uri, user?.id) as never; + + bridge.onmessage = async ({ content }) => { + const text = (content as MessageContentBlock[]) + .filter((block) => block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('\n'); + if (text) { + askRef.current({ text }); + } + return {}; + }; + bridge.addEventListener('sandboxready', async () => { try { const { html, csp, permissions } = await fetchMCPResourceHtml( diff --git a/client/src/utils/mcpApps.ts b/client/src/utils/mcpApps.ts index 6c354f2472..78baeafefa 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -2,8 +2,14 @@ import { request, apiBaseUrl } from 'librechat-data-provider'; export function getMCPSandboxUrl(): string { const configured = (import.meta.env as Record).VITE_MCP_SANDBOX_URL; - if (configured) return configured; - return `${window.location.origin}${apiBaseUrl()}/api/mcp/sandbox`; + const base = configured ?? `${window.location.origin}${apiBaseUrl()}/api/mcp/sandbox`; + try { + const url = new URL(base, window.location.origin); + url.searchParams.set('parentOrigin', window.location.origin); + return url.toString(); + } catch { + return base; + } } export async function callMCPAppTool( diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index da8341b3a8..1a143b8684 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -347,7 +347,14 @@ ${formattedInstructions} Please follow these instructions when using tools from the respective MCP servers.`; } - public clearResourceUriCache(serverName?: string): void { + public clearResourceUriCache(serverName?: string, userId?: string): void { + if (serverName && userId != null) { + const cacheKey = `${serverName}:${userId}`; + this.resourceUriCache.delete(cacheKey); + this.modelOnlyToolCache.delete(cacheKey); + this.knownToolNamesCache.delete(cacheKey); + return; + } if (serverName) { for (const key of this.resourceUriCache.keys()) { if (key === serverName || key.startsWith(`${serverName}:`)) { @@ -363,6 +370,11 @@ Please follow these instructions when using tools from the respective MCP server } } + protected removeUserConnection(userId: string, serverName: string): void { + this.clearResourceUriCache(serverName, userId); + super.removeUserConnection(userId, serverName); + } + private async populateToolCaches(connection: MCPConnection, cacheKey: string): Promise { const tools = await connection.fetchTools(); const serverMap = new Map< @@ -652,6 +664,66 @@ Please follow these instructions when using tools from the respective MCP server * Reads a UI resource from an MCP server. * Used by MCP Apps iframes to fetch additional resources via the host. */ + /** + * Resolves the same registry-backed config the original tool call used and hands it to + * getConnection so config-source servers resolve, then refreshes headers for non-DB-sourced + * servers. Iframe follow-up requests arrive without the original requestBody, so configs that + * still need runtime body placeholders are rejected rather than connected with unresolved values. + */ + private async getAppConnection({ + serverName, + userId, + user, + }: { + serverName: string; + userId: string; + user?: IUser; + }): Promise { + const logPrefix = `[MCP][User: ${userId}][${serverName}]`; + const rawConfig = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId); + const isDbSourced = rawConfig ? isUserSourced(rawConfig) : false; + if (rawConfig) { + if (rawConfig.obo) { + throw new McpError( + ErrorCode.InvalidRequest, + `${logPrefix} Server "${serverName}" requires per-call OBO token resolution which is not supported for app requests.`, + ); + } + if (!isDbSourced && mcpOptionsContainGraphTokenPlaceholder(rawConfig as t.MCPOptions)) { + throw new McpError( + ErrorCode.InvalidRequest, + `${logPrefix} Server "${serverName}" requires Graph API token resolution which is not supported for app requests.`, + ); + } + const missingBodyFields = getMissingRuntimeBodyPlaceholderFields(rawConfig); + if (missingBodyFields.length > 0) { + throw new McpError( + ErrorCode.InvalidRequest, + `${logPrefix} Server "${serverName}" requires request body field(s) (${missingBodyFields.join(', ')}) that are not available for app requests.`, + ); + } + } + + const connection = await this.getConnection({ + serverName, + user, + serverConfig: rawConfig ?? undefined, + }); + + if (rawConfig && !isDbSourced) { + const currentOptions = processMCPEnv({ + user, + dbSourced: false, + options: rawConfig as t.MCPOptions, + }); + const resolvedHeaders: Record = + 'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {}; + connection.setRequestHeaders(resolvedHeaders); + } + + return connection; + } + async readResource({ userId, serverName, @@ -665,7 +737,7 @@ Please follow these instructions when using tools from the respective MCP server }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); - const connection = await this.getConnection({ serverName, user }); + const connection = await this.getAppConnection({ serverName, userId, user }); if (!(await connection.isConnected())) { throw new McpError( @@ -705,7 +777,7 @@ Please follow these instructions when using tools from the respective MCP server }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); - const connection = await this.getConnection({ serverName, user }); + const connection = await this.getAppConnection({ serverName, userId, user }); if (!(await connection.isConnected())) { throw new McpError( @@ -732,36 +804,6 @@ Please follow these instructions when using tools from the respective MCP server ); } - const rawConfig = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId); - if (rawConfig) { - if (rawConfig.obo) { - throw new McpError( - ErrorCode.InvalidRequest, - `${logPrefix} Server "${serverName}" requires per-call OBO token resolution which is not supported for app tool calls.`, - ); - } - const isDbSourced = isUserSourced(rawConfig); - if (!isDbSourced && mcpOptionsContainGraphTokenPlaceholder(rawConfig as t.MCPOptions)) { - throw new McpError( - ErrorCode.InvalidRequest, - `${logPrefix} Server "${serverName}" requires Graph API token resolution which is not supported for app tool calls.`, - ); - } - // DB-sourced servers have their customUserVars (e.g. {{MCP_API_KEY}}) resolved during - // the original callTool setup. Re-processing without customUserVars would overwrite - // those resolved headers with unresolved placeholders, so skip for DB-sourced servers. - if (!isDbSourced) { - const currentOptions = processMCPEnv({ - user, - dbSourced: false, - options: rawConfig as t.MCPOptions, - }); - const resolvedHeaders: Record = - 'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {}; - connection.setRequestHeaders(resolvedHeaders); - } - } - const result = await connection.client.request( { method: 'tools/call', diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 865de69eb4..665d0681c5 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -1182,6 +1182,30 @@ describe('MCPManager', () => { }); }); + describe('appToolCall - app request context', () => { + const mockUser: Partial = { id: 'user-123' }; + + it('rejects when the server config needs request body placeholders unavailable to app calls', async () => { + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + source: 'yaml', + type: 'sse', + url: 'https://example.com/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp', + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + await expect( + manager.appToolCall({ + userId: 'user-123', + serverName: 'body-server', + toolName: 'do_thing', + toolArguments: {}, + user: mockUser as IUser, + }), + ).rejects.toThrow(/request body field/); + }); + }); + describe('getConnection', () => { const mockUser: Partial = { id: 'user-123',