diff --git a/api/server/controllers/mcpApps.js b/api/server/controllers/mcpApps.js index 96852e28d7..8c68c70039 100644 --- a/api/server/controllers/mcpApps.js +++ b/api/server/controllers/mcpApps.js @@ -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, }; diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index ac1524ee3d..ffb2228ba4 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -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, ); diff --git a/api/server/services/initializeMCPs.js b/api/server/services/initializeMCPs.js index e3b35a6e86..f1c0f95d43 100644 --- a/api/server/services/initializeMCPs.js +++ b/api/server/services/initializeMCPs.js @@ -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); diff --git a/api/server/services/initializeMCPs.spec.js b/api/server/services/initializeMCPs.spec.js index fe0766343c..da5c10c733 100644 --- a/api/server/services/initializeMCPs.spec.js +++ b/api/server/services/initializeMCPs.spec.js @@ -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, ); }); diff --git a/client/src/Providers/MessagesViewContext.tsx b/client/src/Providers/MessagesViewContext.tsx index 1d9172c95b..2c58b4a358 100644 --- a/client/src/Providers/MessagesViewContext.tsx +++ b/client/src/Providers/MessagesViewContext.tsx @@ -6,6 +6,9 @@ interface MessagesViewContextValue { conversation: ReturnType['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['isSubmitting']; abortScroll: ReturnType['abortScroll']; @@ -92,6 +95,7 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } /** Combine all values into final context value */ const contextValue = useMemo( () => ({ + 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(); diff --git a/client/src/components/Share/ShareMessagesProvider.tsx b/client/src/components/Share/ShareMessagesProvider.tsx index cfaeff338c..81ee66effc 100644 --- a/client/src/components/Share/ShareMessagesProvider.tsx +++ b/client/src/components/Share/ShareMessagesProvider.tsx @@ -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: () => {}, diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index a683f5e1fa..572377daf9 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -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(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) ?? {}, - ) 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) ?? {}, + ) 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) { diff --git a/packages/api/package.json b/packages/api/package.json index 0e0945d0f9..0c15b8197d 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/mcp/ConnectionsRepository.ts b/packages/api/src/mcp/ConnectionsRepository.ts index 7dd2695467..5296f4fc82 100644 --- a/packages/api/src/mcp/ConnectionsRepository.ts +++ b/packages/api/src/mcp/ConnectionsRepository.ts @@ -88,6 +88,7 @@ export class ConnectionsRepository { useSSRFProtection, allowedDomains, allowedAddresses, + enableApps: registry.getAppsEnabled(), }, this.oauthOpts, ); diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index a7808feaab..537cadfafc 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -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, }); diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index 8d0921a0be..5641070f9c 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -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(); + /** + * 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; templates: RegExp[] } + >(); + + private readonly advertisedResourceConnStamp = new Map(); + /** 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 { @@ -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 { + if (uri.startsWith('ui://')) { + return; + } + let advertised: { uris: Set; 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; templates: RegExp[] }> { + const cached = this.advertisedResourceCache.get(cacheKey); + if (cached && this.advertisedResourceConnStamp.get(cacheKey) === this.connStamp(connection)) { + return cached; + } + + const uris = new Set(); + 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). diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index c083d79979..785dfb6755 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -472,6 +472,7 @@ export abstract class UserConnectionManager { useSSRFProtection, allowedDomains, allowedAddresses, + enableApps: registry.getAppsEnabled(), ephemeralConnection, }; diff --git a/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts b/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts index 73b88587de..37c5b11237 100644 --- a/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts +++ b/packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts @@ -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, diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 7fc9f4ffec..75d502c611 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -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 = { 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 = { id: 'user-123', diff --git a/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts index fbb682c3cf..661af087cc 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts @@ -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, diff --git a/packages/api/src/mcp/apps.ts b/packages/api/src/mcp/apps.ts new file mode 100644 index 0000000000..a8fd83cd12 --- /dev/null +++ b/packages/api/src/mcp/apps.ts @@ -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 | 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'; +} diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 44544652b8..fc79f412df 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -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; @@ -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(); diff --git a/packages/api/src/mcp/registry/MCPServerInspector.ts b/packages/api/src/mcp/registry/MCPServerInspector.ts index 03f7e49a97..17fdb64a17 100644 --- a/packages/api/src/mcp/registry/MCPServerInspector.ts +++ b/packages/api/src/mcp/registry/MCPServerInspector.ts @@ -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 { // 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, }); } diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index fa0bdb4e13..9f26871354 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -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; @@ -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`, diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts index 8964c15c19..fb50453644 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts @@ -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, ); }); diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts index cf0b18a739..8ce0dedab6 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts @@ -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( diff --git a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts index 933ff7cec2..da068650c3 100644 --- a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts @@ -106,6 +106,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => { undefined, undefined, undefined, + true, ); }); @@ -199,6 +200,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => { undefined, undefined, undefined, + true, ); }); diff --git a/packages/api/src/mcp/tools.ts b/packages/api/src/mcp/tools.ts index 4c926eb39c..c086a0f582 100644 --- a/packages/api/src/mcp/tools.ts +++ b/packages/api/src/mcp/tools.ts @@ -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)?.ui as - | Record - | 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}`; diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 38020ac7ae..00967090ea 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -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 */ diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index eafcc2f97f..91ca4360b8 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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,