mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
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:
parent
de28930ddf
commit
228627750a
14 changed files with 100 additions and 55 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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' : '') +
|
||||
|
|
|
|||
|
|
@ -854,6 +854,7 @@ export type UIResource = {
|
|||
};
|
||||
toolArgs?: Record<string, unknown>;
|
||||
isError?: boolean;
|
||||
resultMeta?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue