mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
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:
parent
228627750a
commit
251b18b9e9
6 changed files with 147 additions and 16 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue