🏷️ feat: Request Placeholders for Custom Endpoint & MCP Headers (#9095)

* 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 <gopalsharma@gopal.sharma1>
Co-authored-by: s10gopal <36487439+s10gopal@users.noreply.github.com>
Co-authored-by: Dustin Healy <dustinhealy1@gmail.com>
This commit is contained in:
Danny Avila 2025-08-16 20:45:55 -04:00 committed by GitHub
parent 627f0bffe5
commit d7d02766ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 353 additions and 171 deletions

View file

@ -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<string, t.MCPOptions>;
protected connections: Map<string, MCPConnection> = 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;
}

View file

@ -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<string, string>;
flowManager: FlowStateManager<MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
signal?: AbortSignal;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
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<MCPConnection> {
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

View file

@ -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<string, unknown>;
options?: RequestOptions;
requestBody?: RequestBody;
tokenMethods?: TokenMethods;
customUserVars?: Record<string, string>;
flowManager: FlowStateManager<MCPOAuthTokens | null>;
@ -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',

View file

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

View file

@ -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<MCPOAuthTokens | null>;
customUserVars?: Record<string, string>;
requestBody?: RequestBody;
tokenMethods?: TokenMethods;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
@ -127,6 +130,7 @@ export abstract class UserConnectionManager {
oauthStart: oauthStart,
oauthEnd: oauthEnd,
returnOnOAuth: returnOnOAuth,
requestBody: requestBody,
},
);

View file

@ -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<any>,
findToken: undefined as unknown as TokenMethods['findToken'],
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),

View file

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

View file

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

View file

@ -81,11 +81,21 @@ export class MCPConnection extends EventEmitter {
private lastPingTime: number;
private lastConnectionCheckAt: number = 0;
private oauthTokens?: MCPOAuthTokens | null;
private requestHeaders?: Record<string, string> | null;
private oauthRequired = false;
iconPath?: string;
timeout?: number;
url?: string;
setRequestHeaders(headers: Record<string, string> | null): void {
logger.debug(`${this.getLogPrefix()} Setting request headers: ${JSON.stringify(headers)}`);
this.requestHeaders = headers;
}
getRequestHeaders(): Record<string, string> | 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<string, string> | null | undefined,
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const requestHeaders = getHeaders();
if (!requestHeaders) {
return fetch(input, init);
}
let initHeaders: Record<string, string> = {};
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<string, string>;
}
}
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 = () => {

View file

@ -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<typeof StdioOptionsSchema>;
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
@ -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<string, string>;
flowManager: FlowStateManager<o.MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
signal?: AbortSignal;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
}