diff --git a/api/server/controllers/mcpApps.js b/api/server/controllers/mcpApps.js index 7568a1b6da..8a05227ea7 100644 --- a/api/server/controllers/mcpApps.js +++ b/api/server/controllers/mcpApps.js @@ -1,10 +1,33 @@ const path = require('path'); const { logger } = require('@librechat/data-schemas'); +const { Constants } = require('librechat-data-provider'); +const { getUserMCPAuthMap } = require('@librechat/api'); const { getMCPManager } = require('~/config'); +const { resolveConfigServers } = require('~/server/services/MCP'); +const { findPluginAuthsByKeys } = require('~/models'); // MCP SDK ErrorCode.InvalidRequest = -32600 const MCP_INVALID_REQUEST = -32600; +/** + * Resolves the request-scoped config and the user's custom variables for a server so app + * follow-up requests can connect to config-sourced servers and re-resolve credentialed headers + * even when the original tool-call connection is gone. + */ +const resolveAppContext = async (req, serverName) => { + const userId = req.user?.id; + const [configServers, userMCPAuthMap] = await Promise.all([ + Promise.resolve() + .then(() => resolveConfigServers(req)) + .catch(() => undefined), + Promise.resolve() + .then(() => getUserMCPAuthMap({ userId, servers: [serverName], findPluginAuthsByKeys })) + .catch(() => undefined), + ]); + const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; + return { configServers, customUserVars }; +}; + /** @route POST /api/mcp/resources/read */ const readMCPResource = async (req, res) => { try { @@ -25,7 +48,15 @@ const readMCPResource = async (req, res) => { } const mcpManager = getMCPManager(); - const result = await mcpManager.readResource({ userId, serverName, uri, user: req.user }); + const { configServers, customUserVars } = await resolveAppContext(req, serverName); + const result = await mcpManager.readResource({ + userId, + serverName, + uri, + user: req.user, + configServers, + customUserVars, + }); return res.json(result); } catch (error) { logger.error('[readMCPResource] Error:', error); @@ -54,12 +85,15 @@ const appToolCall = async (req, res) => { } const mcpManager = getMCPManager(); + const { configServers, customUserVars } = await resolveAppContext(req, serverName); const result = await mcpManager.appToolCall({ userId, serverName, toolName, toolArguments: toolArgs || {}, user: req.user, + configServers, + customUserVars, }); return res.json(result); } catch (error) { diff --git a/client/src/utils/mcpApps.ts b/client/src/utils/mcpApps.ts index c4732fbac6..ff120f200f 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -66,7 +66,21 @@ const CACHE_TTL_MS = 5 * 60 * 1000; type CacheEntry = { promise: Promise; ts: number }; const resourceCache = new Map(); -export async function readMCPResource(serverName: string, uri: string, userId?: string) { +export async function readMCPResource( + serverName: string, + uri: string, + userId?: string, + options?: { cache?: boolean }, +) { + const doRequest = () => + request.post(`${apiBaseUrl()}/api/mcp/resources/read`, { serverName, uri }); + + // App-initiated reads (bridge onreadresource) may target dynamic or mutated resources, so they + // are never cached. Only the immutable app HTML fetch opts into the short-lived cache. + if (!options?.cache) { + return doRequest(); + } + const key = `${userId ?? ''}:${serverName}:${uri}`; const now = Date.now(); @@ -82,7 +96,7 @@ export async function readMCPResource(serverName: string, uri: string, userId?: } } - const promise = request.post(`${apiBaseUrl()}/api/mcp/resources/read`, { serverName, uri }); + const promise = doRequest(); resourceCache.set(key, { promise, ts: now }); promise.catch(() => resourceCache.delete(key)); return promise; @@ -112,7 +126,7 @@ export async function fetchMCPResourceHtml( csp?: ResourceUiMeta['csp']; permissions?: ResourceUiMeta['permissions']; }> { - const result = (await readMCPResource(serverName, uri, userId)) as { + const result = (await readMCPResource(serverName, uri, userId, { cache: true })) as { contents?: Array<{ text?: string; blob?: string; _meta?: { ui?: ResourceUiMeta } }>; }; const item = result?.contents?.[0]; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index ae2772bc57..ca4d6babdd 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -102,6 +102,7 @@ export class MCPManager extends UserConnectionManager { flowManager?: FlowStateManager; /** Pre-resolved config for config-source servers not in YAML/DB */ serverConfig?: t.ParsedServerConfig; + customUserVars?: Record; } & Omit, ): Promise { const userId = args.user?.id; @@ -718,13 +719,21 @@ Please follow these instructions when using tools from the respective MCP server serverName, userId, user, + configServers, + customUserVars, }: { serverName: string; userId: string; user?: IUser; + configServers?: Record; + customUserVars?: Record; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; - const rawConfig = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId); + const rawConfig = await MCPServersRegistry.getInstance().getServerConfig( + serverName, + userId, + configServers, + ); const isDbSourced = rawConfig ? isUserSourced(rawConfig) : false; if (rawConfig) { if (rawConfig.obo) { @@ -752,17 +761,20 @@ Please follow these instructions when using tools from the respective MCP server serverName, user, serverConfig: rawConfig ?? undefined, + customUserVars, }); - // customUserVars are resolved into the connection's headers during the original callTool. - // The app context has no access to them, so re-processing here would overwrite resolved - // auth headers with bare placeholders. Only refresh when the config can be fully resolved - // without them (env-var headers on non-DB servers). - if (rawConfig && !isDbSourced && !hasCustomUserVars(rawConfig)) { + // Refresh headers when the config can be fully resolved: env-var-only configs always, and + // customUserVar configs only when the route supplied those vars. Without them, re-processing + // would overwrite the original connection's resolved auth headers with bare placeholders, so + // those are left to the existing/cold connection that was built with customUserVars. + const hasUserVars = !!customUserVars && Object.keys(customUserVars).length > 0; + if (rawConfig && !isDbSourced && (!hasCustomUserVars(rawConfig) || hasUserVars)) { const currentOptions = processMCPEnv({ user, dbSourced: false, options: rawConfig as t.MCPOptions, + customUserVars, }); const resolvedHeaders: Record = 'headers' in currentOptions ? { ...(currentOptions.headers || {}) } : {}; @@ -777,15 +789,25 @@ Please follow these instructions when using tools from the respective MCP server serverName, uri, user, + configServers, + customUserVars, }: { userId: string; serverName: string; uri: string; user?: import('@librechat/data-schemas').IUser; + configServers?: Record; + customUserVars?: Record; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); - const connection = await this.getAppConnection({ serverName, userId, user }); + const connection = await this.getAppConnection({ + serverName, + userId, + user, + configServers, + customUserVars, + }); if (!(await connection.isConnected())) { throw new McpError( @@ -816,16 +838,26 @@ Please follow these instructions when using tools from the respective MCP server toolName, toolArguments, user, + configServers, + customUserVars, }: { userId: string; serverName: string; toolName: string; toolArguments: Record; user?: import('@librechat/data-schemas').IUser; + configServers?: Record; + customUserVars?: Record; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); - const connection = await this.getAppConnection({ serverName, userId, user }); + const connection = await this.getAppConnection({ + serverName, + userId, + user, + configServers, + customUserVars, + }); if (!(await connection.isConnected())) { throw new McpError( diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 7c1aef90c6..52d5758c49 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -1205,7 +1205,7 @@ describe('MCPManager', () => { ).rejects.toThrow(/request body field/); }); - it('does not overwrite resolved headers for non-DB servers with customUserVars', async () => { + it('preserves resolved headers for customUserVars servers when the route supplies no vars', async () => { (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ source: 'yaml', type: 'sse', @@ -1236,6 +1236,46 @@ describe('MCPManager', () => { expect(mockConnection.setRequestHeaders).not.toHaveBeenCalled(); expect(mockConnection.client.request).toHaveBeenCalled(); }); + + it('resolves headers with customUserVars when the app route supplies them', async () => { + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + source: 'yaml', + type: 'sse', + url: 'https://example.com/mcp', + headers: { Authorization: 'Bearer {{API_KEY}}' }, + customUserVars: { API_KEY: { title: 'API Key' } }, + }); + mockProcessMCPEnv.mockImplementation((params) => ({ + ...params.options, + headers: { + Authorization: `Bearer ${(params.customUserVars as Record)?.API_KEY ?? '{{API_KEY}}'}`, + }, + })); + + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + fetchTools: jest.fn().mockResolvedValue([{ name: 'do_thing', _meta: {} }]), + timeout: 30000, + client: { request: jest.fn().mockResolvedValue({ content: [] }) }, + } as unknown as MCPConnection; + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getConnection').mockResolvedValue(mockConnection); + + await manager.appToolCall({ + userId: 'user-123', + serverName: 'cuv-server', + toolName: 'do_thing', + toolArguments: {}, + user: mockUser as IUser, + customUserVars: { API_KEY: 'secret' }, + }); + + expect(mockConnection.setRequestHeaders).toHaveBeenCalledWith({ + Authorization: 'Bearer secret', + }); + }); }); describe('getConnection', () => { diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index bd9884ef64..ffdc3ddf15 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -4,6 +4,7 @@ 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 { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -1253,7 +1254,14 @@ export class MCPConnection extends EventEmitter { version: '1.2.3', }, { - capabilities: {}, + // 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] }, + }, + }, }, ); diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts index 2d13a2d1b4..9ac41a1ea0 100644 --- a/packages/api/src/mcp/parsers.ts +++ b/packages/api/src/mcp/parsers.ts @@ -202,6 +202,9 @@ export function formatToolContent( result?.structuredContent != null ? JSON.stringify(result.structuredContent) : ''; const argsKey = metadata?.toolArgs != null ? JSON.stringify(metadata.toolArgs) : ''; const resourceId = generateResourceId(baseHash + '\x00' + scKey + '\x00' + argsKey); + const itemUi = (item.resource._meta as { ui?: Record } | undefined)?.ui as + | { csp?: UIResource['csp']; permissions?: UIResource['permissions'] } + | undefined; const uiResource: UIResource = { ...item.resource, resourceId, @@ -211,8 +214,8 @@ export function formatToolContent( content: result?.content, isError: result?.isError, resultMeta: (result as { _meta?: Record })?._meta, - csp: metadata?.csp, - permissions: metadata?.permissions, + csp: itemUi?.csp ?? metadata?.csp, + permissions: itemUi?.permissions ?? metadata?.permissions, toolArgs: metadata?.toolArgs, }; uiResources.push(uiResource);