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.
This commit is contained in:
Dustin Healy 2026-06-24 00:14:32 -07:00
parent de28930ddf
commit 228627750a
14 changed files with 100 additions and 55 deletions

View file

@ -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();

View file

@ -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 '';

View file

@ -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<string, unknown> | 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 (
<div className="my-2">
<iframe

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import type { UIResource } from 'librechat-data-provider';
import { getMCPSandboxUrl } from '~/utils/mcpApps';
import { getMCPSandboxUrl, buildAppToolResult } from '~/utils/mcpApps';
import { useAppBridge } from '~/hooks/MCP';
import { useLocalize } from '~/hooks';
@ -20,17 +20,7 @@ function MCPAppCard({
const [loaded, setLoaded] = useState(false);
const sandboxUrl = React.useMemo(() => getMCPSandboxUrl(), []);
const toolResult = React.useMemo(() => {
const sc = resource.structuredContent as Record<string, unknown> | undefined | null;
const content = (resource.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 } : {}),
...(resource.isError === true ? { isError: true } : {}),
};
}, [resource.structuredContent, resource.content, resource.isError]);
const toolResult = React.useMemo(() => buildAppToolResult(resource), [resource]);
const handleSizeChanged = React.useCallback(
(params: { height?: number; width?: number }) => {
@ -50,7 +40,7 @@ function MCPAppCard({
handleSizeChanged,
);
if (resource.toolName && resource.serverName && !resource.text) {
if (resource.toolName && resource.serverName) {
return (
<>
{!loaded && (

View file

@ -24,6 +24,7 @@ jest.mock('~/hooks');
jest.mock('~/hooks/Messages/useConversationUIResources');
jest.mock('~/utils/mcpApps', () => ({
buildAppToolResult: jest.fn(),
getMCPSandboxUrl: () => 'http://localhost/sandbox',
callMCPAppTool: jest.fn(),
readMCPResource: jest.fn(),

View file

@ -201,9 +201,10 @@ describe('ToolCall', () => {
<ToolCall {...mockProps} attachments={attachments as any} />,
);
const iframe = container.querySelector('iframe[srcdoc]');
// A server-bound inline resource renders through the sandbox bridge (not bare srcDoc),
// so its App.connect handshake receives tool input/results.
const iframe = container.querySelector('iframe[data-sandbox-url]');
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('srcdoc', '<p>inline resource</p>');
});
});

View file

@ -8,6 +8,7 @@ jest.mock('~/hooks/MCP', () => ({
}));
jest.mock('~/utils/mcpApps', () => ({
buildAppToolResult: jest.fn(),
getMCPSandboxUrl: () => 'http://localhost/sandbox',
callMCPAppTool: jest.fn(),
readMCPResource: jest.fn(),

View file

@ -1,7 +1,7 @@
import React, { useRef, useState, useMemo, useCallback } from 'react';
import { useConversationUIResources } from '~/hooks/Messages/useConversationUIResources';
import { getMCPSandboxUrl, buildAppToolResult } from '~/utils/mcpApps';
import { useOptionalMessagesConversation } from '~/Providers';
import { getMCPSandboxUrl } from '~/utils/mcpApps';
import { useAppBridge } from '~/hooks/MCP';
import { useLocalize } from '~/hooks';
import { logger } from '~/utils';
@ -28,17 +28,10 @@ export function MCPUIResource(props: MCPUIResourceProps) {
const [height, setHeight] = useState<number | undefined>(undefined);
const sandboxUrl = useMemo(() => getMCPSandboxUrl(), []);
const toolResult = useMemo(() => {
const sc = uiResource?.structuredContent as Record<string, unknown> | undefined | null;
const content = (uiResource?.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 } : {}),
...(uiResource?.isError === true ? { isError: true } : {}),
};
}, [uiResource?.structuredContent, uiResource?.content, uiResource?.isError]);
const toolResult = useMemo(
() => (uiResource ? buildAppToolResult(uiResource) : undefined),
[uiResource],
);
const handleSizeChanged = useCallback((params: { height?: number; width?: number }) => {
if (params.height && params.height > 0) {
@ -66,7 +59,7 @@ export function MCPUIResource(props: MCPUIResourceProps) {
}
try {
if (uiResource.toolName && uiResource.serverName && !uiResource.text) {
if (uiResource.toolName && uiResource.serverName) {
return (
<span
className="mx-1 inline-block w-full align-middle"

View file

@ -19,6 +19,7 @@ jest.mock('~/hooks/MCP', () => ({
}));
jest.mock('~/utils/mcpApps', () => ({
buildAppToolResult: jest.fn(),
getMCPSandboxUrl: () => 'http://localhost/sandbox',
callMCPAppTool: jest.fn(),
readMCPResource: jest.fn(),

View file

@ -6,6 +6,7 @@ import {
buildAllowAttribute,
} from '@modelcontextprotocol/ext-apps/app-bridge';
import type { UIResource } from 'librechat-data-provider';
import type { AppToolResult } from '~/utils/mcpApps';
import { callMCPAppTool, fetchMCPResourceHtml, readMCPResource } from '~/utils/mcpApps';
import { useOptionalMessagesOperations } from '~/Providers';
import { logger } from '~/utils';
@ -19,7 +20,7 @@ export function useAppBridge(
iframeRef: React.RefObject<HTMLIFrameElement | null>,
resource: UIResource,
toolArgs: Record<string, unknown> | undefined,
toolResult: { content: []; structuredContent?: Record<string, unknown> } | undefined,
toolResult: AppToolResult | undefined,
onSizeChanged: (params: SizeParams) => void,
) {
const user = useRecoilValue(store.user);
@ -64,7 +65,13 @@ export function useAppBridge(
bridge = new AppBridge(
null,
{ name: 'LibreChat', version: '1.0.0' },
{ openLinks: {}, serverTools: {}, serverResources: {}, logging: {} },
{
openLinks: {},
serverTools: {},
serverResources: {},
logging: {},
message: { text: {} },
},
{
hostContext: {
theme,
@ -114,11 +121,11 @@ export function useAppBridge(
bridge.addEventListener('sandboxready', async () => {
try {
const { html, csp, permissions } = await fetchMCPResourceHtml(
resource.serverName as string,
resource.uri,
user?.id,
);
// Inline mcp-app resources already carry their HTML, so use it directly instead of a
// resources/read round trip; resourceUri-only apps are fetched from the server.
const { html, csp, permissions } = resource.text
? { html: resource.text, csp: resource.csp, permissions: resource.permissions }
: await fetchMCPResourceHtml(resource.serverName as string, resource.uri, user?.id);
const resolvedPermissions = permissions ?? resource.permissions;
if (resolvedPermissions) {
const updatedAllow = buildAllowAttribute(

View file

@ -1,4 +1,40 @@
import { request, apiBaseUrl } from 'librechat-data-provider';
import type { UIResource } from 'librechat-data-provider';
export type AppToolResult = {
content: [];
structuredContent?: Record<string, unknown>;
isError?: boolean;
_meta?: Record<string, unknown>;
};
/**
* 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 [] | 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 configured = (import.meta.env as Record<string, string | undefined>).VITE_MCP_SANDBOX_URL;

View file

@ -70,6 +70,8 @@ export class MCPManager extends UserConnectionManager {
private readonly modelOnlyToolCache = new Map<string, Set<string>>();
private readonly knownToolNamesCache = new Map<string, Set<string>>();
/** createdAt of the connection each cache entry was built from, to detect reconnects. */
private readonly toolCacheConnStamp = new Map<string, number>();
/** Creates and initializes the singleton MCPManager instance */
public static async createInstance(configs: t.MCPServers): Promise<MCPManager> {
@ -354,6 +356,7 @@ Please follow these instructions when using tools from the respective MCP server
this.resourceUriCache.delete(cacheKey);
this.modelOnlyToolCache.delete(cacheKey);
this.knownToolNamesCache.delete(cacheKey);
this.toolCacheConnStamp.delete(cacheKey);
return;
}
if (serverName) {
@ -362,15 +365,29 @@ Please follow these instructions when using tools from the respective MCP server
this.resourceUriCache.delete(key);
this.modelOnlyToolCache.delete(key);
this.knownToolNamesCache.delete(key);
this.toolCacheConnStamp.delete(key);
}
}
} else {
this.resourceUriCache.clear();
this.modelOnlyToolCache.clear();
this.knownToolNamesCache.clear();
this.toolCacheConnStamp.clear();
}
}
/**
* App-level connections can be transparently recreated when a server config changes
* (ConnectionsRepository.get), so cached tool metadata is only valid while it was built
* from the current connection instance.
*/
private isToolCacheFresh(cacheKey: string, connection: MCPConnection): boolean {
return (
this.knownToolNamesCache.has(cacheKey) &&
this.toolCacheConnStamp.get(cacheKey) === connection.createdAt
);
}
protected removeUserConnection(userId: string, serverName: string): void {
this.clearResourceUriCache(serverName, userId);
super.removeUserConnection(userId, serverName);
@ -412,6 +429,7 @@ Please follow these instructions when using tools from the respective MCP server
this.resourceUriCache.set(cacheKey, serverMap);
this.modelOnlyToolCache.set(cacheKey, modelOnly);
this.knownToolNamesCache.set(cacheKey, knownNames);
this.toolCacheConnStamp.set(cacheKey, connection.createdAt);
}
private async getResourceMeta(
@ -430,7 +448,7 @@ Please follow these instructions when using tools from the respective MCP server
return serverMap.get(toolName);
}
const cacheKey = `${serverName}:${userId ?? ''}`;
if (!this.resourceUriCache.has(cacheKey)) {
if (!this.isToolCacheFresh(cacheKey, connection)) {
await this.populateToolCaches(connection, cacheKey);
}
return this.resourceUriCache.get(cacheKey)?.get(toolName);
@ -817,7 +835,7 @@ Please follow these instructions when using tools from the respective MCP server
}
const cacheKey = `${serverName}:${userId ?? ''}`;
if (!this.knownToolNamesCache.has(cacheKey)) {
if (!this.isToolCacheFresh(cacheKey, connection)) {
await this.populateToolCaches(connection, cacheKey);
}
if (!this.knownToolNamesCache.get(cacheKey)?.has(toolName)) {

View file

@ -210,6 +210,7 @@ export function formatToolContent(
structuredContent: result?.structuredContent,
content: result?.content,
isError: result?.isError,
resultMeta: (result as { _meta?: Record<string, unknown> })?._meta,
csp: metadata?.csp,
permissions: metadata?.permissions,
toolArgs: metadata?.toolArgs,
@ -267,6 +268,7 @@ export function formatToolContent(
permissions: metadata.permissions,
toolArgs: metadata.toolArgs,
isError: result?.isError,
resultMeta: (result as { _meta?: Record<string, unknown> })?._meta,
});
currentTextBlock +=
(currentTextBlock ? '\n\n' : '') +

View file

@ -854,6 +854,7 @@ export type UIResource = {
};
toolArgs?: Record<string, unknown>;
isError?: boolean;
resultMeta?: Record<string, unknown>;
[key: string]: unknown;
};