diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index c0e32ccd8f..e28d1caa77 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -1,5 +1,9 @@ const { logger } = require('@librechat/data-schemas'); -const { getMissingCustomUserVars, requiresEphemeralUserConnection } = require('@librechat/api'); +const { + getMissingCustomUserVars, + requiresEphemeralUserConnection, + getMissingRuntimeBodyPlaceholderFields, +} = require('@librechat/api'); const { CacheKeys, Constants } = require('librechat-data-provider'); const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config'); const { findToken, createToken, updateToken, deleteTokens } = require('~/models'); @@ -121,6 +125,28 @@ async function reinitMCPServer({ }; } + /** `{{LIBRECHAT_BODY_*}}` placeholders only resolve during a chat turn; connecting + * without them would fail, so defer the connection instead of reporting a failure. */ + const missingBodyFields = serverConfig + ? getMissingRuntimeBodyPlaceholderFields(serverConfig, requestBody) + : []; + if (missingBodyFields.length > 0) { + logger.info( + `[MCP Reinitialize] Server '${serverName}' requires request body field(s) [${missingBodyFields.join( + ', ', + )}] for runtime placeholders; connection deferred to first use in a chat turn`, + ); + return { + availableTools: null, + success: true, + message: `MCP server '${serverName}' uses request-scoped placeholders; connection will be established on first use in a chat turn`, + oauthRequired: false, + serverName, + oauthUrl: null, + tools: null, + }; + } + const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS)); const mcpManager = getMCPManager(); const tokenMethods = { findToken, updateToken, createToken, deleteTokens }; diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js index ab8cd3f281..8c2042cf63 100644 --- a/api/server/services/Tools/mcp.spec.js +++ b/api/server/services/Tools/mcp.spec.js @@ -184,3 +184,100 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => { expect(mockGetConnection).toHaveBeenCalledTimes(1); }); }); + +describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)', () => { + const user = { id: 'user-123' }; + const serverName = 'Thingy'; + const serverConfig = { + type: 'streamable-http', + url: 'https://thingy.example.com/mcp', + source: 'yaml', + headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUpdateMCPServerTools.mockResolvedValue({}); + }); + + it('defers connection without failing when body placeholders cannot resolve outside a chat turn', async () => { + const result = await reinitMCPServer({ + user, + serverName, + serverConfig, + userMCPAuthMap: undefined, + }); + + expect(mockGetConnection).not.toHaveBeenCalled(); + expect(mockDiscoverServerTools).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + availableTools: null, + success: true, + tools: null, + oauthRequired: false, + serverName, + }); + expect(result.message).toContain('first use in a chat turn'); + }); + + it('treats an empty-string body field as missing', async () => { + const result = await reinitMCPServer({ + user, + serverName, + serverConfig, + requestBody: { conversationId: ' ' }, + userMCPAuthMap: undefined, + }); + + expect(mockGetConnection).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + + it('connects normally when the request body provides the placeholder fields', async () => { + const disconnect = jest.fn().mockResolvedValue(undefined); + mockGetConnection.mockResolvedValue({ + disconnect, + fetchTools: jest.fn().mockResolvedValue([]), + }); + + await reinitMCPServer({ + user, + serverName, + serverConfig, + requestBody: { conversationId: 'convo-1' }, + userMCPAuthMap: undefined, + }); + + expect(mockGetConnection).toHaveBeenCalledTimes(1); + }); + + it('reports missing customUserVars before deferring on body placeholders', async () => { + const result = await reinitMCPServer({ + user, + serverName, + serverConfig: { + ...serverConfig, + customUserVars: { THINGY_TOKEN: { title: 'Thingy Access Token' } }, + }, + userMCPAuthMap: undefined, + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('THINGY_TOKEN'); + }); + + it('still treats unrelated connection errors as real failures', async () => { + mockGetConnection.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await reinitMCPServer({ + user, + serverName, + serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' }, + userMCPAuthMap: undefined, + }); + + expect(mockDiscoverServerTools).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.message).toBe(`Failed to reinitialize MCP server '${serverName}'`); + }); +});