diff --git a/api/server/controllers/mcpApps.js b/api/server/controllers/mcpApps.js index 8a05227ea7..027064123e 100644 --- a/api/server/controllers/mcpApps.js +++ b/api/server/controllers/mcpApps.js @@ -1,18 +1,25 @@ const path = require('path'); const { logger } = require('@librechat/data-schemas'); -const { Constants } = require('librechat-data-provider'); +const { CacheKeys, Constants } = require('librechat-data-provider'); const { getUserMCPAuthMap } = require('@librechat/api'); -const { getMCPManager } = require('~/config'); +const { getMCPManager, getFlowStateManager } = require('~/config'); const { resolveConfigServers } = require('~/server/services/MCP'); -const { findPluginAuthsByKeys } = require('~/models'); +const { + findPluginAuthsByKeys, + findToken, + createToken, + updateToken, + deleteTokens, +} = require('~/models'); +const { getLogStores } = require('~/cache'); // 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. + * Resolves the request-scoped config, the user's custom variables, and the OAuth flow/token + * context for a server so app follow-up requests can connect to config-sourced servers and + * re-resolve credentialed or OAuth connections even when the original tool-call connection is gone. */ const resolveAppContext = async (req, serverName) => { const userId = req.user?.id; @@ -25,7 +32,9 @@ const resolveAppContext = async (req, serverName) => { .catch(() => undefined), ]); const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; - return { configServers, customUserVars }; + const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS)); + const tokenMethods = { findToken, createToken, updateToken, deleteTokens }; + return { configServers, customUserVars, flowManager, tokenMethods }; }; /** @route POST /api/mcp/resources/read */ @@ -48,7 +57,10 @@ const readMCPResource = async (req, res) => { } const mcpManager = getMCPManager(); - const { configServers, customUserVars } = await resolveAppContext(req, serverName); + const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext( + req, + serverName, + ); const result = await mcpManager.readResource({ userId, serverName, @@ -56,6 +68,8 @@ const readMCPResource = async (req, res) => { user: req.user, configServers, customUserVars, + flowManager, + tokenMethods, }); return res.json(result); } catch (error) { @@ -64,6 +78,44 @@ const readMCPResource = async (req, res) => { } }; +/** @route POST /api/mcp/resources/list */ +const listMCPResources = async (req, res) => { + try { + const userId = req.user?.id; + if (!userId) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + const { serverName, cursor } = req.body; + if (!serverName) { + return res.status(400).json({ error: 'serverName is required' }); + } + if (cursor !== undefined && typeof cursor !== 'string') { + return res.status(400).json({ error: 'cursor must be a string' }); + } + + const mcpManager = getMCPManager(); + const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext( + req, + serverName, + ); + const result = await mcpManager.listResources({ + userId, + serverName, + user: req.user, + cursor, + configServers, + customUserVars, + flowManager, + tokenMethods, + }); + return res.json(result); + } catch (error) { + logger.error('[listMCPResources] Error:', error); + return res.status(500).json({ error: 'Failed to list resources' }); + } +}; + /** @route POST /api/mcp/app-tool-call */ const appToolCall = async (req, res) => { try { @@ -85,7 +137,10 @@ const appToolCall = async (req, res) => { } const mcpManager = getMCPManager(); - const { configServers, customUserVars } = await resolveAppContext(req, serverName); + const { configServers, customUserVars, flowManager, tokenMethods } = await resolveAppContext( + req, + serverName, + ); const result = await mcpManager.appToolCall({ userId, serverName, @@ -94,6 +149,8 @@ const appToolCall = async (req, res) => { user: req.user, configServers, customUserVars, + flowManager, + tokenMethods, }); return res.json(result); } catch (error) { @@ -153,4 +210,4 @@ const serveMCPSandbox = async (_req, res) => { } }; -module.exports = { readMCPResource, appToolCall, serveMCPSandbox }; +module.exports = { readMCPResource, listMCPResources, appToolCall, serveMCPSandbox }; diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index d4b821388c..3ca4af5c87 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -31,7 +31,12 @@ const { getMCPServerById, getMCPTools, } = require('~/server/controllers/mcp'); -const { readMCPResource, appToolCall, serveMCPSandbox } = require('~/server/controllers/mcpApps'); +const { + readMCPResource, + listMCPResources, + appToolCall, + serveMCPSandbox, +} = require('~/server/controllers/mcpApps'); const mcpAppToolCallLimiter = require('~/server/middleware/limiters/mcpAppToolCallLimiter'); const { getOAuthReconnectionManager, @@ -988,6 +993,12 @@ router.delete( */ router.post('/resources/read', requireJwtAuth, checkMCPUsePermissions, readMCPResource); +/** + * List resources available on an MCP server + * @route POST /api/mcp/resources/list + */ +router.post('/resources/list', requireJwtAuth, checkMCPUsePermissions, listMCPResources); + /** * Proxy tool calls from MCP App iframe to MCP server * @route POST /api/mcp/app-tool-call diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index a5c6b96e6e..d1015217c1 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -76,9 +76,9 @@ const MCPAppView = React.memo(function MCPAppView({ } return ( -
+
{!loaded && !timedOut && ( -
+
)} {timedOut && !loaded && ( -
+
{localize('com_ui_mcp_app_failed_to_load')}
)} @@ -110,7 +110,7 @@ const MCPAppView = React.memo(function MCPAppView({ width: '100%', height: '100%', border: 'none', - display: loaded ? 'block' : 'none', + opacity: loaded ? 1 : 0, }} title={`MCP App: ${app.toolName ?? ''}`} /> diff --git a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx index 637fa54188..b0b5a26f66 100644 --- a/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx +++ b/client/src/components/Chat/Messages/Content/UIResourceCarousel.tsx @@ -44,7 +44,7 @@ function MCPAppCard({ return ( <> {!loaded && ( -
+
{localize('com_ui_loading_interactive_view')}
)} @@ -56,7 +56,7 @@ function MCPAppCard({ width: '100%', height: '100%', border: 'none', - display: loaded ? 'block' : 'none', + opacity: loaded ? 1 : 0, }} title={`MCP App: ${resource.toolName ?? ''}`} /> @@ -181,7 +181,7 @@ const UIResourceCarousel: React.FC = React.memo(({ uiRe animationDelay: `${index * 100}ms`, }} > -
+
handleCardHeightChange(index, h)} diff --git a/client/src/components/MCPUIResource/MCPUIResource.tsx b/client/src/components/MCPUIResource/MCPUIResource.tsx index 5016a0718b..5973c33ba3 100644 --- a/client/src/components/MCPUIResource/MCPUIResource.tsx +++ b/client/src/components/MCPUIResource/MCPUIResource.tsx @@ -62,11 +62,11 @@ export function MCPUIResource(props: MCPUIResourceProps) { if (uiResource.toolName && uiResource.serverName) { return ( {!loaded && ( -
+
{localize('com_ui_loading_interactive_view')}
)} @@ -78,7 +78,7 @@ export function MCPUIResource(props: MCPUIResourceProps) { width: '100%', height: '100%', border: 'none', - display: loaded ? 'block' : 'none', + opacity: loaded ? 1 : 0, }} title={`MCP App: ${uiResource.toolName ?? ''}`} /> diff --git a/client/src/hooks/MCP/useAppBridge.ts b/client/src/hooks/MCP/useAppBridge.ts index bee136a589..0ad90f82a8 100644 --- a/client/src/hooks/MCP/useAppBridge.ts +++ b/client/src/hooks/MCP/useAppBridge.ts @@ -7,7 +7,12 @@ import { } from '@modelcontextprotocol/ext-apps/app-bridge'; import type { UIResource } from 'librechat-data-provider'; import type { AppToolResult } from '~/utils/mcpApps'; -import { callMCPAppTool, fetchMCPResourceHtml, readMCPResource } from '~/utils/mcpApps'; +import { + callMCPAppTool, + fetchMCPResourceHtml, + readMCPResource, + listMCPResources, +} from '~/utils/mcpApps'; import { useOptionalMessagesOperations } from '~/Providers'; import { logger } from '~/utils'; import store from '~/store'; @@ -108,6 +113,9 @@ export function useAppBridge( bridge.onreadresource = async (params) => readMCPResource(resource.serverName as string, params.uri, user?.id) 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') diff --git a/client/src/utils/mcpApps.ts b/client/src/utils/mcpApps.ts index ff120f200f..45773e890e 100644 --- a/client/src/utils/mcpApps.ts +++ b/client/src/utils/mcpApps.ts @@ -102,6 +102,10 @@ export async function readMCPResource( return promise; } +export async function listMCPResources(serverName: string, cursor?: string) { + return request.post(`${apiBaseUrl()}/api/mcp/resources/list`, { serverName, cursor }); +} + type ResourceUiMeta = { csp?: { connectDomains?: string[]; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index ca4d6babdd..0f5d95a2f8 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -8,6 +8,7 @@ import { import { CallToolResultSchema, ReadResourceResultSchema, + ListResourcesResultSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js'; @@ -721,12 +722,16 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }: { serverName: string; userId: string; user?: IUser; configServers?: Record; customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; const rawConfig = await MCPServersRegistry.getInstance().getServerConfig( @@ -762,6 +767,8 @@ Please follow these instructions when using tools from the respective MCP server user, serverConfig: rawConfig ?? undefined, customUserVars, + flowManager, + tokenMethods, }); // Refresh headers when the config can be fully resolved: env-var-only configs always, and @@ -791,6 +798,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }: { userId: string; serverName: string; @@ -798,6 +807,8 @@ Please follow these instructions when using tools from the respective MCP server user?: import('@librechat/data-schemas').IUser; configServers?: Record; customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); @@ -807,6 +818,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }); if (!(await connection.isConnected())) { @@ -828,6 +841,60 @@ Please follow these instructions when using tools from the respective MCP server return result; } + /** + * 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). + */ + async listResources({ + userId, + serverName, + user, + cursor, + configServers, + customUserVars, + flowManager, + tokenMethods, + }: { + userId: string; + serverName: string; + user?: import('@librechat/data-schemas').IUser; + cursor?: string; + configServers?: Record; + customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; + }): Promise { + const logPrefix = `[MCP][User: ${userId}][${serverName}]`; + if (userId && user) this.updateUserLastActivity(userId); + const connection = await this.getAppConnection({ + serverName, + userId, + user, + configServers, + customUserVars, + flowManager, + tokenMethods, + }); + + if (!(await connection.isConnected())) { + throw new McpError( + ErrorCode.InternalError, + `${logPrefix} Connection is not active. Cannot list resources.`, + ); + } + + const result = await connection.client.request( + { + method: 'resources/list', + params: cursor != null ? { cursor } : {}, + }, + ListResourcesResultSchema, + { timeout: connection.timeout }, + ); + + return result; + } + /** * Proxies a tool call from an MCP App iframe to the MCP server. * Unlike callTool, this is a lightweight proxy without provider formatting. @@ -840,6 +907,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }: { userId: string; serverName: string; @@ -848,6 +917,8 @@ Please follow these instructions when using tools from the respective MCP server user?: import('@librechat/data-schemas').IUser; configServers?: Record; customUserVars?: Record; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; }): Promise { const logPrefix = `[MCP][User: ${userId}][${serverName}]`; if (userId && user) this.updateUserLastActivity(userId); @@ -857,6 +928,8 @@ Please follow these instructions when using tools from the respective MCP server user, configServers, customUserVars, + flowManager, + tokenMethods, }); if (!(await connection.isConnected())) { diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 52d5758c49..7fc9f4ffec 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -1276,6 +1276,85 @@ describe('MCPManager', () => { Authorization: 'Bearer secret', }); }); + + it('forwards configServers, flowManager, and tokenMethods to getConnection', async () => { + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + source: 'yaml', + type: 'sse', + url: 'https://example.com/mcp', + }); + + 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()); + const getConnectionSpy = jest + .spyOn(manager, 'getConnection') + .mockResolvedValue(mockConnection); + + const flowManager = {} as never; + const tokenMethods = {} as never; + const configServers = { 'cfg-server': { type: 'sse', url: 'https://x' } } as never; + + await manager.appToolCall({ + userId: 'user-123', + serverName: 'cfg-server', + toolName: 'do_thing', + toolArguments: {}, + user: mockUser as IUser, + configServers, + flowManager, + tokenMethods, + }); + + expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith( + 'cfg-server', + 'user-123', + configServers, + ); + expect(getConnectionSpy).toHaveBeenCalledWith( + expect.objectContaining({ flowManager, tokenMethods }), + ); + }); + + it('proxies resources/list through the app connection', async () => { + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + source: 'yaml', + type: 'sse', + url: 'https://example.com/mcp', + }); + + const request = jest.fn().mockResolvedValue({ resources: [{ uri: 'file://a' }] }); + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + setRequestHeaders: jest.fn(), + fetchTools: jest.fn().mockResolvedValue([]), + timeout: 30000, + client: { request }, + } as unknown as MCPConnection; + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + jest.spyOn(manager, 'getConnection').mockResolvedValue(mockConnection); + + const result = await manager.listResources({ + userId: 'user-123', + serverName: 'srv', + user: mockUser as IUser, + cursor: 'next', + }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'resources/list', params: { cursor: 'next' } }), + expect.anything(), + expect.anything(), + ); + expect(result).toEqual({ resources: [{ uri: 'file://a' }] }); + }); }); describe('getConnection', () => { diff --git a/packages/api/src/mcp/parsers.ts b/packages/api/src/mcp/parsers.ts index 9ac41a1ea0..d33edbf393 100644 --- a/packages/api/src/mcp/parsers.ts +++ b/packages/api/src/mcp/parsers.ts @@ -9,6 +9,24 @@ function generateResourceId(text: string): string { return crypto.createHash('sha256').update(text).digest('hex').substring(0, 10); } +/** + * Derives a UI resource ID that is unique per result snapshot. The frontend indexes conversation + * resources by ID, so two calls that share a base (resourceUri/text) and args but differ in + * structuredContent, text content, _meta, or error state must not collide and overwrite each other. + */ +function deriveResourceId(base: string, result: t.MCPToolCallResponse, toolArgs: unknown): string { + const meta = (result as { _meta?: unknown } | undefined)?._meta; + const parts = [ + base, + result?.structuredContent != null ? JSON.stringify(result.structuredContent) : '', + result?.content != null ? JSON.stringify(result.content) : '', + meta != null ? JSON.stringify(meta) : '', + result?.isError === true ? '1' : '', + toolArgs != null ? JSON.stringify(toolArgs) : '', + ]; + return generateResourceId(parts.join('\x00')); +} + function getMCPImageDataMaxBytes(): number { const raw = process.env.MCP_IMAGE_DATA_MAX_BYTES; if (!raw) { @@ -198,10 +216,7 @@ export function formatToolContent( 'text' in item.resource && item.resource.text && typeof item.resource.text === 'string' ? item.resource.text : item.resource.uri; - const scKey = - 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 resourceId = deriveResourceId(baseHash, result, metadata?.toolArgs); const itemUi = (item.resource._meta as { ui?: Record } | undefined)?.ui as | { csp?: UIResource['csp']; permissions?: UIResource['permissions'] } | undefined; @@ -256,9 +271,7 @@ export function formatToolContent( metadata.serverName && metadata.toolName ) { - const scKey = result?.structuredContent != null ? JSON.stringify(result.structuredContent) : ''; - const argsKey = metadata.toolArgs != null ? JSON.stringify(metadata.toolArgs) : ''; - const resourceId = generateResourceId(metadata.resourceUri + '\x00' + scKey + '\x00' + argsKey); + const resourceId = deriveResourceId(metadata.resourceUri, result, metadata.toolArgs); uiResources.push({ resourceId, uri: metadata.resourceUri,