From 228627750ac7c22815a517bb37b1361dbe46a2c8 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:14:32 -0700 Subject: [PATCH] fix(mcp): bridge inline apps, forward full results, and close review gaps Bridges inline MCP App HTML. Server-bound resources now always render through the sandbox bridge rather than a bare srcDoc iframe, and useAppBridge sends the resource's inline text directly when present instead of a resources/read round trip, so inline text/html;profile=mcp-app apps complete their App.connect handshake and receive tool input and results. Bare srcDoc is kept only for inline HTML with no server binding. Forwards the complete tool result to apps. A shared buildAppToolResult always produces a result for app-backed resources so ontoolresult fires even for empty output, and it carries the tool result _meta the App Bridge forwards via sendToolResult (the result is a full CallToolResult), which apps use to hydrate component-only state. Advertises the message capability. The bridge handles ui/message via onmessage but omitted the matching host capability, so spec-compliant apps disabled message actions; it now advertises the text message modality it supports. Permits app reads of server resources. The resources/read proxy required the ui:// scheme, which contradicts the serverResources capability the bridge advertises, so it now accepts any resource URI and leaves authorization to the MCP server. Allows WebSocket origins in app CSP. The sandbox host allowlist dropped wss:// endpoints declared in csp.connectDomains; the pattern now permits ws and wss so apps relying on live updates can connect. Invalidates app-level tool metadata on reconnect. App-level connections can be transparently recreated when a server config changes, so cached resourceUri and visibility are now keyed to the connection that produced them and rebuilt when it changes. --- api/server/controllers/mcpApps.js | 7 ++-- client/public/mcp-sandbox.html | 6 ++-- .../Chat/Messages/Content/ToolCall.tsx | 17 +++------ .../Messages/Content/UIResourceCarousel.tsx | 16 ++------- .../Content/__tests__/Markdown.mcpui.test.tsx | 1 + .../Content/__tests__/ToolCall.test.tsx | 5 +-- .../__tests__/UIResourceCarousel.test.tsx | 1 + .../MCPUIResource/MCPUIResource.tsx | 19 ++++------ .../__tests__/MCPUIResource.test.tsx | 1 + client/src/hooks/MCP/useAppBridge.ts | 21 +++++++---- client/src/utils/mcpApps.ts | 36 +++++++++++++++++++ packages/api/src/mcp/MCPManager.ts | 22 ++++++++++-- packages/api/src/mcp/parsers.ts | 2 ++ packages/data-provider/src/schemas.ts | 1 + 14 files changed, 100 insertions(+), 55 deletions(-) diff --git a/api/server/controllers/mcpApps.js b/api/server/controllers/mcpApps.js index ca9738fe78..7568a1b6da 100644 --- a/api/server/controllers/mcpApps.js +++ b/api/server/controllers/mcpApps.js @@ -17,8 +17,11 @@ const readMCPResource = async (req, res) => { if (!serverName || !uri) { return res.status(400).json({ error: 'serverName and uri are required' }); } - if (typeof uri !== 'string' || !uri.startsWith('ui://')) { - return res.status(400).json({ error: 'uri must use the ui:// scheme' }); + // The serverResources capability lets an app read any resource the connected MCP server + // exposes (ui:// templates plus supporting data such as file:// or custom schemes), so the + // proxy only requires a non-empty string and leaves resource authorization to the server. + if (typeof uri !== 'string' || uri.length === 0) { + return res.status(400).json({ error: 'uri must be a non-empty string' }); } const mcpManager = getMCPManager(); diff --git a/client/public/mcp-sandbox.html b/client/public/mcp-sandbox.html index a6e87b0122..f2e5483dab 100644 --- a/client/public/mcp-sandbox.html +++ b/client/public/mcp-sandbox.html @@ -167,9 +167,9 @@ ].join('; '); } - // Only permit host patterns: optional scheme, optional wildcard subdomain prefix, - // hostname characters, optional port. Rejects CSP keywords and injection attempts. - const SAFE_HOST_RE = /^(?:https?:\/\/)?(?:\*\.)?[a-zA-Z0-9][a-zA-Z0-9\-.]*(?::\d{1,5})?$/; + // Only permit host patterns: optional http(s)/ws(s) scheme, optional wildcard subdomain + // prefix, hostname characters, optional port. Rejects CSP keywords and injection attempts. + const SAFE_HOST_RE = /^(?:(?:https?|wss?):\/\/)?(?:\*\.)?[a-zA-Z0-9][a-zA-Z0-9\-.]*(?::\d{1,5})?$/; function toDomainList(value) { if (!Array.isArray(value)) return ''; diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index ba134f96c7..a5c6b96e6e 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -10,10 +10,10 @@ import { actionDomainSeparator, } from 'librechat-data-provider'; import type { TAttachment, UIResource } from 'librechat-data-provider'; +import { getMCPSandboxUrl, buildAppToolResult } from '~/utils/mcpApps'; import { useLocalize, useProgress, useExpandCollapse } from '~/hooks'; import { ToolIcon, getToolIconType, isError } from './ToolOutput'; import { useMCPIconMap, useAppBridge } from '~/hooks/MCP'; -import { getMCPSandboxUrl } from '~/utils/mcpApps'; import { AttachmentGroup } from './Parts'; import ToolCallInfo from './ToolCallInfo'; import ProgressText from './ProgressText'; @@ -50,17 +50,7 @@ const MCPAppView = React.memo(function MCPAppView({ } }, [args]); - const toolResult = useMemo(() => { - const sc = app.structuredContent as Record | undefined | null; - const content = (app.content as [] | undefined) ?? []; - if ((!sc || typeof sc !== 'object' || Array.isArray(sc)) && content.length === 0) - return undefined; - return { - content, - ...(sc && typeof sc === 'object' && !Array.isArray(sc) ? { structuredContent: sc } : {}), - ...(app.isError === true ? { isError: true } : {}), - }; - }, [app.structuredContent, app.content, app.isError]); + const toolResult = useMemo(() => buildAppToolResult(app), [app]); const handleSizeChanged = useCallback((params: { height?: number; width?: number }) => { if (params.height && params.height > 0) { @@ -71,7 +61,8 @@ const MCPAppView = React.memo(function MCPAppView({ useAppBridge(iframeRef, app, toolArgs, toolResult, handleSizeChanged); - if (app.text && (app.mimeType ?? 'text/html').includes('html')) { + const isAppBacked = !!(app.toolName && app.serverName); + if (!isAppBacked && app.text && (app.mimeType ?? 'text/html').includes('html')) { return (