From d7d02766eaa82ba810ece2cdded763a7a65acb89 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 16 Aug 2025 20:45:55 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20feat:=20Request=20Place?= =?UTF-8?q?holders=20for=20Custom=20Endpoint=20&=20MCP=20Headers=20(#9095)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add conversation ID support to custom endpoint headers - Add LIBRECHAT_CONVERSATION_ID to customUserVars when provided - Pass conversation ID to header resolution for dynamic headers - Add comprehensive test coverage Enables custom endpoints to access conversation context using {{LIBRECHAT_CONVERSATION_ID}} placeholder. * fix: filter out unresolved placeholders from headers (thanks @MrunmayS) * feat: add support for request body placeholders in custom endpoint headers - Add {{LIBRECHAT_BODY_*}} placeholders for conversationId, parentMessageId, messageId - Update tests to reflect new body placeholder functionality * refactor resolveHeaders * style: minor styling cleanup * fix: type error in unit test * feat: add body to other endpoints * feat: add body for mcp tool calls * chore: remove changes that unnecessarily increase scope after clarification of requirements * refactor: move http.ts to packages/api and have RequestBody intersect with Express request body * refactor: processMCPEnv now uses single object argument pattern * refactor: update processMCPEnv to use 'options' parameter and align types across MCP connection classes * feat: enhance MCP connection handling with dynamic request headers to pass request body fields --------- Co-authored-by: Gopal Sharma Co-authored-by: s10gopal <36487439+s10gopal@users.noreply.github.com> Co-authored-by: Dustin Healy --- api/app/clients/BaseClient.js | 12 +- api/app/clients/OpenAIClient.js | 10 +- api/server/cleanup.js | 11 +- api/server/controllers/agents/client.js | 5 + .../Endpoints/azureAssistants/initialize.js | 8 +- .../services/Endpoints/custom/initialize.js | 6 +- .../Endpoints/custom/initialize.spec.js | 11 +- .../services/Endpoints/openAI/initialize.js | 8 +- api/server/services/MCP.js | 1 + librechat.example.yaml | 2 + .../api/src/endpoints/openai/initialize.ts | 8 +- packages/api/src/mcp/ConnectionsRepository.ts | 6 +- packages/api/src/mcp/MCPConnectionFactory.ts | 31 ++--- packages/api/src/mcp/MCPManager.ts | 16 +++ packages/api/src/mcp/MCPServersRegistry.ts | 2 +- packages/api/src/mcp/UserConnectionManager.ts | 4 + .../__tests__/MCPConnectionFactory.test.ts | 17 +-- .../mcp/__tests__/MCPServersRegistry.test.ts | 4 +- packages/api/src/mcp/__tests__/mcp.spec.ts | 114 +++++++++--------- packages/api/src/mcp/connection.ts | 51 +++++++- packages/api/src/mcp/types/index.ts | 24 +++- packages/api/src/types/http.ts | 9 ++ packages/api/src/types/index.ts | 1 + packages/api/src/utils/env.spec.ts | 51 +++++--- packages/api/src/utils/env.ts | 112 ++++++++++++----- 25 files changed, 353 insertions(+), 171 deletions(-) create mode 100644 packages/api/src/types/http.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 19f7073fe2..c1e3a8a57a 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -37,6 +37,8 @@ class BaseClient { this.conversationId; /** @type {string} */ this.responseMessageId; + /** @type {string} */ + this.parentMessageId; /** @type {TAttachment[]} */ this.attachments; /** The key for the usage object's input tokens @@ -614,15 +616,19 @@ class BaseClient { this.currentMessages.push(userMessage); } + /** + * When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId. + * this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation + */ + const parentMessageId = isEdited ? head : userMessage.messageId; + this.parentMessageId = parentMessageId; let { prompt: payload, tokenCountMap, promptTokens, } = await this.buildMessages( this.currentMessages, - // When the userMessage is pushed to currentMessages, the parentMessage is the userMessageId. - // this only matters when buildMessages is utilizing the parentMessageId, and may vary on implementation - isEdited ? head : userMessage.messageId, + parentMessageId, this.getBuildMessagesOptions(opts), opts, ); diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 700fca6a7b..3553e40dd0 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -653,8 +653,10 @@ class OpenAIClient extends BaseClient { if (headers && typeof headers === 'object' && !Array.isArray(headers)) { configOptions.baseOptions = { headers: resolveHeaders({ - ...headers, - ...configOptions?.baseOptions?.headers, + headers: { + ...headers, + ...configOptions?.baseOptions?.headers, + }, }), }; } @@ -749,7 +751,7 @@ class OpenAIClient extends BaseClient { groupMap, }); - this.options.headers = resolveHeaders(headers); + this.options.headers = resolveHeaders({ headers }); this.options.reverseProxyUrl = baseURL ?? null; this.langchainProxy = extractBaseURL(this.options.reverseProxyUrl); this.apiKey = azureOptions.azureOpenAIApiKey; @@ -1181,7 +1183,7 @@ ${convo} modelGroupMap, groupMap, }); - opts.defaultHeaders = resolveHeaders(headers); + opts.defaultHeaders = resolveHeaders({ headers }); this.langchainProxy = extractBaseURL(baseURL); this.apiKey = azureOptions.azureOpenAIApiKey; diff --git a/api/server/cleanup.js b/api/server/cleanup.js index 84164eb641..5a027e7f4f 100644 --- a/api/server/cleanup.js +++ b/api/server/cleanup.js @@ -1,6 +1,6 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); -// WeakMap to hold temporary data associated with requests +/** WeakMap to hold temporary data associated with requests */ const requestDataMap = new WeakMap(); const FinalizationRegistry = global.FinalizationRegistry || null; @@ -23,7 +23,7 @@ const clientRegistry = FinalizationRegistry } else { logger.debug('[FinalizationRegistry] Cleaning up client'); } - } catch (e) { + } catch { // Ignore errors } }) @@ -55,6 +55,9 @@ function disposeClient(client) { if (client.responseMessageId) { client.responseMessageId = null; } + if (client.parentMessageId) { + client.parentMessageId = null; + } if (client.message_file_map) { client.message_file_map = null; } @@ -334,7 +337,7 @@ function disposeClient(client) { } } client.options = null; - } catch (e) { + } catch { // Ignore errors during disposal } } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 407712264e..14c9c38224 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -768,6 +768,11 @@ class AgentClient extends BaseClient { last_agent_index: this.agentConfigs?.size ?? 0, user_id: this.user ?? this.options.req.user?.id, hide_sequential_outputs: this.options.agent.hide_sequential_outputs, + requestBody: { + messageId: this.responseMessageId, + conversationId: this.conversationId, + parentMessageId: this.parentMessageId, + }, user: this.options.req.user, }, recursionLimit: agentsEConfig?.recursionLimit ?? 25, diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js index 51d52b8ac2..e8aaf89e01 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.js @@ -109,14 +109,14 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie apiKey = azureOptions.azureOpenAIApiKey; opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion }; - opts.defaultHeaders = resolveHeaders( - { + opts.defaultHeaders = resolveHeaders({ + headers: { ...headers, 'api-key': apiKey, 'OpenAI-Beta': `assistants=${version}`, }, - req.user, - ); + user: req.user, + }); opts.model = azureOptions.azureOpenAIApiDeploymentName; if (initAppClient) { diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index d2b7549699..184cb612d8 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -28,7 +28,11 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid const CUSTOM_API_KEY = extractEnvVariable(endpointConfig.apiKey); const CUSTOM_BASE_URL = extractEnvVariable(endpointConfig.baseURL); - let resolvedHeaders = resolveHeaders(endpointConfig.headers, req.user); + let resolvedHeaders = resolveHeaders({ + headers: endpointConfig.headers, + user: req.user, + body: req.body, + }); if (CUSTOM_API_KEY.match(envVarRegex)) { throw new Error(`Missing API Key for ${endpoint}.`); diff --git a/api/server/services/Endpoints/custom/initialize.spec.js b/api/server/services/Endpoints/custom/initialize.spec.js index 7e28995127..373620d42a 100644 --- a/api/server/services/Endpoints/custom/initialize.spec.js +++ b/api/server/services/Endpoints/custom/initialize.spec.js @@ -64,13 +64,14 @@ describe('custom/initializeClient', () => { jest.clearAllMocks(); }); - it('calls resolveHeaders with headers and user', async () => { + it('calls resolveHeaders with headers, user, and body for body placeholder support', async () => { const { resolveHeaders } = require('@librechat/api'); await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }); - expect(resolveHeaders).toHaveBeenCalledWith( - { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, - { id: 'user-123', email: 'test@example.com' }, - ); + expect(resolveHeaders).toHaveBeenCalledWith({ + headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' }, + user: { id: 'user-123', email: 'test@example.com' }, + body: { endpoint: 'test-endpoint' }, // body - supports {{LIBRECHAT_BODY_*}} placeholders + }); }); it('throws if endpoint config is missing', async () => { diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index bfa228f378..30673ecb54 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -81,10 +81,10 @@ const initializeClient = async ({ serverless = _serverless; clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl; - clientOptions.headers = resolveHeaders( - { ...headers, ...(clientOptions.headers ?? {}) }, - req.user, - ); + clientOptions.headers = resolveHeaders({ + headers: { ...headers, ...(clientOptions.headers ?? {}) }, + user: req.user, + }); clientOptions.titleConvo = azureConfig.titleConvo; clientOptions.titleModel = azureConfig.titleModel; diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 586c50c09c..eb9d91e4cc 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -186,6 +186,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) { signal: derivedSignal, }, user: config?.configurable?.user, + requestBody: config?.configurable?.requestBody, customUserVars, flowManager, tokenMethods: { diff --git a/librechat.example.yaml b/librechat.example.yaml index 6e41a102a8..fa19f7c07a 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -290,6 +290,8 @@ endpoints: # recommended environment variables: apiKey: '${OPENROUTER_KEY}' baseURL: 'https://openrouter.ai/api/v1' + headers: + x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' models: default: ['meta-llama/llama-3-70b-instruct'] fetch: true diff --git a/packages/api/src/endpoints/openai/initialize.ts b/packages/api/src/endpoints/openai/initialize.ts index ad44ed4697..babfed0bea 100644 --- a/packages/api/src/endpoints/openai/initialize.ts +++ b/packages/api/src/endpoints/openai/initialize.ts @@ -87,10 +87,10 @@ export const initializeOpenAI = async ({ }); clientOptions.reverseProxyUrl = configBaseURL ?? clientOptions.reverseProxyUrl; - clientOptions.headers = resolveHeaders( - { ...headers, ...(clientOptions.headers ?? {}) }, - req.user, - ); + clientOptions.headers = resolveHeaders({ + headers: { ...headers, ...(clientOptions.headers ?? {}) }, + user: req.user, + }); const groupName = modelGroupMap[modelName || '']?.group; if (groupName && groupMap[groupName]) { diff --git a/packages/api/src/mcp/ConnectionsRepository.ts b/packages/api/src/mcp/ConnectionsRepository.ts index 7bfb95ad2c..851f539e12 100644 --- a/packages/api/src/mcp/ConnectionsRepository.ts +++ b/packages/api/src/mcp/ConnectionsRepository.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import { MCPConnectionFactory, OAuthConnectionOptions } from '~/mcp/MCPConnectionFactory'; +import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory'; import { MCPConnection } from './connection'; import type * as t from './types'; @@ -10,9 +10,9 @@ import type * as t from './types'; export class ConnectionsRepository { protected readonly serverConfigs: Record; protected connections: Map = new Map(); - protected oauthOpts: OAuthConnectionOptions | undefined; + protected oauthOpts: t.OAuthConnectionOptions | undefined; - constructor(serverConfigs: t.MCPServers, oauthOpts?: OAuthConnectionOptions) { + constructor(serverConfigs: t.MCPServers, oauthOpts?: t.OAuthConnectionOptions) { this.serverConfigs = serverConfigs; this.oauthOpts = oauthOpts; } diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index e110ee716d..368b80e617 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -1,7 +1,6 @@ import { logger } from '@librechat/data-schemas'; import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { TokenMethods } from '@librechat/data-schemas'; -import type { TUser } from 'librechat-data-provider'; import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from '~/mcp/oauth'; import type { FlowStateManager } from '~/flow/manager'; import type { FlowMetadata } from '~/flow/types'; @@ -10,23 +9,6 @@ import { MCPTokenStorage, MCPOAuthHandler } from '~/mcp/oauth'; import { MCPConnection } from './connection'; import { processMCPEnv } from '~/utils'; -export interface BasicConnectionOptions { - serverName: string; - serverConfig: t.MCPOptions; -} - -export interface OAuthConnectionOptions { - useOAuth: true; - user: TUser; - customUserVars?: Record; - flowManager: FlowStateManager; - tokenMethods?: TokenMethods; - signal?: AbortSignal; - oauthStart?: (authURL: string) => Promise; - oauthEnd?: () => Promise; - returnOnOAuth?: boolean; -} - /** * Factory for creating MCP connections with optional OAuth authentication. * Handles OAuth flows, token management, and connection retry logic. @@ -49,15 +31,20 @@ export class MCPConnectionFactory { /** Creates a new MCP connection with optional OAuth support */ static async create( - basic: BasicConnectionOptions, - oauth?: OAuthConnectionOptions, + basic: t.BasicConnectionOptions, + oauth?: t.OAuthConnectionOptions, ): Promise { const factory = new this(basic, oauth); return factory.createConnection(); } - protected constructor(basic: BasicConnectionOptions, oauth?: OAuthConnectionOptions) { - this.serverConfig = processMCPEnv(basic.serverConfig, oauth?.user, oauth?.customUserVars); + protected constructor(basic: t.BasicConnectionOptions, oauth?: t.OAuthConnectionOptions) { + this.serverConfig = processMCPEnv({ + options: basic.serverConfig, + user: oauth?.user, + customUserVars: oauth?.customUserVars, + body: oauth?.requestBody, + }); this.serverName = basic.serverName; this.useOAuth = !!oauth?.useOAuth; this.logPrefix = oauth?.user diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index b201df46c9..41b4f1a0fb 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -6,11 +6,13 @@ import type { TokenMethods } from '@librechat/data-schemas'; import type { FlowStateManager } from '~/flow/manager'; import type { TUser } from 'librechat-data-provider'; import type { MCPOAuthTokens } from '~/mcp/oauth'; +import type { RequestBody } from '~/types'; import type * as t from './types'; import { UserConnectionManager } from '~/mcp/UserConnectionManager'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; +import { processMCPEnv } from '~/utils/env'; import { CONSTANTS } from './enum'; /** @@ -179,6 +181,7 @@ Please follow these instructions when using tools from the respective MCP server toolArguments, options, tokenMethods, + requestBody, flowManager, oauthStart, oauthEnd, @@ -190,6 +193,7 @@ Please follow these instructions when using tools from the respective MCP server provider: t.Provider; toolArguments?: Record; options?: RequestOptions; + requestBody?: RequestBody; tokenMethods?: TokenMethods; customUserVars?: Record; flowManager: FlowStateManager; @@ -214,6 +218,7 @@ Please follow these instructions when using tools from the respective MCP server oauthEnd, signal: options?.signal, customUserVars, + requestBody, }); } else { /** App-level connection */ @@ -234,6 +239,17 @@ Please follow these instructions when using tools from the respective MCP server ); } + const rawConfig = this.getRawConfig(serverName) as t.MCPOptions; + const currentOptions = processMCPEnv({ + user, + options: rawConfig, + customUserVars: customUserVars, + body: requestBody, + }); + if ('headers' in currentOptions) { + connection.setRequestHeaders(currentOptions.headers || {}); + } + const result = await connection.client.request( { method: 'tools/call', diff --git a/packages/api/src/mcp/MCPServersRegistry.ts b/packages/api/src/mcp/MCPServersRegistry.ts index c98f3148d9..2e52f65e3a 100644 --- a/packages/api/src/mcp/MCPServersRegistry.ts +++ b/packages/api/src/mcp/MCPServersRegistry.ts @@ -30,7 +30,7 @@ export class MCPServersRegistry { constructor(configs: t.MCPServers) { this.rawConfigs = configs; - this.parsedConfigs = mapValues(configs, (con) => processMCPEnv(con)); + this.parsedConfigs = mapValues(configs, (con) => processMCPEnv({ options: con })); this.connections = new ConnectionsRepository(configs); } diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index 0590c8211f..8e2ab12f3f 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -7,6 +7,7 @@ import type { MCPOAuthTokens } from '~/mcp/oauth'; import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory'; import { MCPServersRegistry } from '~/mcp/MCPServersRegistry'; import { MCPConnection } from './connection'; +import type { RequestBody } from '~/types'; import type * as t from './types'; /** @@ -47,6 +48,7 @@ export abstract class UserConnectionManager { serverName, flowManager, customUserVars, + requestBody, tokenMethods, oauthStart, oauthEnd, @@ -57,6 +59,7 @@ export abstract class UserConnectionManager { serverName: string; flowManager: FlowStateManager; customUserVars?: Record; + requestBody?: RequestBody; tokenMethods?: TokenMethods; oauthStart?: (authURL: string) => Promise; oauthEnd?: () => Promise; @@ -127,6 +130,7 @@ export abstract class UserConnectionManager { oauthStart: oauthStart, oauthEnd: oauthEnd, returnOnOAuth: returnOnOAuth, + requestBody: requestBody, }, ); diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index 9ca447f92c..c739b8ffce 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -1,14 +1,15 @@ import { logger } from '@librechat/data-schemas'; +import type { TokenMethods } from '@librechat/data-schemas'; import type { TUser } from 'librechat-data-provider'; import type { FlowStateManager } from '~/flow/manager'; import type { MCPOAuthTokens } from '~/mcp/oauth'; -import { MCPConnectionFactory } from '../MCPConnectionFactory'; +import type * as t from '~/mcp/types'; +import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory'; +import { MCPConnection } from '~/mcp/connection'; import { MCPOAuthHandler } from '~/mcp/oauth'; -import { MCPConnection } from '../connection'; import { processMCPEnv } from '~/utils'; -import type * as t from '../types'; -jest.mock('../connection'); +jest.mock('~/mcp/connection'); jest.mock('~/mcp/oauth'); jest.mock('~/utils'); jest.mock('@librechat/data-schemas', () => ({ @@ -74,7 +75,7 @@ describe('MCPConnectionFactory', () => { const connection = await MCPConnectionFactory.create(basicOptions); expect(connection).toBe(mockConnectionInstance); - expect(mockProcessMCPEnv).toHaveBeenCalledWith(mockServerConfig, undefined, undefined); + expect(mockProcessMCPEnv).toHaveBeenCalledWith({ options: mockServerConfig }); expect(mockMCPConnection).toHaveBeenCalledWith({ serverName: 'test-server', serverConfig: mockServerConfig, @@ -115,7 +116,7 @@ describe('MCPConnectionFactory', () => { const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions); expect(connection).toBe(mockConnectionInstance); - expect(mockProcessMCPEnv).toHaveBeenCalledWith(mockServerConfig, mockUser, undefined); + expect(mockProcessMCPEnv).toHaveBeenCalledWith({ options: mockServerConfig, user: mockUser }); expect(mockMCPConnection).toHaveBeenCalledWith({ serverName: 'test-server', serverConfig: mockServerConfig, @@ -132,12 +133,12 @@ describe('MCPConnectionFactory', () => { serverConfig: mockServerConfig, }; - const oauthOptions = { + const oauthOptions: t.OAuthConnectionOptions = { useOAuth: true as const, user: mockUser, flowManager: mockFlowManager, tokenMethods: { - findToken: undefined as unknown as () => Promise, + findToken: undefined as unknown as TokenMethods['findToken'], createToken: jest.fn(), updateToken: jest.fn(), deleteTokens: jest.fn(), diff --git a/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts b/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts index 0852e91691..5d58073edf 100644 --- a/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts +++ b/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts @@ -25,8 +25,8 @@ jest.mock('@librechat/data-schemas', () => ({ // Mock processMCPEnv to verify it's called and adds a processed marker jest.mock('~/utils', () => ({ ...jest.requireActual('~/utils'), - processMCPEnv: jest.fn((config) => ({ - ...config, + processMCPEnv: jest.fn(({ options }) => ({ + ...options, _processed: true, // Simple marker to verify processing occurred })), })); diff --git a/packages/api/src/mcp/__tests__/mcp.spec.ts b/packages/api/src/mcp/__tests__/mcp.spec.ts index 5f3b2d9b6d..f33db2f5c1 100644 --- a/packages/api/src/mcp/__tests__/mcp.spec.ts +++ b/packages/api/src/mcp/__tests__/mcp.spec.ts @@ -178,7 +178,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(originalObj); + const result = processMCPEnv({ options: originalObj }); // Verify it's not the same object reference expect(result).not.toBe(originalObj); @@ -192,7 +192,7 @@ describe('Environment Variable Extraction (MCP)', () => { }); it('should process environment variables in env field', () => { - const obj: MCPOptions = { + const options: MCPOptions = { command: 'node', args: ['server.js'], env: { @@ -203,7 +203,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj); + const result = processMCPEnv({ options }); expect('env' in result && result.env).toEqual({ API_KEY: 'test-api-key-value', @@ -215,7 +215,7 @@ describe('Environment Variable Extraction (MCP)', () => { it('should process user ID in headers field', () => { const user = createTestUser({ id: 'test-user-123' }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com', headers: { @@ -225,7 +225,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ Authorization: 'test-api-key-value', @@ -236,22 +236,22 @@ describe('Environment Variable Extraction (MCP)', () => { it('should handle null or undefined input', () => { // @ts-ignore - Testing null/undefined handling - expect(processMCPEnv(null)).toBeNull(); + expect(processMCPEnv({ options: null })).toBeNull(); // @ts-ignore - Testing null/undefined handling - expect(processMCPEnv(undefined)).toBeUndefined(); + expect(processMCPEnv({ options: undefined })).toBeUndefined(); }); it('should not modify objects without env or headers', () => { - const obj: MCPOptions = { + const options: MCPOptions = { command: 'node', args: ['server.js'], timeout: 5000, }; - const result = processMCPEnv(obj); + const result = processMCPEnv({ options }); - expect(result).toEqual(obj); - expect(result).not.toBe(obj); // Still a different object (deep clone) + expect(result).toEqual(options); + expect(result).not.toBe(options); // Still a different object (deep clone) }); it('should ensure different users with same starting config get separate values', () => { @@ -269,8 +269,8 @@ describe('Environment Variable Extraction (MCP)', () => { const user1 = createTestUser({ id: 'user-123' }); const user2 = createTestUser({ id: 'user-456' }); - const resultUser1 = processMCPEnv(baseConfig, user1); - const resultUser2 = processMCPEnv(baseConfig, user2); + const resultUser1 = processMCPEnv({ options: baseConfig, user: user1 }); + const resultUser2 = processMCPEnv({ options: baseConfig, user: user2 }); // Verify each has the correct user ID expect('headers' in resultUser1 && resultUser1.headers?.['User-Id']).toBe('user-123'); @@ -293,7 +293,7 @@ describe('Environment Variable Extraction (MCP)', () => { it('should process headers in streamable-http options', () => { const user = createTestUser({ id: 'test-user-123' }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'streamable-http', url: 'https://example.com', headers: { @@ -303,7 +303,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ Authorization: 'test-api-key-value', @@ -313,12 +313,12 @@ describe('Environment Variable Extraction (MCP)', () => { }); it('should maintain streamable-http type in processed options', () => { - const obj: MCPOptions = { + const options: MCPOptions = { type: 'streamable-http', url: 'https://example.com/api', }; - const result = processMCPEnv(obj); + const result = processMCPEnv({ options }); expect(result.type).toBe('streamable-http'); }); @@ -329,7 +329,7 @@ describe('Environment Variable Extraction (MCP)', () => { url: 'https://example.com/api', }; - const result = processMCPEnv(obj as unknown as MCPOptions); + const result = processMCPEnv({ options: obj as unknown as MCPOptions }); expect(result.type).toBe('http'); }); @@ -346,7 +346,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj as unknown as MCPOptions, user); + const result = processMCPEnv({ options: obj as unknown as MCPOptions, user }); expect('headers' in result && result.headers).toEqual({ Authorization: 'test-api-key-value', @@ -365,7 +365,7 @@ describe('Environment Variable Extraction (MCP)', () => { emailVerified: true, role: 'admin', }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com', headers: { @@ -379,7 +379,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ 'User-Email': 'test@example.com', @@ -398,7 +398,7 @@ describe('Environment Variable Extraction (MCP)', () => { email: 'test@example.com', username: undefined, // explicitly set to undefined to test missing field }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com', headers: { @@ -408,7 +408,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ 'User-Email': 'test@example.com', @@ -423,7 +423,7 @@ describe('Environment Variable Extraction (MCP)', () => { email: 'test@example.com', ldapId: 'ldap-user-123', }); - const obj: MCPOptions = { + const options: MCPOptions = { command: 'node', args: ['server.js'], env: { @@ -433,7 +433,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('env' in result && result.env).toEqual({ USER_EMAIL: 'test@example.com', @@ -447,12 +447,12 @@ describe('Environment Variable Extraction (MCP)', () => { id: 'user-123', username: 'testuser', }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com/api/{{LIBRECHAT_USER_USERNAME}}/stream', }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('url' in result && result.url).toBe('https://example.com/api/testuser/stream'); }); @@ -464,7 +464,7 @@ describe('Environment Variable Extraction (MCP)', () => { twoFactorEnabled: false, termsAccepted: true, }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com', headers: { @@ -474,7 +474,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ 'Email-Verified': 'true', @@ -489,7 +489,7 @@ describe('Environment Variable Extraction (MCP)', () => { email: 'test@example.com', password: 'secret-password', }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com', headers: { @@ -498,7 +498,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ 'User-Email': 'test@example.com', @@ -511,7 +511,7 @@ describe('Environment Variable Extraction (MCP)', () => { id: 'user-123', email: 'test@example.com', }); - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com', headers: { @@ -521,7 +521,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ 'Primary-Email': 'test@example.com', @@ -544,7 +544,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result1 = processMCPEnv(obj1, userWithId); + const result1 = processMCPEnv({ options: obj1, user: userWithId }); expect('headers' in result1 && result1.headers?.['User-Id']).toBe('user-123'); // Test with '_id' property only (should not work since we only check 'id') @@ -561,7 +561,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result2 = processMCPEnv(obj2, userWithUnderscore); + const result2 = processMCPEnv({ options: obj2, user: userWithUnderscore }); // Since we don't check _id, the placeholder should remain unchanged expect('headers' in result2 && result2.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}'); @@ -579,7 +579,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result3 = processMCPEnv(obj3, userWithBoth); + const result3 = processMCPEnv({ options: obj3, user: userWithBoth }); expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789'); }); @@ -589,7 +589,7 @@ describe('Environment Variable Extraction (MCP)', () => { CUSTOM_VAR_1: 'custom-value-1', CUSTOM_VAR_2: 'custom-value-2', }; - const obj: MCPOptions = { + const options: MCPOptions = { command: 'node', args: ['server.js'], env: { @@ -600,7 +600,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('env' in result && result.env).toEqual({ VAR_A: 'custom-value-1', @@ -616,7 +616,7 @@ describe('Environment Variable Extraction (MCP)', () => { USER_TOKEN: 'user-specific-token', REGION: 'us-west-1', }; - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com/api', headers: { @@ -627,7 +627,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('headers' in result && result.headers).toEqual({ Authorization: 'Bearer user-specific-token', @@ -643,12 +643,12 @@ describe('Environment Variable Extraction (MCP)', () => { API_VERSION: 'v2', TENANT_ID: 'tenant123', }; - const obj: MCPOptions = { + const options: MCPOptions = { type: 'websocket', url: 'wss://example.com/{{TENANT_ID}}/api/{{API_VERSION}}?user={{LIBRECHAT_USER_ID}}&key=${TEST_API_KEY}', }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('url' in result && result.url).toBe( 'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value', @@ -664,7 +664,7 @@ describe('Environment Variable Extraction (MCP)', () => { MY_API_KEY: 'user-provided-api-key-12345', PROFILE_NAME: 'production-profile', }; - const obj: MCPOptions = { + const options: MCPOptions = { command: 'npx', args: [ '-y', @@ -680,7 +680,7 @@ describe('Environment Variable Extraction (MCP)', () => { ], }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('args' in result && result.args).toEqual([ '-y', @@ -704,7 +704,7 @@ describe('Environment Variable Extraction (MCP)', () => { const customUserVars = { LIBRECHAT_USER_EMAIL: 'custom-email-wins', }; - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com/api', headers: { @@ -712,7 +712,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins'); // Clean up env var @@ -724,7 +724,7 @@ describe('Environment Variable Extraction (MCP)', () => { const customUserVars = { UNUSED_VAR: 'unused-value', }; - const obj: MCPOptions = { + const options: MCPOptions = { command: 'node', args: ['server.js'], env: { @@ -732,7 +732,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('env' in result && result.env).toEqual({ API_KEY: 'test-api-key-value', }); @@ -742,7 +742,7 @@ describe('Environment Variable Extraction (MCP)', () => { const user = createTestUser({ email: 'user-provided-email@example.com' }); // No customUserVars provided or customUserVars is empty const customUserVars = {}; - const obj: MCPOptions = { + const options: MCPOptions = { type: 'sse', url: 'https://example.com/api', headers: { @@ -752,7 +752,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('headers' in result && result.headers).toEqual({ 'User-Email-Header': 'user-provided-email@example.com', 'System-Key-Header': 'test-api-key-value', @@ -792,7 +792,11 @@ describe('Environment Variable Extraction (MCP)', () => { // Cast obj to MCPOptions when calling processMCPEnv. // This acknowledges the object might not strictly conform to one schema in the union, // but we are testing the function's ability to handle these properties if present. - const result = processMCPEnv(obj as MCPOptions, user, allCustomVarsForCall); + const result = processMCPEnv({ + options: obj as MCPOptions, + user, + customUserVars: allCustomVarsForCall, + }); expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe'); expect('headers' in result && result.headers).toEqual({ @@ -814,7 +818,7 @@ describe('Environment Variable Extraction (MCP)', () => { }; // Simulate the GitHub MCP server configuration from librechat.yaml - const obj: MCPOptions = { + const options: MCPOptions = { type: 'streamable-http', url: 'https://api.githubcopilot.com/mcp/', headers: { @@ -824,7 +828,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user, customUserVars); + const result = processMCPEnv({ options, user, customUserVars }); expect('headers' in result && result.headers).toEqual({ Authorization: 'ghp_1234567890abcdef1234567890abcdef12345678', @@ -838,7 +842,7 @@ describe('Environment Variable Extraction (MCP)', () => { it('should handle GitHub MCP server configuration without PAT_TOKEN (placeholder remains)', () => { const user = createTestUser({ id: 'github-user-123' }); // No customUserVars provided - PAT_TOKEN should remain as placeholder - const obj: MCPOptions = { + const options: MCPOptions = { type: 'streamable-http', url: 'https://api.githubcopilot.com/mcp/', headers: { @@ -847,7 +851,7 @@ describe('Environment Variable Extraction (MCP)', () => { }, }; - const result = processMCPEnv(obj, user); + const result = processMCPEnv({ options, user }); expect('headers' in result && result.headers).toEqual({ Authorization: '{{PAT_TOKEN}}', // Should remain unchanged since no customUserVars provided diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 7099205e6b..5f45bd8335 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -81,11 +81,21 @@ export class MCPConnection extends EventEmitter { private lastPingTime: number; private lastConnectionCheckAt: number = 0; private oauthTokens?: MCPOAuthTokens | null; + private requestHeaders?: Record | null; private oauthRequired = false; iconPath?: string; timeout?: number; url?: string; + setRequestHeaders(headers: Record | null): void { + logger.debug(`${this.getLogPrefix()} Setting request headers: ${JSON.stringify(headers)}`); + this.requestHeaders = headers; + } + + getRequestHeaders(): Record | null | undefined { + return this.requestHeaders; + } + constructor(params: MCPConnectionParams) { super(); this.options = params.serverConfig; @@ -116,6 +126,43 @@ export class MCPConnection extends EventEmitter { return `[MCP]${userPart}[${this.serverName}]`; } + /** + * Factory function to create fetch functions without capturing the entire `this` context. + * This helps prevent memory leaks by only passing necessary dependencies. + * + * @param getHeaders Function to retrieve request headers + * @returns A fetch function that merges headers appropriately + */ + private createFetchFunction( + getHeaders: () => Record | null | undefined, + ): (input: RequestInfo | URL, init?: RequestInit) => Promise { + return function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const requestHeaders = getHeaders(); + if (!requestHeaders) { + return fetch(input, init); + } + + let initHeaders: Record = {}; + if (init?.headers) { + if (init.headers instanceof Headers) { + initHeaders = Object.fromEntries(init.headers.entries()); + } else if (Array.isArray(init.headers)) { + initHeaders = Object.fromEntries(init.headers); + } else { + initHeaders = init.headers as Record; + } + } + + return fetch(input, { + ...init, + headers: { + ...initHeaders, + ...requestHeaders, + }, + }); + }; + } + private emitError(error: unknown, errorContext: string): void { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`${this.getLogPrefix()} ${errorContext}: ${errorMessage}`); @@ -188,6 +235,7 @@ export class MCPConnection extends EventEmitter { }); }, }, + fetch: this.createFetchFunction(this.getRequestHeaders.bind(this)), }); transport.onclose = () => { @@ -214,7 +262,7 @@ export class MCPConnection extends EventEmitter { ); const abortController = new AbortController(); - // Add OAuth token to headers if available + /** Add OAuth token to headers if available */ const headers = { ...options.headers }; if (this.oauthTokens?.access_token) { headers['Authorization'] = `Bearer ${this.oauthTokens.access_token}`; @@ -225,6 +273,7 @@ export class MCPConnection extends EventEmitter { headers, signal: abortController.signal, }, + fetch: this.createFetchFunction(this.getRequestHeaders.bind(this)), }); transport.onclose = () => { diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 504f9cb5ec..cdf51d4ef6 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -7,9 +7,13 @@ import { WebSocketOptionsSchema, StreamableHTTPOptionsSchema, } from 'librechat-data-provider'; +import type { TPlugin, TUser } from 'librechat-data-provider'; import type * as t from '@modelcontextprotocol/sdk/types.js'; -import type { TPlugin } from 'librechat-data-provider'; +import type { TokenMethods } from '@librechat/data-schemas'; +import type { FlowStateManager } from '~/flow/manager'; import type { JsonSchemaType } from '~/types/zod'; +import type { RequestBody } from '~/types/http'; +import type * as o from '~/mcp/oauth/types'; export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; @@ -113,3 +117,21 @@ export type ParsedServerConfig = MCPOptions & { capabilities?: string; tools?: string; }; + +export interface BasicConnectionOptions { + serverName: string; + serverConfig: MCPOptions; +} + +export interface OAuthConnectionOptions { + user: TUser; + useOAuth: true; + requestBody?: RequestBody; + customUserVars?: Record; + flowManager: FlowStateManager; + tokenMethods?: TokenMethods; + signal?: AbortSignal; + oauthStart?: (authURL: string) => Promise; + oauthEnd?: () => Promise; + returnOnOAuth?: boolean; +} diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts new file mode 100644 index 0000000000..972a48e44d --- /dev/null +++ b/packages/api/src/types/http.ts @@ -0,0 +1,9 @@ +/** + * LibreChat-specific request body type that extends Express Request body + * (have to use type alias because you can't extend indexed access types like Request['body']) + */ +export type RequestBody = { + messageId?: string; + conversationId?: string; + parentMessageId?: string; +}; diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index e0a3d3cb3c..b38f16e703 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -3,6 +3,7 @@ export * from './balance'; export * from './events'; export * from './error'; export * from './google'; +export * from './http'; export * from './mistral'; export * from './openai'; export * from './prompts'; diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index 4cb8da0d6b..8905e8d230 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -36,12 +36,14 @@ describe('resolveHeaders', () => { }); it('should return empty object when headers is null', () => { - const result = resolveHeaders(null as unknown as Record | undefined); + const result = resolveHeaders({ + headers: null as unknown as Record, + }); expect(result).toEqual({}); }); it('should return empty object when headers is empty', () => { - const result = resolveHeaders({}); + const result = resolveHeaders({ headers: {} }); expect(result).toEqual({}); }); @@ -52,7 +54,7 @@ describe('resolveHeaders', () => { 'Content-Type': 'application/json', }; - const result = resolveHeaders(headers); + const result = resolveHeaders({ headers }); expect(result).toEqual({ Authorization: 'test-api-key-value', @@ -68,7 +70,7 @@ describe('resolveHeaders', () => { 'Content-Type': 'application/json', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result).toEqual({ 'User-Id': 'test-user-123', @@ -82,7 +84,7 @@ describe('resolveHeaders', () => { 'Content-Type': 'application/json', }; - const result = resolveHeaders(headers); + const result = resolveHeaders({ headers }); expect(result).toEqual({ 'User-Id': '{{LIBRECHAT_USER_ID}}', @@ -97,7 +99,7 @@ describe('resolveHeaders', () => { 'Content-Type': 'application/json', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result).toEqual({ 'User-Id': '{{LIBRECHAT_USER_ID}}', @@ -123,7 +125,7 @@ describe('resolveHeaders', () => { 'Content-Type': 'application/json', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result).toEqual({ 'User-Email': 'test@example.com', @@ -148,7 +150,7 @@ describe('resolveHeaders', () => { 'Non-Existent': '{{LIBRECHAT_USER_NONEXISTENT}}', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result).toEqual({ 'User-Email': 'test@example.com', @@ -171,7 +173,7 @@ describe('resolveHeaders', () => { 'X-User-Id': '{{LIBRECHAT_USER_ID}}', }; - const result = resolveHeaders(headers, user, customUserVars); + const result = resolveHeaders({ headers, user, customUserVars }); expect(result).toEqual({ Authorization: 'Bearer user-specific-token', @@ -194,7 +196,7 @@ describe('resolveHeaders', () => { 'Test-Email': '{{LIBRECHAT_USER_EMAIL}}', }; - const result = resolveHeaders(headers, user, customUserVars); + const result = resolveHeaders({ headers, user, customUserVars }); expect(result).toEqual({ 'Test-Email': 'custom-email@example.com', @@ -213,7 +215,7 @@ describe('resolveHeaders', () => { 'User-Id': '{{LIBRECHAT_USER_ID}}', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result).toEqual({ 'User-Role': 'admin', @@ -233,7 +235,7 @@ describe('resolveHeaders', () => { 'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result).toEqual({ 'Primary-Email': 'test@example.com', @@ -259,7 +261,7 @@ describe('resolveHeaders', () => { 'Content-Type': 'application/json', }; - const result = resolveHeaders(headers, user, customUserVars); + const result = resolveHeaders({ headers, user, customUserVars }); expect(result).toEqual({ Authorization: 'Bearer secret-token', @@ -277,7 +279,7 @@ describe('resolveHeaders', () => { }; const user = { id: 'user-123' }; - const result = resolveHeaders(originalHeaders, user); + const result = resolveHeaders({ headers: originalHeaders, user }); // Verify the result is processed expect(result).toEqual({ @@ -306,7 +308,7 @@ describe('resolveHeaders', () => { 'Dot-Header': '{{CUSTOM.VAR}}', }; - const result = resolveHeaders(headers, user, customUserVars); + const result = resolveHeaders({ headers, user, customUserVars }); expect(result).toEqual({ 'Dash-Header': 'dash-value', @@ -357,7 +359,7 @@ describe('resolveHeaders', () => { 'X-User-TermsAccepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result['X-User-ID']).toBe('abc'); expect(result['X-User-Name']).toBe('Test User'); @@ -384,7 +386,7 @@ describe('resolveHeaders', () => { 'X-Multi': 'User: {{LIBRECHAT_USER_ID}}, Env: ${TEST_API_KEY}, Custom: {{MY_CUSTOM}}', }; const customVars = { MY_CUSTOM: 'custom-value' }; - const result = resolveHeaders(headers, user, customVars); + const result = resolveHeaders({ headers, user, customUserVars: customVars }); expect(result['X-Multi']).toBe('User: abc, Env: test-api-key-value, Custom: custom-value'); }); @@ -394,7 +396,7 @@ describe('resolveHeaders', () => { 'X-Unknown': '{{SOMETHING_NOT_RECOGNIZED}}', 'X-Known': '{{LIBRECHAT_USER_ID}}', }; - const result = resolveHeaders(headers, user); + const result = resolveHeaders({ headers, user }); expect(result['X-Unknown']).toBe('{{SOMETHING_NOT_RECOGNIZED}}'); expect(result['X-Known']).toBe('abc'); }); @@ -416,7 +418,7 @@ describe('resolveHeaders', () => { 'X-Boolean': '{{LIBRECHAT_USER_EMAILVERIFIED}}', }; const customVars = { MY_CUSTOM: 'custom-value' }; - const result = resolveHeaders(headers, user, customVars); + const result = resolveHeaders({ headers, user, customUserVars: customVars }); expect(result['X-User']).toBe('abc'); expect(result['X-Env']).toBe('test-api-key-value'); @@ -426,4 +428,15 @@ describe('resolveHeaders', () => { expect(result['X-Empty']).toBe(''); expect(result['X-Boolean']).toBe('true'); }); + + it('should process LIBRECHAT_BODY placeholders', () => { + const body = { + conversationId: 'conv-123', + parentMessageId: 'parent-456', + messageId: 'msg-789', + }; + const headers = { 'X-Conversation': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }; + const result = resolveHeaders({ headers, body }); + expect(result['X-Conversation']).toBe('conv-123'); + }); }); diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 54169d9b93..d9f29fddcb 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -1,5 +1,6 @@ import { extractEnvVariable } from 'librechat-data-provider'; import type { TUser, MCPOptions } from 'librechat-data-provider'; +import type { RequestBody } from '~/types'; /** * List of allowed user fields that can be used in MCP environment variables. @@ -25,6 +26,12 @@ const ALLOWED_USER_FIELDS = [ 'termsAccepted', ] as const; +/** + * List of allowed request body fields that can be used in header placeholders. + * These are common fields from the request body that are safe to expose in headers. + */ +const ALLOWED_BODY_FIELDS = ['conversationId', 'parentMessageId', 'messageId'] as const; + /** * Processes a string value to replace user field placeholders * @param value - The string value to process @@ -61,21 +68,48 @@ function processUserPlaceholders(value: string, user?: TUser): string { return value; } +/** + * Replaces request body field placeholders within a string. + * Recognized placeholders: `{{LIBRECHAT_BODY_}}` where `` ∈ ALLOWED_BODY_FIELDS. + * If a body field is absent or null/undefined, it is replaced with an empty string. + * + * @param value - The string value to process + * @param body - The request body object + * @returns The processed string with placeholders replaced + */ +function processBodyPlaceholders(value: string, body: RequestBody): string { + for (const field of ALLOWED_BODY_FIELDS) { + const placeholder = `{{LIBRECHAT_BODY_${field.toUpperCase()}}}`; + if (!value.includes(placeholder)) { + continue; + } + + const fieldValue = body[field]; + const replacementValue = fieldValue == null ? '' : String(fieldValue); + value = value.replace(new RegExp(placeholder, 'g'), replacementValue); + } + + return value; +} + /** * Processes a single string value by replacing various types of placeholders * @param originalValue - The original string value to process * @param customUserVars - Optional custom user variables to replace placeholders * @param user - Optional user object for replacing user field placeholders + * @param body - Optional request body object for replacing body field placeholders * @returns The processed string with all placeholders replaced */ function processSingleValue({ originalValue, customUserVars, user, + body = undefined, }: { originalValue: string; customUserVars?: Record; user?: TUser; + body?: RequestBody; }): string { let value = originalValue; @@ -92,7 +126,12 @@ function processSingleValue({ // 2. Replace user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}, {{LIBRECHAT_USER_ID}}) value = processUserPlaceholders(value, user); - // 3. Replace system environment variables + // 3. Replace body field placeholders (e.g., {{LIBRECHAT_BODY_CONVERSATIONID}}, {{LIBRECHAT_BODY_PARENTMESSAGEID}}) + if (body) { + value = processBodyPlaceholders(value, body); + } + + // 4. Replace system environment variables value = extractEnvVariable(value); return value; @@ -100,26 +139,31 @@ function processSingleValue({ /** * Recursively processes an object to replace environment variables in string values - * @param obj - The object to process - * @param user - The user object containing all user fields - * @param customUserVars - vars that user set in settings + * @param params - Processing parameters + * @param params.options - The MCP options to process + * @param params.user - The user object containing all user fields + * @param params.customUserVars - vars that user set in settings + * @param params.body - the body of the request that is being processed * @returns - The processed object with environment variables replaced */ -export function processMCPEnv( - obj: Readonly, - user?: TUser, - customUserVars?: Record, -): MCPOptions { - if (obj === null || obj === undefined) { - return obj; +export function processMCPEnv(params: { + options: Readonly; + user?: TUser; + customUserVars?: Record; + body?: RequestBody; +}): MCPOptions { + const { options, user, customUserVars, body } = params; + + if (options === null || options === undefined) { + return options; } - const newObj: MCPOptions = structuredClone(obj); + const newObj: MCPOptions = structuredClone(options); if ('env' in newObj && newObj.env) { const processedEnv: Record = {}; for (const [key, originalValue] of Object.entries(newObj.env)) { - processedEnv[key] = processSingleValue({ originalValue, customUserVars, user }); + processedEnv[key] = processSingleValue({ originalValue, customUserVars, user, body }); } newObj.env = processedEnv; } @@ -127,7 +171,7 @@ export function processMCPEnv( if ('args' in newObj && newObj.args) { const processedArgs: string[] = []; for (const originalValue of newObj.args) { - processedArgs.push(processSingleValue({ originalValue, customUserVars, user })); + processedArgs.push(processSingleValue({ originalValue, customUserVars, user, body })); } newObj.args = processedArgs; } @@ -137,39 +181,47 @@ export function processMCPEnv( if ('headers' in newObj && newObj.headers) { const processedHeaders: Record = {}; for (const [key, originalValue] of Object.entries(newObj.headers)) { - processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user }); + processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user, body }); } newObj.headers = processedHeaders; } // Process URL if it exists (for WebSocket, SSE, StreamableHTTP types) if ('url' in newObj && newObj.url) { - newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user }); + newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user, body }); } return newObj; } /** - * Resolves header values by replacing user placeholders, custom variables, and environment variables - * @param headers - The headers object to process - * @param user - Optional user object for replacing user field placeholders (can be partial with just id) - * @param customUserVars - Optional custom user variables to replace placeholders - * @returns - The processed headers with all placeholders replaced + * Resolves header values by replacing user placeholders, body variables, custom variables, and environment variables. + * + * @param options - Optional configuration object. + * @param options.headers - The headers object to process. + * @param options.user - Optional user object for replacing user field placeholders (can be partial with just id). + * @param options.body - Optional request body object for replacing body field placeholders. + * @param options.customUserVars - Optional custom user variables to replace placeholders. + * @returns The processed headers with all placeholders replaced. */ -export function resolveHeaders( - headers: Record | undefined, - user?: Partial | { id: string }, - customUserVars?: Record, -) { - const resolvedHeaders = { ...(headers ?? {}) }; +export function resolveHeaders(options?: { + headers: Record | undefined; + user?: Partial | { id: string }; + body?: RequestBody; + customUserVars?: Record; +}) { + const { headers, user, body, customUserVars } = options ?? {}; + const inputHeaders = headers ?? {}; - if (headers && typeof headers === 'object' && !Array.isArray(headers)) { - Object.keys(headers).forEach((key) => { + const resolvedHeaders: Record = { ...inputHeaders }; + + if (inputHeaders && typeof inputHeaders === 'object' && !Array.isArray(inputHeaders)) { + Object.keys(inputHeaders).forEach((key) => { resolvedHeaders[key] = processSingleValue({ - originalValue: headers[key], + originalValue: inputHeaders[key], customUserVars, user: user as TUser, + body, }); }); }