fix(mcp): resolve config and credential context for app follow-up requests

Plumbs the request-scoped config and user credentials into the app endpoints so
config-sourced servers resolve and credentialed connections work even after the
original tool-call connection is gone. The readResource and app-tool-call
controllers now resolve configServers and the user's customUserVars and pass
them through to getAppConnection, which forwards configServers to
getServerConfig and customUserVars to both the connection factory and the header
refresh. Header refresh now runs for customUserVar configs when the route
supplied those vars, and is still skipped when they are absent so a live
connection's resolved headers are never clobbered with bare placeholders.

Prefers the resource item's own _meta.ui csp and permissions for embedded ui://
resources, falling back to tool-level metadata, so a resource that declares its
own connect or resource domains is not served the default restrictive policy.

Stops caching app-initiated resource reads. The five-minute cache now applies
only to the immutable app HTML fetch; bridge onreadresource calls bypass it so
dynamic resources are not served stale.

Advertises MCP Apps support during MCP initialize. The client now sends the
ext-apps capability (mimeTypes including text/html;profile=mcp-app) so servers
using the getUiCapability graceful-degradation path expose app-enhanced tools
rather than text-only fallbacks.
This commit is contained in:
Dustin Healy 2026-06-24 08:13:32 -07:00
parent 228627750a
commit 251b18b9e9
6 changed files with 147 additions and 16 deletions

View file

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

View file

@ -66,7 +66,21 @@ const CACHE_TTL_MS = 5 * 60 * 1000;
type CacheEntry = { promise: Promise<unknown>; ts: number };
const resourceCache = new Map<string, CacheEntry>();
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];

View file

@ -102,6 +102,7 @@ export class MCPManager extends UserConnectionManager {
flowManager?: FlowStateManager<MCPOAuthTokens | null>;
/** Pre-resolved config for config-source servers not in YAML/DB */
serverConfig?: t.ParsedServerConfig;
customUserVars?: Record<string, string>;
} & Omit<t.OAuthConnectionOptions, 'useOAuth' | 'user' | 'flowManager'>,
): Promise<MCPConnection> {
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<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
}): Promise<MCPConnection> {
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<string, string> =
'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<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
}): Promise<unknown> {
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<string, unknown>;
user?: import('@librechat/data-schemas').IUser;
configServers?: Record<string, t.ParsedServerConfig>;
customUserVars?: Record<string, string>;
}): Promise<unknown> {
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(

View file

@ -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<string, string>)?.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', () => {

View file

@ -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] },
},
},
},
);

View file

@ -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<string, unknown> } | 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<string, unknown> })?._meta,
csp: metadata?.csp,
permissions: metadata?.permissions,
csp: itemUi?.csp ?? metadata?.csp,
permissions: itemUi?.permissions ?? metadata?.permissions,
toolArgs: metadata?.toolArgs,
};
uiResources.push(uiResource);