fix(mcp): harden MCP Apps host security and CJS compatibility

Reimplement the MCP Apps ui-meta helpers (RESOURCE_MIME_TYPE, getToolUiResourceUri,
isToolVisibilityModelOnly, isToolVisibilityAppOnly) in packages/api/src/mcp/apps.ts so
@librechat/api no longer imports the ESM-only @modelcontextprotocol/ext-apps from its CommonJS
build. ext-apps remains a client-only dependency, removing the require(ESM) boundary that throws
ERR_REQUIRE_ESM on Node versions without synchronous require(esm) support.

Add an mcpSettings.apps toggle (enabled unless explicitly false). Thread enableApps through
connection creation so the io.modelcontextprotocol/ui capability is advertised only when apps are
enabled, and gate the resource and app-tool-call routes with a requireMCPAppsEnabled middleware.

Authorize app-driven resources/read against the resources and templates a server advertises, so a
sandboxed app cannot proxy arbitrary uris. ui:// resources stay allowed and the check fails closed.

Render MCP apps in shared and search transcripts display-only by withholding the host-bound bridge
handlers and capabilities in read-only views, so an embedded app cannot call tools or read
resources with the viewer's auth while the stored tool result still renders.
This commit is contained in:
Dustin Healy 2026-06-28 21:56:28 -07:00
parent 2f650687d6
commit ea75afc99a
25 changed files with 469 additions and 55 deletions

View file

@ -6,6 +6,9 @@ interface MessagesViewContextValue {
conversation: ReturnType<typeof useChatContext>['conversation'];
conversationId: string | null | undefined;
/** True when the view cannot mutate server state (shared/search); MCP App bridges render display-only. */
readOnly: boolean;
/** Submission and control states */
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
@ -92,6 +95,7 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
/** Combine all values into final context value */
const contextValue = useMemo<MessagesViewContextValue>(
() => ({
readOnly: false,
...conversationValues,
...submissionStates,
...messageOperations,
@ -113,6 +117,15 @@ export function useMessagesViewContext() {
return context;
}
/**
* True when MCP App bridges should be display-only: the shared view, the /search route, or any
* mount outside an interactive MessagesViewProvider. Defaults to read-only when no provider is
* present so a new render context never accidentally enables live, auth-bearing app actions.
*/
export function useIsMessagesViewReadOnly(): boolean {
return useContext(MessagesViewContext)?.readOnly ?? true;
}
/** Hook for components that only need conversation data */
export function useMessagesConversation() {
const { conversation, conversationId } = useMessagesViewContext();

View file

@ -21,6 +21,9 @@ export function ShareMessagesProvider({ messages, children }: ShareMessagesProvi
() => ({
conversation: null,
conversationId: undefined,
// Share view is read-only: MCP App bridges must render display-only and never proxy
// auth-bearing tool calls or resource reads against the viewer's MCP servers.
readOnly: true,
// These are required by the context but not used in share view
ask: () => {},
regenerate: () => {},

View file

@ -16,7 +16,7 @@ import {
listMCPResources,
listMCPResourceTemplates,
} from '~/utils/mcpApps';
import { useOptionalMessagesOperations } from '~/Providers';
import { useOptionalMessagesOperations, useIsMessagesViewReadOnly } from '~/Providers';
import { logger } from '~/utils';
import store from '~/store';
@ -35,6 +35,10 @@ export function useAppBridge(
) {
const user = useRecoilValue(store.user);
const { ask } = useOptionalMessagesOperations();
// Shared transcripts and /search render read-only: the embedded app must not proxy tool calls or
// resource reads against the viewer's MCP servers with the viewer's auth. Such views render the
// app display-only (initial tool input/result still shown), with no host-bound action handlers.
const readOnly = useIsMessagesViewReadOnly();
const queryClient = useQueryClient();
const bridgeRef = useRef<AppBridge | null>(null);
// The bridge mounts once per resourceId and reads these only inside its handlers, so a changed
@ -46,7 +50,9 @@ export function useAppBridge(
const onTeardownRef = useRef(onTeardown);
const toolArgsRef = useRef(toolArgs);
const toolResultRef = useRef(toolResult);
const readOnlyRef = useRef(readOnly);
askRef.current = ask;
readOnlyRef.current = readOnly;
onSizeChangedRef.current = onSizeChanged;
onLoadedRef.current = onLoaded;
onTeardownRef.current = onTeardown;
@ -73,16 +79,17 @@ export function useAppBridge(
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions();
// Display-only views advertise no host-bound action capabilities, so a well-behaved app
// disables those affordances instead of issuing calls the host will ignore.
const interactive = !readOnlyRef.current;
bridge = new AppBridge(
null,
{ name: 'LibreChat', version: '1.0.0' },
{
openLinks: {},
serverTools: {},
serverResources: {},
logging: {},
message: { text: {} },
...(interactive ? { serverTools: {}, serverResources: {}, message: { text: {} } } : {}),
},
{
hostContext: {
@ -96,13 +103,6 @@ export function useAppBridge(
},
);
bridge.oncalltool = async (params) =>
callMCPAppTool(
resource.serverName as string,
params.name,
(params.arguments as Record<string, unknown>) ?? {},
) as never;
bridge.onopenlink = async ({ url }) => {
try {
const { protocol } = new URL(url);
@ -117,25 +117,36 @@ export function useAppBridge(
return {};
};
bridge.onreadresource = async (params) =>
readMCPResource(resource.serverName as string, params.uri) as never;
// Host-bound actions (tool calls, resource reads/lists, model messages) run with the viewer's
// auth, so they are only wired in interactive views — never for shared transcripts or /search.
if (interactive) {
bridge.oncalltool = async (params) =>
callMCPAppTool(
resource.serverName as string,
params.name,
(params.arguments as Record<string, unknown>) ?? {},
) as never;
bridge.onlistresources = async (params) =>
listMCPResources(resource.serverName as string, params?.cursor) as never;
bridge.onreadresource = async (params) =>
readMCPResource(resource.serverName as string, params.uri) as never;
bridge.onlistresourcetemplates = async (params) =>
listMCPResourceTemplates(resource.serverName as string, params?.cursor) as never;
bridge.onlistresources = async (params) =>
listMCPResources(resource.serverName as string, params?.cursor) 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.onlistresourcetemplates = async (params) =>
listMCPResourceTemplates(resource.serverName as string, params?.cursor) 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 () => {
if (sandboxReadyHandled) {