mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 11:33:44 +00:00
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:
parent
2f650687d6
commit
ea75afc99a
25 changed files with 469 additions and 55 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue