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

@ -3,6 +3,7 @@ const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getUserMCPAuthMap } = require('@librechat/api');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getAppConfig } = require('~/server/services/Config');
const { resolveConfigServers } = require('~/server/services/MCP');
const {
findPluginAuthsByKeys,
@ -249,10 +250,35 @@ const serveMCPSandbox = async (_req, res) => {
}
};
/**
* Blocks MCP App endpoints when an admin has disabled apps via `mcpSettings.apps: false`.
* Defense-in-depth alongside the connection-level capability gate: even if a server still
* advertises UI tools, the host refuses to proxy resource reads and app tool calls while off.
*/
const requireMCPAppsEnabled = async (req, res, next) => {
try {
const appConfig =
req.config ??
(await getAppConfig({
role: req.user?.role,
userId: req.user?.id,
tenantId: req.user?.tenantId,
}));
if (appConfig?.mcpSettings?.apps === false) {
return res.status(403).json({ error: 'MCP Apps are disabled' });
}
return next();
} catch (error) {
logger.error('[requireMCPAppsEnabled] Error:', error);
return res.status(500).json({ error: 'Failed to resolve MCP Apps configuration' });
}
};
module.exports = {
readMCPResource,
listMCPResources,
listMCPResourceTemplates,
appToolCall,
serveMCPSandbox,
requireMCPAppsEnabled,
};

View file

@ -37,6 +37,7 @@ const {
listMCPResourceTemplates,
appToolCall,
serveMCPSandbox,
requireMCPAppsEnabled,
} = require('~/server/controllers/mcpApps');
const mcpAppToolCallLimiter = require('~/server/middleware/limiters/mcpAppToolCallLimiter');
const {
@ -992,13 +993,25 @@ router.delete(
* Read a UI resource from an MCP server
* @route POST /api/mcp/resources/read
*/
router.post('/resources/read', requireJwtAuth, checkMCPUsePermissions, readMCPResource);
router.post(
'/resources/read',
requireJwtAuth,
checkMCPUsePermissions,
requireMCPAppsEnabled,
readMCPResource,
);
/**
* List resources available on an MCP server
* @route POST /api/mcp/resources/list
*/
router.post('/resources/list', requireJwtAuth, checkMCPUsePermissions, listMCPResources);
router.post(
'/resources/list',
requireJwtAuth,
checkMCPUsePermissions,
requireMCPAppsEnabled,
listMCPResources,
);
/**
* List resource templates available on an MCP server
@ -1008,6 +1021,7 @@ router.post(
'/resources/templates/list',
requireJwtAuth,
checkMCPUsePermissions,
requireMCPAppsEnabled,
listMCPResourceTemplates,
);
@ -1019,6 +1033,7 @@ router.post(
'/app-tool-call',
requireJwtAuth,
checkMCPUsePermissions,
requireMCPAppsEnabled,
mcpAppToolCallLimiter,
appToolCall,
);

View file

@ -31,6 +31,7 @@ async function initializeMCPs() {
appConfig?.mcpSettings?.allowedDomains,
appConfig?.mcpSettings?.allowedAddresses,
resolveMCPAllowlists,
appConfig?.mcpSettings?.apps,
);
} catch (error) {
logger.error('[MCP] Failed to initialize MCPServersRegistry:', error);

View file

@ -83,6 +83,7 @@ describe('initializeMCPs', () => {
['localhost'],
undefined,
expect.any(Function), // per-request allowlist resolver
undefined, // mcpSettings.apps
);
});
@ -100,6 +101,7 @@ describe('initializeMCPs', () => {
allowedDomains,
undefined,
expect.any(Function),
undefined,
);
});
@ -116,6 +118,7 @@ describe('initializeMCPs', () => {
undefined,
undefined,
expect.any(Function),
undefined,
);
});

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) {

View file

@ -115,7 +115,6 @@
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.44",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/ext-apps": "^1.7.4",
"@modelcontextprotocol/sdk": "^1.29.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/instrumentation-express": "^0.56.0",

View file

@ -88,6 +88,7 @@ export class ConnectionsRepository {
useSSRFProtection,
allowedDomains,
allowedAddresses,
enableApps: registry.getAppsEnabled(),
},
this.oauthOpts,
);

View file

@ -54,6 +54,7 @@ export class MCPConnectionFactory {
protected readonly useSSRFProtection: boolean;
protected readonly allowedDomains?: string[] | null;
protected readonly allowedAddresses?: string[] | null;
protected readonly enableApps?: boolean;
protected readonly ephemeralConnection: boolean;
// OAuth-related properties (only set when useOAuth is true)
@ -176,6 +177,7 @@ export class MCPConnectionFactory {
oauthTokens,
useSSRFProtection: this.useSSRFProtection,
allowedAddresses: this.allowedAddresses,
enableApps: this.enableApps,
ephemeralConnection: this.ephemeralConnection,
});
@ -250,6 +252,7 @@ export class MCPConnectionFactory {
oauthTokens: null,
useSSRFProtection: this.useSSRFProtection,
allowedAddresses: this.allowedAddresses,
enableApps: this.enableApps,
ephemeralConnection: this.ephemeralConnection,
});
@ -302,6 +305,7 @@ export class MCPConnectionFactory {
this.useSSRFProtection = basic.useSSRFProtection === true;
this.allowedDomains = basic.allowedDomains;
this.allowedAddresses = basic.allowedAddresses;
this.enableApps = basic.enableApps;
this.ephemeralConnection = basic.ephemeralConnection === true;
this.connectionTimeout = options?.connectionTimeout;
this.tenantContext = tenantStorage?.getStore?.();
@ -400,6 +404,7 @@ export class MCPConnectionFactory {
oauthTokens,
useSSRFProtection: this.useSSRFProtection,
allowedAddresses: this.allowedAddresses,
enableApps: this.enableApps,
ephemeralConnection: this.ephemeralConnection,
});

View file

@ -1,10 +1,6 @@
import pick from 'lodash/pick';
import { logger } from '@librechat/data-schemas';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import {
getToolUiResourceUri,
isToolVisibilityModelOnly,
} from '@modelcontextprotocol/ext-apps/app-bridge';
import {
CallToolResultSchema,
ReadResourceResultSchema,
@ -13,6 +9,10 @@ import {
ErrorCode,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import type {
ListResourcesResult,
ListResourceTemplatesResult,
} from '@modelcontextprotocol/sdk/types.js';
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { TokenMethods, IUser } from '@librechat/data-schemas';
import type { UIResource } from 'librechat-data-provider';
@ -32,6 +32,7 @@ import {
requiresUserScopedConnection,
} from './utils';
import { mcpOptionsContainGraphTokenPlaceholder, preProcessGraphTokens } from '~/utils/graph';
import { getToolUiResourceUri, isToolVisibilityModelOnly } from './apps';
import { MCPServersInitializer } from './registry/MCPServersInitializer';
import { OboTokenResolutionError, resolveOboToken } from '~/mcp/oauth';
import { MCPServerInspector } from './registry/MCPServerInspector';
@ -77,6 +78,19 @@ export class MCPManager extends UserConnectionManager {
* live tools/list_changed notifications (toolListVersion) that createdAt alone would miss.
*/
private readonly toolCacheConnStamp = new Map<string, string>();
/**
* Per-connection snapshot of the resource URIs and URI templates a server advertises, used to
* authorize app-driven `resources/read` so an embedded app can only proxy resources the server
* publicly exposes not arbitrary `file://`/`secret://` URIs it happens to be reachable for.
*/
private readonly advertisedResourceCache = new Map<
string,
{ uris: Set<string>; templates: RegExp[] }
>();
private readonly advertisedResourceConnStamp = new Map<string, string>();
/** Bounds the resources/list + templates/list pagination loops when snapshotting advertised resources. */
private static readonly RESOURCE_LIST_MAX_PAGES = 20;
/** Creates and initializes the singleton MCPManager instance */
public static async createInstance(configs: t.MCPServers): Promise<MCPManager> {
@ -205,6 +219,7 @@ export class MCPManager extends UserConnectionManager {
useSSRFProtection,
allowedDomains,
allowedAddresses,
enableApps: registry.getAppsEnabled(),
};
const finalizeDiscoveryResult = async (
@ -363,6 +378,8 @@ Please follow these instructions when using tools from the respective MCP server
this.modelOnlyToolCache.delete(cacheKey);
this.knownToolNamesCache.delete(cacheKey);
this.toolCacheConnStamp.delete(cacheKey);
this.advertisedResourceCache.delete(cacheKey);
this.advertisedResourceConnStamp.delete(cacheKey);
return;
}
if (serverName) {
@ -372,6 +389,8 @@ Please follow these instructions when using tools from the respective MCP server
this.modelOnlyToolCache.delete(key);
this.knownToolNamesCache.delete(key);
this.toolCacheConnStamp.delete(key);
this.advertisedResourceCache.delete(key);
this.advertisedResourceConnStamp.delete(key);
}
}
} else {
@ -379,6 +398,8 @@ Please follow these instructions when using tools from the respective MCP server
this.modelOnlyToolCache.clear();
this.knownToolNamesCache.clear();
this.toolCacheConnStamp.clear();
this.advertisedResourceCache.clear();
this.advertisedResourceConnStamp.clear();
}
}
@ -635,6 +656,7 @@ Please follow these instructions when using tools from the respective MCP server
useSSRFProtection,
allowedDomains,
allowedAddresses,
enableApps: registry.getAppsEnabled(),
},
{
useOAuth: true,
@ -862,6 +884,8 @@ Please follow these instructions when using tools from the respective MCP server
);
}
await this.assertResourceReadable(connection, `${serverName}:${userId}`, uri, logPrefix);
const result = await connection.client.request(
{
method: 'resources/read',
@ -874,6 +898,131 @@ Please follow these instructions when using tools from the respective MCP server
return result;
}
/**
* Authorizes an app-driven `resources/read`. App UI resources (`ui://`) are always allowed;
* any other URI must be one the server actually advertises (an exact `resources/list` entry or
* a `resources/templates/list` match), so a sandboxed app cannot exfiltrate unrelated resources
* the host connection can otherwise reach. Fails closed when the advertised set is unavailable.
*/
private async assertResourceReadable(
connection: MCPConnection,
cacheKey: string,
uri: string,
logPrefix: string,
): Promise<void> {
if (uri.startsWith('ui://')) {
return;
}
let advertised: { uris: Set<string>; templates: RegExp[] };
try {
advertised = await this.getAdvertisedResources(connection, cacheKey);
} catch (error) {
logger.warn(
`${logPrefix} Could not list advertised resources to authorize read of "${uri}"; denying.`,
error,
);
throw new McpError(
ErrorCode.InvalidRequest,
`${logPrefix} Resource "${uri}" is not permitted.`,
);
}
if (advertised.uris.has(uri) || advertised.templates.some((pattern) => pattern.test(uri))) {
return;
}
throw new McpError(
ErrorCode.InvalidRequest,
`${logPrefix} Resource "${uri}" is not advertised by the server and cannot be read by an app.`,
);
}
/** Snapshots (and caches per connection) the resource URIs and URI templates a server advertises. */
private async getAdvertisedResources(
connection: MCPConnection,
cacheKey: string,
): Promise<{ uris: Set<string>; templates: RegExp[] }> {
const cached = this.advertisedResourceCache.get(cacheKey);
if (cached && this.advertisedResourceConnStamp.get(cacheKey) === this.connStamp(connection)) {
return cached;
}
const uris = new Set<string>();
let cursor: string | undefined;
for (let page = 0; page < MCPManager.RESOURCE_LIST_MAX_PAGES; page++) {
const result: ListResourcesResult = await connection.client.request(
{ method: 'resources/list', params: cursor != null ? { cursor } : {} },
ListResourcesResultSchema,
{ timeout: connection.timeout },
);
for (const resource of result.resources) {
uris.add(resource.uri);
}
if (result.nextCursor == null) {
break;
}
cursor = result.nextCursor;
}
const templates: RegExp[] = [];
try {
cursor = undefined;
for (let page = 0; page < MCPManager.RESOURCE_LIST_MAX_PAGES; page++) {
const result: ListResourceTemplatesResult = await connection.client.request(
{ method: 'resources/templates/list', params: cursor != null ? { cursor } : {} },
ListResourceTemplatesResultSchema,
{ timeout: connection.timeout },
);
for (const template of result.resourceTemplates) {
const pattern = MCPManager.uriTemplateToRegExp(template.uriTemplate);
if (pattern) {
templates.push(pattern);
}
}
if (result.nextCursor == null) {
break;
}
cursor = result.nextCursor;
}
} catch (error) {
logger.debug(
`[MCP][${cacheKey}] resources/templates/list unavailable; skipping templates.`,
error,
);
}
const entry = { uris, templates };
this.advertisedResourceCache.set(cacheKey, entry);
this.advertisedResourceConnStamp.set(cacheKey, this.connStamp(connection));
return entry;
}
/**
* Converts an RFC 6570 resource URI template into an anchored matcher. Simple expansions match a
* single path segment; reserved/operator expansions (`{+x}`, `{#x}`, `{/x}`, ...) may span `/`.
*/
private static uriTemplateToRegExp(template: string): RegExp | null {
try {
let pattern = '';
for (let i = 0; i < template.length; ) {
const char = template[i];
if (char !== '{') {
pattern += char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
i += 1;
continue;
}
const end = template.indexOf('}', i);
if (end === -1) {
pattern += template.slice(i).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
break;
}
pattern += '+#./;?&'.includes(template[i + 1] ?? '') ? '.+' : '[^/]+';
i = end + 1;
}
return new RegExp(`^${pattern}$`);
} catch {
return null;
}
}
/**
* Proxies an MCP App resources/list request to the server. Paired with readResource so the
* advertised serverResources capability is fully backed (resource-browser apps need listing).

View file

@ -472,6 +472,7 @@ export abstract class UserConnectionManager {
useSSRFProtection,
allowedDomains,
allowedAddresses,
enableApps: registry.getAppsEnabled(),
ephemeralConnection,
};

View file

@ -30,6 +30,7 @@ const mockRegistryInstance = {
shouldEnableSSRFProtection: mockShouldEnableSSRFProtection,
getAllowedDomains: mockGetAllowedDomains,
getAllowedAddresses: mockGetAllowedAddresses,
getAppsEnabled: jest.fn().mockReturnValue(true),
resolveAllowlists: jest.fn(async () => ({
allowedDomains: mockGetAllowedDomains(),
allowedAddresses: mockGetAllowedAddresses(),
@ -122,6 +123,7 @@ describe('ConnectionsRepository', () => {
useSSRFProtection: false,
allowedDomains: null,
allowedAddresses: null,
enableApps: true,
dbSourced: false,
},
undefined,
@ -147,6 +149,7 @@ describe('ConnectionsRepository', () => {
useSSRFProtection: false,
allowedDomains: null,
allowedAddresses: null,
enableApps: true,
dbSourced: false,
},
undefined,
@ -189,6 +192,7 @@ describe('ConnectionsRepository', () => {
useSSRFProtection: false,
allowedDomains: null,
allowedAddresses: null,
enableApps: true,
dbSourced: false,
},
undefined,

View file

@ -52,6 +52,7 @@ const mockRegistryInstance = {
shouldEnableSSRFProtection: mockShouldEnableSSRFProtection,
getAllowedDomains: mockGetAllowedDomains,
getAllowedAddresses: mockGetAllowedAddresses,
getAppsEnabled: jest.fn().mockReturnValue(true),
// Mirrors the real per-request resolver by reading the base-allowlist mocks above, so
// existing tests that override getAllowedDomains/shouldEnableSSRFProtection still apply.
resolveAllowlists: jest.fn(async () => ({
@ -1357,6 +1358,104 @@ describe('MCPManager', () => {
});
});
describe('readResource - app resource authorization', () => {
const mockUser: Partial<IUser> = { id: 'user-123' };
const buildConnection = (request: jest.Mock) =>
({
isConnected: jest.fn().mockResolvedValue(true),
setRequestHeaders: jest.fn(),
fetchTools: jest.fn().mockResolvedValue([]),
timeout: 30000,
client: { request },
}) as unknown as MCPConnection;
const advertisingRequest = (readResult: unknown) =>
jest.fn().mockImplementation((req: { method: string }) => {
if (req.method === 'resources/list') {
return Promise.resolve({ resources: [{ uri: 'file://allowed.txt' }] });
}
if (req.method === 'resources/templates/list') {
return Promise.resolve({ resourceTemplates: [{ uriTemplate: 'db://items/{id}' }] });
}
return Promise.resolve(readResult);
});
beforeEach(() => {
(mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({
source: 'yaml',
type: 'sse',
url: 'https://example.com/mcp',
});
});
it('proxies a ui:// resource without consulting the advertised list', async () => {
const request = jest.fn().mockResolvedValue({ contents: [{ uri: 'ui://app/main' }] });
const manager = await MCPManager.createInstance(newMCPServersConfig());
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
await manager.readResource({
userId: 'user-123',
serverName: 'srv',
uri: 'ui://app/main',
user: mockUser as IUser,
});
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
expect(methods).toEqual(['resources/read']);
});
it('proxies a non-ui:// resource the server advertises', async () => {
const request = advertisingRequest({ contents: [{ uri: 'file://allowed.txt' }] });
const manager = await MCPManager.createInstance(newMCPServersConfig());
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
await manager.readResource({
userId: 'user-123',
serverName: 'srv',
uri: 'file://allowed.txt',
user: mockUser as IUser,
});
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
expect(methods).toContain('resources/read');
});
it('allows a resource matching an advertised URI template', async () => {
const request = advertisingRequest({ contents: [] });
const manager = await MCPManager.createInstance(newMCPServersConfig());
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
await manager.readResource({
userId: 'user-123',
serverName: 'srv',
uri: 'db://items/42',
user: mockUser as IUser,
});
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
expect(methods).toContain('resources/read');
});
it('rejects a non-ui:// resource the server does not advertise', async () => {
const request = advertisingRequest({ contents: [] });
const manager = await MCPManager.createInstance(newMCPServersConfig());
jest.spyOn(manager, 'getConnection').mockResolvedValue(buildConnection(request));
await expect(
manager.readResource({
userId: 'user-123',
serverName: 'srv',
uri: 'file:///etc/passwd',
user: mockUser as IUser,
}),
).rejects.toThrow(/not advertised/);
const methods = request.mock.calls.map((c) => (c[0] as { method: string }).method);
expect(methods).not.toContain('resources/read');
});
});
describe('getConnection', () => {
const mockUser: Partial<IUser> = {
id: 'user-123',

View file

@ -93,6 +93,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
getAppsEnabled: jest.fn().mockReturnValue(true),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
@ -170,6 +171,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
getAppsEnabled: jest.fn().mockReturnValue(true),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
@ -256,6 +258,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
getAppsEnabled: jest.fn().mockReturnValue(true),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
@ -361,6 +364,7 @@ describe('MCP OAuth Race Condition Fixes', () => {
shouldEnableSSRFProtection: jest.fn().mockReturnValue(false),
getAllowedDomains: jest.fn().mockReturnValue(null),
getAllowedAddresses: jest.fn().mockReturnValue(null),
getAppsEnabled: jest.fn().mockReturnValue(true),
resolveAllowlists: jest.fn().mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,

View file

@ -0,0 +1,56 @@
/**
* MCP Apps tool-metadata helpers, mirrored from the spec's reference
* implementation in `@modelcontextprotocol/ext-apps`. They are reimplemented
* here so `@librechat/api` (emitted as CommonJS) never statically imports the
* ESM-only ext-apps package; the client bundle keeps importing ext-apps directly.
*/
interface ToolWithMeta {
_meta?: Record<string, unknown> | null;
}
type McpUiToolVisibility = 'model' | 'app';
interface McpUiToolMeta {
resourceUri?: string;
visibility?: McpUiToolVisibility[];
}
/** Deprecated flat metadata key for a tool's UI resource URI. */
export const RESOURCE_URI_META_KEY = 'ui/resourceUri';
/** MIME type identifying HTML content as an MCP App UI resource. */
export const RESOURCE_MIME_TYPE = 'text/html;profile=mcp-app';
/**
* Extract a tool's UI resource URI. Prefers the nested `_meta.ui.resourceUri`
* format and falls back to the deprecated flat `_meta["ui/resourceUri"]`.
* Throws if a URI is present but does not use the `ui://` scheme.
*/
export function getToolUiResourceUri(tool: ToolWithMeta): string | undefined {
const uiMeta = tool._meta?.ui as McpUiToolMeta | undefined;
let uri: unknown = uiMeta?.resourceUri;
if (uri === undefined) {
uri = tool._meta?.[RESOURCE_URI_META_KEY];
}
if (typeof uri === 'string' && uri.startsWith('ui://')) {
return uri;
} else if (uri !== undefined) {
throw new Error(`Invalid UI resource URI: ${JSON.stringify(uri)}`);
}
return undefined;
}
/** True when a tool is exposed to the model only (never callable from an app). */
export function isToolVisibilityModelOnly(tool: ToolWithMeta): boolean {
const visibility = (tool._meta?.ui as McpUiToolMeta | undefined)?.visibility;
return Array.isArray(visibility) && visibility.length === 1 && visibility[0] === 'model';
}
/** True when a tool is exposed to the app only (hidden from the model). */
export function isToolVisibilityAppOnly(tool: ToolWithMeta): boolean {
const visibility = (tool._meta?.ui as McpUiToolMeta | undefined)?.visibility;
return Array.isArray(visibility) && visibility.length === 1 && visibility[0] === 'app';
}

View file

@ -4,7 +4,6 @@ import { logger } from '@librechat/data-schemas';
import { fetch as undiciFetch, Agent, ProxyAgent } from 'undici';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/app-bridge';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {
@ -22,6 +21,7 @@ import type {
Dispatcher,
} from 'undici';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { ClientCapabilities } from '@modelcontextprotocol/sdk/types.js';
import type { MCPOAuthTokens } from './oauth/types';
import type * as t from './types';
import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from '~/auth';
@ -29,6 +29,7 @@ import { runOutsideTracing } from '~/utils/tracing';
import { isAddressAllowed } from '~/auth/domain';
import { sanitizeUrlForLogging } from './utils';
import { withTimeout } from '~/utils/promise';
import { RESOURCE_MIME_TYPE } from './apps';
import { mcpConfig } from './mcpConfig';
type FetchLike = (url: string | URL, init?: RequestInit) => Promise<Response>;
@ -1096,6 +1097,7 @@ interface MCPConnectionParams {
oauthTokens?: MCPOAuthTokens | null;
useSSRFProtection?: boolean;
allowedAddresses?: string[] | null;
enableApps?: boolean;
ephemeralConnection?: boolean;
}
@ -1258,21 +1260,19 @@ export class MCPConnection extends EventEmitter {
if (params.oauthTokens) {
this.oauthTokens = params.oauthTokens;
}
// Advertise MCP Apps support so servers using the ext-apps graceful-degradation path
// (getUiCapability) expose app-enhanced tools. The capability rides on the `extensions`
// field keyed by ext-apps EXTENSION_ID. Suppressed when MCP Apps are disabled by config.
const appsEnabled = params.enableApps !== false;
const capabilities: ClientCapabilities = appsEnabled
? { extensions: { 'io.modelcontextprotocol/ui': { mimeTypes: [RESOURCE_MIME_TYPE] } } }
: {};
this.client = new Client(
{
name: '@librechat/api-client',
version: '1.2.3',
},
{
// Advertise MCP Apps support so servers using the ext-apps graceful-degradation path
// (getUiCapability) expose app-enhanced tools. The capability rides on the `extensions`
// field keyed by ext-apps EXTENSION_ID.
capabilities: {
extensions: {
'io.modelcontextprotocol/ui': { mimeTypes: [RESOURCE_MIME_TYPE] },
},
},
},
{ capabilities },
);
this.setupEventListeners();

View file

@ -28,6 +28,7 @@ export class MCPServerInspector {
private readonly useSSRFProtection: boolean = false,
private readonly allowedDomains?: string[] | null,
private readonly allowedAddresses?: string[] | null,
private readonly enableApps?: boolean,
) {}
/**
@ -45,6 +46,7 @@ export class MCPServerInspector {
connection?: MCPConnection,
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
enableApps?: boolean,
): Promise<t.ParsedServerConfig> {
// Validate domain against allowlist BEFORE attempting connection
const isDomainAllowed = await isMCPDomainAllowed(rawConfig, allowedDomains, allowedAddresses);
@ -62,6 +64,7 @@ export class MCPServerInspector {
useSSRFProtection,
allowedDomains,
allowedAddresses,
enableApps,
);
await inspector.inspectServer();
inspector.config.initDuration = Date.now() - start;
@ -89,6 +92,7 @@ export class MCPServerInspector {
useSSRFProtection: this.useSSRFProtection,
allowedDomains: this.allowedDomains,
allowedAddresses: this.allowedAddresses,
enableApps: this.enableApps,
});
}

View file

@ -124,6 +124,7 @@ export class MCPServersRegistry {
/** YAML-derived base allowlists; used at boot and as the fallback when no resolver is set. */
private readonly allowedDomains?: string[] | null;
private readonly allowedAddresses?: string[] | null;
private readonly appsEnabled?: boolean;
/** Resolves the per-request (tenant-scoped) merged allowlists; falls back to the base above. */
private readonly allowlistResolver?: MCPAllowlistResolver;
private readonly readThroughCache: Keyv<t.ParsedServerConfig>;
@ -148,6 +149,7 @@ export class MCPServersRegistry {
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
allowlistResolver?: MCPAllowlistResolver,
appsEnabled?: boolean,
) {
this.dbConfigsRepo = new ServerConfigsDB(mongoose);
this.cacheConfigsRepo = ServerConfigsCacheFactory.create(APP_CACHE_NAMESPACE, false);
@ -155,6 +157,7 @@ export class MCPServersRegistry {
this.allowedDomains = allowedDomains;
this.allowedAddresses = allowedAddresses;
this.allowlistResolver = allowlistResolver;
this.appsEnabled = appsEnabled;
const ttl = cacheConfig.MCP_REGISTRY_CACHE_TTL;
@ -175,6 +178,7 @@ export class MCPServersRegistry {
allowedDomains?: string[] | null,
allowedAddresses?: string[] | null,
allowlistResolver?: MCPAllowlistResolver,
appsEnabled?: boolean,
): MCPServersRegistry {
if (!mongoose) {
throw new Error(
@ -192,6 +196,7 @@ export class MCPServersRegistry {
allowedDomains,
allowedAddresses,
allowlistResolver,
appsEnabled,
);
return MCPServersRegistry.instance;
}
@ -214,6 +219,11 @@ export class MCPServersRegistry {
return this.allowedAddresses;
}
/** YAML base apps-enabled flag (boot/fallback). MCP Apps are enabled unless explicitly disabled. */
public getAppsEnabled(): boolean {
return this.appsEnabled !== false;
}
/** Returns true when no explicit allowedDomains allowlist is configured, enabling SSRF TOCTOU protection */
public shouldEnableSSRFProtection(): boolean {
return !Array.isArray(this.allowedDomains) || this.allowedDomains.length === 0;
@ -418,6 +428,7 @@ export class MCPServersRegistry {
undefined,
allowedDomains,
allowedAddresses,
this.getAppsEnabled(),
);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
@ -476,6 +487,7 @@ export class MCPServersRegistry {
undefined,
allowedDomains,
allowedAddresses,
this.getAppsEnabled(),
);
} catch (error) {
logger.error(`[MCPServersRegistry] Reinspection failed for server "${serverName}":`, error);
@ -524,6 +536,7 @@ export class MCPServersRegistry {
undefined,
allowedDomains,
allowedAddresses,
this.getAppsEnabled(),
);
} catch (error) {
logger.error(`[MCPServersRegistry] Failed to inspect server "${serverName}":`, error);
@ -672,6 +685,7 @@ export class MCPServersRegistry {
undefined,
allowedDomains,
allowedAddresses,
this.getAppsEnabled(),
),
CONFIG_SERVER_INIT_TIMEOUT_MS,
`${prefix} Server initialization timed out`,

View file

@ -363,7 +363,7 @@ describe('MCPServersInitializer', () => {
await MCPServersInitializer.initialize(testConfigs);
// Verify all configs were processed by inspector
// Signature: inspect(serverName, rawConfig, connection?, allowedDomains?, allowedAddresses?)
// Signature: inspect(serverName, rawConfig, connection?, allowedDomains?, allowedAddresses?, enableApps?)
expect(mockInspect).toHaveBeenCalledTimes(5);
expect(mockInspect).toHaveBeenCalledWith(
'disabled_server',
@ -371,6 +371,7 @@ describe('MCPServersInitializer', () => {
undefined,
undefined,
undefined,
true,
);
expect(mockInspect).toHaveBeenCalledWith(
'oauth_server',
@ -378,6 +379,7 @@ describe('MCPServersInitializer', () => {
undefined,
undefined,
undefined,
true,
);
expect(mockInspect).toHaveBeenCalledWith(
'file_tools_server',
@ -385,6 +387,7 @@ describe('MCPServersInitializer', () => {
undefined,
undefined,
undefined,
true,
);
expect(mockInspect).toHaveBeenCalledWith(
'search_tools_server',
@ -392,6 +395,7 @@ describe('MCPServersInitializer', () => {
undefined,
undefined,
undefined,
true,
);
expect(mockInspect).toHaveBeenCalledWith(
'remote_no_oauth_server',
@ -399,6 +403,7 @@ describe('MCPServersInitializer', () => {
undefined,
undefined,
undefined,
true,
);
});

View file

@ -191,6 +191,7 @@ describe('MCPServersRegistry', () => {
undefined,
undefined,
undefined,
true,
);
});
@ -303,6 +304,7 @@ describe('MCPServersRegistry', () => {
undefined,
['admin-added.com'],
['10.0.0.0/8'],
true,
);
});
@ -633,6 +635,7 @@ describe('MCPServersRegistry', () => {
undefined,
undefined,
undefined,
true,
);
expect(result['config-only-server']).toBeDefined();
expect(result['config-only-server'].iconPath).toBe(

View file

@ -106,6 +106,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => {
undefined,
undefined,
undefined,
true,
);
});
@ -199,6 +200,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => {
undefined,
undefined,
undefined,
true,
);
});

View file

@ -3,6 +3,7 @@ import { Constants } from 'librechat-data-provider';
import type { JsonSchemaType } from '@librechat/agents';
import type { LCAvailableTools, LCFunctionTool, ParsedServerConfig } from './types';
import { requiresEphemeralUserConnection } from './utils';
import { isToolVisibilityAppOnly } from './apps';
export interface MCPToolInput {
name: string;
@ -90,15 +91,7 @@ export function createMCPToolCacheService(deps: MCPToolCacheDeps): MCPToolCacheS
}
for (const tool of tools) {
const uiMeta = (tool._meta as Record<string, unknown>)?.ui as
| Record<string, unknown>
| undefined;
const visibility = uiMeta?.visibility as string[] | undefined;
if (
Array.isArray(visibility) &&
visibility.includes('app') &&
!visibility.includes('model')
) {
if (isToolVisibilityAppOnly(tool)) {
continue;
}
const name = `${tool.name}${mcpDelimiter}${serverName}`;

View file

@ -191,6 +191,8 @@ export interface BasicConnectionOptions {
allowedDomains?: string[] | null;
/** Admin exemption list of host:port pairs that bypass the SSRF private-IP block */
allowedAddresses?: string[] | null;
/** When false, the MCP "UI" (apps) capability is not advertised during handshake. Defaults to enabled. */
enableApps?: boolean;
/** When true, only resolve customUserVars in processMCPEnv (for DB-stored servers) */
dbSourced?: boolean;
/** When true, serverConfig has already gone through processMCPEnv for this request */

View file

@ -1705,6 +1705,7 @@ export const configSchema = z.object({
.object({
allowedDomains: z.array(z.string()).optional(),
allowedAddresses: allowedAddressesSchema,
apps: z.boolean().optional(),
})
.optional(),
interface: interfaceSchema,