♻️ refactor: MCPManager for Scalability, Fix App-Level Detection, Add Lazy Connections (#8930)

* feat: MCP Connection management overhaul - Making MCPManager manageable

Refactor the monolithic MCPManager into focused, single-responsibility classes:

• MCPServersRegistry: Server configuration discovery and metadata management
• UserConnectionManager: Manages user-level connections
• ConnectionsRepository: Low-level connection pool with lazy loading
• MCPConnectionFactory: Handles MCP connection creation with OAuth support

New Features:
• Lazy loading of app-level connections for horizontal scaling
• Automatic reconnection for app-level connections
• Enhanced OAuth detection with explicit requiresOAuth flag
• Centralized MCP configuration management

Bug Fixes:
• App-level connection detection in MCPManager.callTool
• MCP Connection Reinitialization route behavior

Optimizations:
• MCPConnection.isConnected() caching to reduce overhead
• Concurrent server metadata retrieval instead of sequential

This refactoring addresses scalability bottlenecks and improves reliability
while maintaining backward compatibility with existing configurations.

* feat: Enabled import order in eslint.

* # Moved tests to __tests__ folder
# added tests for MCPServersRegistry.ts

* # Add unit tests for ConnectionsRepository functionality

* # Add unit tests for MCPConnectionFactory functionality

* # Reorganize MCP connection tests and improve error handling

* # reordering imports

* # Update testPathIgnorePatterns in jest.config.mjs to exclude development TypeScript files

* # removed mcp/manager.ts
This commit is contained in:
Theo N. Truong 2025-08-13 09:45:06 -06:00 committed by GitHub
parent 9dbf153489
commit 8780a78165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2571 additions and 1468 deletions

View file

@ -0,0 +1,212 @@
import { logger } from '@librechat/data-schemas';
import { ConnectionsRepository } from '../ConnectionsRepository';
import { MCPConnectionFactory } from '../MCPConnectionFactory';
import { MCPConnection } from '../connection';
import type * as t from '../types';
// Mock external dependencies
jest.mock('@librechat/data-schemas', () => ({
logger: {
error: jest.fn(),
},
}));
jest.mock('../MCPConnectionFactory', () => ({
MCPConnectionFactory: {
create: jest.fn(),
},
}));
jest.mock('../connection');
const mockLogger = logger as jest.Mocked<typeof logger>;
describe('ConnectionsRepository', () => {
let repository: ConnectionsRepository;
let mockServerConfigs: t.MCPServers;
let mockConnection: jest.Mocked<MCPConnection>;
beforeEach(() => {
mockServerConfigs = {
server1: { url: 'http://localhost:3001' },
server2: { command: 'test-command', args: ['--test'] },
server3: { url: 'ws://localhost:8080', type: 'websocket' },
};
mockConnection = {
isConnected: jest.fn().mockResolvedValue(true),
disconnect: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<MCPConnection>;
(MCPConnectionFactory.create as jest.Mock).mockResolvedValue(mockConnection);
repository = new ConnectionsRepository(mockServerConfigs);
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('has', () => {
it('should return true for existing server', () => {
expect(repository.has('server1')).toBe(true);
});
it('should return false for non-existing server', () => {
expect(repository.has('nonexistent')).toBe(false);
});
});
describe('get', () => {
it('should return existing connected connection', async () => {
mockConnection.isConnected.mockResolvedValue(true);
repository['connections'].set('server1', mockConnection);
const result = await repository.get('server1');
expect(result).toBe(mockConnection);
expect(MCPConnectionFactory.create).not.toHaveBeenCalled();
});
it('should create new connection if none exists', async () => {
const result = await repository.get('server1');
expect(result).toBe(mockConnection);
expect(MCPConnectionFactory.create).toHaveBeenCalledWith(
{
serverName: 'server1',
serverConfig: mockServerConfigs.server1,
},
undefined,
);
expect(repository['connections'].get('server1')).toBe(mockConnection);
});
it('should create new connection if existing connection is not connected', async () => {
const oldConnection = {
isConnected: jest.fn().mockResolvedValue(false),
disconnect: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<MCPConnection>;
repository['connections'].set('server1', oldConnection);
const result = await repository.get('server1');
expect(result).toBe(mockConnection);
expect(oldConnection.disconnect).toHaveBeenCalled();
expect(MCPConnectionFactory.create).toHaveBeenCalledWith(
{
serverName: 'server1',
serverConfig: mockServerConfigs.server1,
},
undefined,
);
});
it('should throw error for non-existent server configuration', async () => {
await expect(repository.get('nonexistent')).rejects.toThrow(
'[MCP][nonexistent] Server not found in configuration',
);
});
it('should handle MCPConnectionFactory.create errors', async () => {
const createError = new Error('Connection creation failed');
(MCPConnectionFactory.create as jest.Mock).mockRejectedValue(createError);
await expect(repository.get('server1')).rejects.toThrow('Connection creation failed');
});
});
describe('getMany', () => {
it('should return connections for multiple servers', async () => {
const result = await repository.getMany(['server1', 'server3']);
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(result.get('server1')).toBe(mockConnection);
expect(result.get('server3')).toBe(mockConnection);
});
});
describe('getLoaded', () => {
it('should return connections for loaded servers only', async () => {
// Load one connection
await repository.get('server1');
const result = await repository.getLoaded();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(1);
expect(result.get('server1')).toBe(mockConnection);
});
it('should return empty map when no connections are loaded', async () => {
const result = await repository.getLoaded();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(0);
});
});
describe('getAll', () => {
it('should return connections for all configured servers', async () => {
const result = await repository.getAll();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(3);
expect(result.get('server1')).toBe(mockConnection);
expect(result.get('server2')).toBe(mockConnection);
expect(result.get('server3')).toBe(mockConnection);
});
});
describe('disconnect', () => {
it('should disconnect and remove existing connection', async () => {
repository['connections'].set('server1', mockConnection);
await repository.disconnect('server1');
expect(mockConnection.disconnect).toHaveBeenCalled();
expect(repository['connections'].has('server1')).toBe(false);
});
it('should handle disconnect error gracefully', async () => {
const disconnectError = new Error('Disconnect failed');
mockConnection.disconnect.mockRejectedValue(disconnectError);
repository['connections'].set('server1', mockConnection);
await repository.disconnect('server1');
expect(mockConnection.disconnect).toHaveBeenCalled();
expect(repository['connections'].has('server1')).toBe(false);
expect(mockLogger.error).toHaveBeenCalledWith(
'[MCP][server1] Error disconnecting',
disconnectError,
);
});
});
describe('disconnectAll', () => {
it('should disconnect all active connections', () => {
const mockConnection1 = {
disconnect: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<MCPConnection>;
const mockConnection2 = {
disconnect: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<MCPConnection>;
const mockConnection3 = {
disconnect: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<MCPConnection>;
repository['connections'].set('server1', mockConnection1);
repository['connections'].set('server2', mockConnection2);
repository['connections'].set('server3', mockConnection3);
const promises = repository.disconnectAll();
expect(promises).toHaveLength(3);
expect(Array.isArray(promises)).toBe(true);
});
});
});

View file

@ -0,0 +1,347 @@
import { logger } 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 { MCPOAuthHandler } from '~/mcp/oauth';
import { MCPConnection } from '../connection';
import { processMCPEnv } from '~/utils';
import type * as t from '../types';
jest.mock('../connection');
jest.mock('~/mcp/oauth');
jest.mock('~/utils');
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
const mockLogger = logger as jest.Mocked<typeof logger>;
const mockProcessMCPEnv = processMCPEnv as jest.MockedFunction<typeof processMCPEnv>;
const mockMCPConnection = MCPConnection as jest.MockedClass<typeof MCPConnection>;
const mockMCPOAuthHandler = MCPOAuthHandler as jest.Mocked<typeof MCPOAuthHandler>;
describe('MCPConnectionFactory', () => {
let mockUser: TUser;
let mockServerConfig: t.MCPOptions;
let mockFlowManager: jest.Mocked<FlowStateManager<MCPOAuthTokens | null>>;
let mockConnectionInstance: jest.Mocked<MCPConnection>;
beforeEach(() => {
jest.clearAllMocks();
mockUser = {
id: 'user123',
email: 'test@example.com',
} as TUser;
mockServerConfig = {
command: 'node',
args: ['server.js'],
initTimeout: 5000,
} as t.MCPOptions;
mockFlowManager = {
createFlow: jest.fn(),
createFlowWithHandler: jest.fn(),
getFlowState: jest.fn(),
} as unknown as jest.Mocked<FlowStateManager<MCPOAuthTokens | null>>;
mockConnectionInstance = {
connect: jest.fn(),
isConnected: jest.fn(),
setOAuthTokens: jest.fn(),
on: jest.fn().mockReturnValue(mockConnectionInstance),
emit: jest.fn(),
} as unknown as jest.Mocked<MCPConnection>;
mockMCPConnection.mockImplementation(() => mockConnectionInstance);
mockProcessMCPEnv.mockReturnValue(mockServerConfig);
});
describe('static create method', () => {
it('should create a basic connection without OAuth', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
};
mockConnectionInstance.isConnected.mockResolvedValue(true);
const connection = await MCPConnectionFactory.create(basicOptions);
expect(connection).toBe(mockConnectionInstance);
expect(mockProcessMCPEnv).toHaveBeenCalledWith(mockServerConfig, undefined, undefined);
expect(mockMCPConnection).toHaveBeenCalledWith({
serverName: 'test-server',
serverConfig: mockServerConfig,
userId: undefined,
oauthTokens: null,
});
expect(mockConnectionInstance.connect).toHaveBeenCalled();
});
it('should create a connection with OAuth', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
};
const oauthOptions = {
useOAuth: true as const,
user: mockUser,
flowManager: mockFlowManager,
tokenMethods: {
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
};
const mockTokens: MCPOAuthTokens = {
access_token: 'access123',
refresh_token: 'refresh123',
token_type: 'Bearer',
obtained_at: Date.now(),
};
mockFlowManager.createFlowWithHandler.mockResolvedValue(mockTokens);
mockConnectionInstance.isConnected.mockResolvedValue(true);
const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions);
expect(connection).toBe(mockConnectionInstance);
expect(mockProcessMCPEnv).toHaveBeenCalledWith(mockServerConfig, mockUser, undefined);
expect(mockMCPConnection).toHaveBeenCalledWith({
serverName: 'test-server',
serverConfig: mockServerConfig,
userId: 'user123',
oauthTokens: mockTokens,
});
});
});
describe('OAuth token handling', () => {
it('should return null when no findToken method is provided', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
};
const oauthOptions = {
useOAuth: true as const,
user: mockUser,
flowManager: mockFlowManager,
tokenMethods: {
findToken: undefined as unknown as () => Promise<any>,
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
};
mockConnectionInstance.isConnected.mockResolvedValue(true);
await MCPConnectionFactory.create(basicOptions, oauthOptions);
expect(mockFlowManager.createFlowWithHandler).not.toHaveBeenCalled();
});
it('should handle token retrieval errors gracefully', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
};
const oauthOptions = {
useOAuth: true as const,
user: mockUser,
flowManager: mockFlowManager,
tokenMethods: {
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
};
mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('Token fetch failed'));
mockConnectionInstance.isConnected.mockResolvedValue(true);
const connection = await MCPConnectionFactory.create(basicOptions, oauthOptions);
expect(connection).toBe(mockConnectionInstance);
expect(mockMCPConnection).toHaveBeenCalledWith({
serverName: 'test-server',
serverConfig: mockServerConfig,
userId: 'user123',
oauthTokens: null,
});
expect(mockLogger.debug).toHaveBeenCalledWith(
expect.stringContaining('No existing tokens found or error loading tokens'),
expect.any(Error),
);
});
});
describe('OAuth event handling', () => {
it('should handle oauthRequired event for returnOnOAuth scenario', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: {
...mockServerConfig,
url: 'https://api.example.com',
type: 'sse' as const,
} as t.SSEOptions,
};
const oauthOptions = {
useOAuth: true as const,
user: mockUser,
flowManager: mockFlowManager,
returnOnOAuth: true,
oauthStart: jest.fn(),
tokenMethods: {
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
};
const mockFlowData = {
authorizationUrl: 'https://auth.example.com',
flowId: 'flow123',
flowMetadata: {
serverName: 'test-server',
userId: 'user123',
serverUrl: 'https://api.example.com',
state: 'random-state',
clientInfo: { client_id: 'client123' },
},
};
mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData);
mockFlowManager.createFlow.mockRejectedValue(new Error('Timeout expected'));
mockConnectionInstance.isConnected.mockResolvedValue(false);
let oauthRequiredHandler: (data: Record<string, unknown>) => Promise<void>;
mockConnectionInstance.on.mockImplementation((event, handler) => {
if (event === 'oauthRequired') {
oauthRequiredHandler = handler as (data: Record<string, unknown>) => Promise<void>;
}
return mockConnectionInstance;
});
try {
await MCPConnectionFactory.create(basicOptions, oauthOptions);
} catch {
// Expected to fail due to connection not established
}
expect(oauthRequiredHandler!).toBeDefined();
await oauthRequiredHandler!({ serverUrl: 'https://api.example.com' });
expect(mockMCPOAuthHandler.initiateOAuthFlow).toHaveBeenCalledWith(
'test-server',
'https://api.example.com',
'user123',
undefined,
);
expect(oauthOptions.oauthStart).toHaveBeenCalledWith('https://auth.example.com');
expect(mockConnectionInstance.emit).toHaveBeenCalledWith(
'oauthFailed',
expect.objectContaining({
message: 'OAuth flow initiated - return early',
}),
);
});
});
describe('connection retry logic', () => {
it('should establish connection successfully', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig, // Use default 5000ms timeout
};
mockConnectionInstance.connect.mockResolvedValue(undefined);
mockConnectionInstance.isConnected.mockResolvedValue(true);
const connection = await MCPConnectionFactory.create(basicOptions);
expect(connection).toBe(mockConnectionInstance);
expect(mockConnectionInstance.connect).toHaveBeenCalledTimes(1);
});
it('should handle OAuth errors during connection attempts', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
};
const oauthOptions = {
useOAuth: true as const,
user: mockUser,
flowManager: mockFlowManager,
oauthStart: jest.fn(),
tokenMethods: {
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
};
const oauthError = new Error('Non-200 status code (401)');
(oauthError as unknown as Record<string, unknown>).isOAuthError = true;
mockConnectionInstance.connect.mockRejectedValue(oauthError);
mockConnectionInstance.isConnected.mockResolvedValue(false);
await expect(MCPConnectionFactory.create(basicOptions, oauthOptions)).rejects.toThrow(
'Non-200 status code (401)',
);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining('OAuth required, stopping connection attempts'),
);
});
});
describe('isOAuthError method', () => {
it('should identify OAuth errors by message content', async () => {
const basicOptions = {
serverName: 'test-server',
serverConfig: mockServerConfig,
};
const oauthOptions = {
useOAuth: true as const,
user: mockUser,
flowManager: mockFlowManager,
tokenMethods: {
findToken: jest.fn(),
createToken: jest.fn(),
updateToken: jest.fn(),
deleteTokens: jest.fn(),
},
};
const error401 = new Error('401 Unauthorized');
mockConnectionInstance.connect.mockRejectedValue(error401);
mockConnectionInstance.isConnected.mockResolvedValue(false);
await expect(MCPConnectionFactory.create(basicOptions, oauthOptions)).rejects.toThrow('401');
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining('OAuth required, stopping connection attempts'),
);
});
});
});

View file

@ -0,0 +1,287 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { logger } from '@librechat/data-schemas';
import { load as yamlLoad } from 'js-yaml';
import { ConnectionsRepository } from '../ConnectionsRepository';
import { MCPServersRegistry } from '../MCPServersRegistry';
import { detectOAuthRequirement } from '~/mcp/oauth';
import { MCPConnection } from '../connection';
import type * as t from '../types';
// Mock external dependencies
jest.mock('../oauth/detectOAuth');
jest.mock('../ConnectionsRepository');
jest.mock('../connection');
jest.mock('@librechat/data-schemas', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
// Mock processMCPEnv to verify it's called and adds a processed marker
jest.mock('~/utils', () => ({
...jest.requireActual('~/utils'),
processMCPEnv: jest.fn((config) => ({
...config,
_processed: true, // Simple marker to verify processing occurred
})),
}));
const mockDetectOAuthRequirement = detectOAuthRequirement as jest.MockedFunction<
typeof detectOAuthRequirement
>;
const mockLogger = logger as jest.Mocked<typeof logger>;
describe('MCPServersRegistry - Initialize Function', () => {
let rawConfigs: t.MCPServers;
let expectedParsedConfigs: Record<string, any>;
let mockConnectionsRepo: jest.Mocked<ConnectionsRepository>;
let mockConnections: Map<string, jest.Mocked<MCPConnection>>;
beforeEach(() => {
// Load fixtures
const rawConfigsPath = join(__dirname, 'fixtures', 'MCPServersRegistry.rawConfigs.yml');
const parsedConfigsPath = join(__dirname, 'fixtures', 'MCPServersRegistry.parsedConfigs.yml');
rawConfigs = yamlLoad(readFileSync(rawConfigsPath, 'utf8')) as t.MCPServers;
expectedParsedConfigs = yamlLoad(readFileSync(parsedConfigsPath, 'utf8')) as Record<
string,
any
>;
// Setup mock connections
mockConnections = new Map();
const serverNames = Object.keys(rawConfigs);
serverNames.forEach((serverName) => {
const mockConnection = {
client: {
listTools: jest.fn(),
getInstructions: jest.fn(),
getServerCapabilities: jest.fn(),
},
} as unknown as jest.Mocked<MCPConnection>;
// Setup mock responses based on expected configs
const expectedConfig = expectedParsedConfigs[serverName];
// Mock listTools response
if (expectedConfig.tools) {
const toolNames = expectedConfig.tools.split(', ');
const tools = toolNames.map((name: string) => ({
name,
description: `Description for ${name}`,
inputSchema: {
type: 'object',
properties: {
input: { type: 'string' },
},
},
}));
mockConnection.client.listTools.mockResolvedValue({ tools });
} else {
mockConnection.client.listTools.mockResolvedValue({ tools: [] });
}
// Mock getInstructions response
if (expectedConfig.serverInstructions) {
mockConnection.client.getInstructions.mockReturnValue(expectedConfig.serverInstructions);
} else {
mockConnection.client.getInstructions.mockReturnValue(null);
}
// Mock getServerCapabilities response
if (expectedConfig.capabilities) {
const capabilities = JSON.parse(expectedConfig.capabilities);
mockConnection.client.getServerCapabilities.mockReturnValue(capabilities);
} else {
mockConnection.client.getServerCapabilities.mockReturnValue(null);
}
mockConnections.set(serverName, mockConnection);
});
// Setup ConnectionsRepository mock
mockConnectionsRepo = {
get: jest.fn(),
getLoaded: jest.fn(),
disconnectAll: jest.fn(),
} as unknown as jest.Mocked<ConnectionsRepository>;
mockConnectionsRepo.get.mockImplementation((serverName: string) =>
Promise.resolve(mockConnections.get(serverName)!),
);
mockConnectionsRepo.getLoaded.mockResolvedValue(mockConnections);
(ConnectionsRepository as jest.Mock).mockImplementation(() => mockConnectionsRepo);
// Setup OAuth detection mock with deterministic results
mockDetectOAuthRequirement.mockImplementation((url: string) => {
const oauthResults: Record<string, any> = {
'https://api.github.com/mcp': {
requiresOAuth: true,
metadata: {
authorization_url: 'https://github.com/login/oauth/authorize',
token_url: 'https://github.com/login/oauth/access_token',
},
},
'https://api.disabled.com/mcp': {
requiresOAuth: false,
metadata: null,
},
'https://api.public.com/mcp': {
requiresOAuth: false,
metadata: null,
},
};
return Promise.resolve(oauthResults[url] || { requiresOAuth: false, metadata: null });
});
// Clear all mocks
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initialize() method', () => {
it('should only run initialization once', async () => {
const registry = new MCPServersRegistry(rawConfigs);
await registry.initialize();
await registry.initialize(); // Second call should not re-run
// Verify that connections are only requested for servers that need them
// (servers with serverInstructions=true or all servers for capabilities)
expect(mockConnectionsRepo.get).toHaveBeenCalled();
});
it('should set all public properties correctly after initialization', async () => {
const registry = new MCPServersRegistry(rawConfigs);
// Verify initial state
expect(registry.oauthServers).toBeNull();
expect(registry.serverInstructions).toBeNull();
expect(registry.toolFunctions).toBeNull();
expect(registry.appServerConfigs).toBeNull();
await registry.initialize();
// Test oauthServers Set
expect(registry.oauthServers).toBeInstanceOf(Set);
expect(registry.oauthServers).toEqual(
new Set(['oauth_server', 'oauth_predefined', 'oauth_startup_enabled']),
);
// Test serverInstructions
expect(registry.serverInstructions).toEqual({
oauth_server: 'GitHub MCP server instructions',
stdio_server: 'Follow these instructions for stdio server',
non_oauth_server: 'Public API instructions',
});
// Test appServerConfigs (startup enabled, non-OAuth servers only)
expect(registry.appServerConfigs).toEqual({
stdio_server: rawConfigs.stdio_server,
websocket_server: rawConfigs.websocket_server,
non_oauth_server: rawConfigs.non_oauth_server,
});
// Test toolFunctions (only 2 servers have tools: oauth_server has 1, stdio_server has 2)
const expectedToolFunctions = {
get_repository_mcp_oauth_server: {
type: 'function',
function: {
name: 'get_repository_mcp_oauth_server',
description: 'Description for get_repository',
parameters: { type: 'object', properties: { input: { type: 'string' } } },
},
},
file_read_mcp_stdio_server: {
type: 'function',
function: {
name: 'file_read_mcp_stdio_server',
description: 'Description for file_read',
parameters: { type: 'object', properties: { input: { type: 'string' } } },
},
},
file_write_mcp_stdio_server: {
type: 'function',
function: {
name: 'file_write_mcp_stdio_server',
description: 'Description for file_write',
parameters: { type: 'object', properties: { input: { type: 'string' } } },
},
},
};
expect(registry.toolFunctions).toEqual(expectedToolFunctions);
});
it('should handle errors gracefully and continue initialization', async () => {
const registry = new MCPServersRegistry(rawConfigs);
// Make one server throw an error
mockDetectOAuthRequirement.mockRejectedValueOnce(new Error('OAuth detection failed'));
await registry.initialize();
// Should still initialize successfully
expect(registry.oauthServers).toBeInstanceOf(Set);
expect(registry.toolFunctions).toBeDefined();
// Error should be logged
expect(mockLogger.error).toHaveBeenCalledWith(
expect.stringContaining('[MCP][oauth_server] Failed to fetch OAuth requirement:'),
expect.any(Error),
);
});
it('should disconnect all connections after initialization', async () => {
const registry = new MCPServersRegistry(rawConfigs);
await registry.initialize();
expect(mockConnectionsRepo.disconnectAll).toHaveBeenCalledTimes(1);
});
it('should log configuration updates for each server', async () => {
const registry = new MCPServersRegistry(rawConfigs);
await registry.initialize();
const serverNames = Object.keys(rawConfigs);
serverNames.forEach((serverName) => {
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining(`[MCP][${serverName}] URL:`),
);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining(`[MCP][${serverName}] OAuth Required:`),
);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining(`[MCP][${serverName}] Capabilities:`),
);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining(`[MCP][${serverName}] Tools:`),
);
expect(mockLogger.info).toHaveBeenCalledWith(
expect.stringContaining(`[MCP][${serverName}] Server Instructions:`),
);
});
});
it('should have parsedConfigs matching the expected fixture after initialization', async () => {
const registry = new MCPServersRegistry(rawConfigs);
await registry.initialize();
// Compare the actual parsedConfigs against the expected fixture
expect(registry.parsedConfigs).toEqual(expectedParsedConfigs);
});
});
});

View file

@ -0,0 +1,168 @@
import type { PluginAuthMethods } from '@librechat/data-schemas';
import type { GenericTool } from '@librechat/agents';
import { getPluginAuthMap } from '~/agents/auth';
import { getUserMCPAuthMap } from '../auth';
jest.mock('~/agents/auth', () => ({
getPluginAuthMap: jest.fn(),
}));
const mockGetPluginAuthMap = getPluginAuthMap as jest.MockedFunction<typeof getPluginAuthMap>;
const createMockTool = (
name: string,
mcpRawServerName?: string,
mcp = true,
): GenericTool & { mcpRawServerName?: string; mcp?: boolean } =>
({
name,
mcpRawServerName,
mcp,
description: 'Mock tool',
}) as GenericTool & { mcpRawServerName?: string; mcp?: boolean };
const mockFindPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'] = jest.fn();
describe('getUserMCPAuthMap', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Core Functionality', () => {
it('should handle server names with various special characters and spaces', async () => {
const testCases = [
{
originalName: 'Connector: Company',
normalizedToolName: 'tool_mcp_Connector__Company',
},
{
originalName: 'Server (Production) @ Company.com',
normalizedToolName: 'tool_mcp_Server__Production____Company.com',
},
{
originalName: '🌟 Testing Server™ (α-β) 测试服务器',
normalizedToolName: 'tool_mcp_____Testing_Server_________',
},
];
const tools = testCases.map((testCase) =>
createMockTool(testCase.normalizedToolName, testCase.originalName),
);
const expectedKeys = testCases.map((tc) => `mcp_${tc.originalName}`);
mockGetPluginAuthMap.mockResolvedValue({});
await getUserMCPAuthMap({
userId: 'user123',
tools,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: expectedKeys,
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
});
});
describe('Edge Cases', () => {
it('should return empty object when no tools have mcpRawServerName', async () => {
const tools = [
createMockTool('regular_tool', undefined, false),
createMockTool('another_tool', undefined, false),
createMockTool('test_mcp_Server_no_raw_name', undefined),
];
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
expect(mockGetPluginAuthMap).not.toHaveBeenCalled();
});
it('should handle empty or undefined tools array', async () => {
let result = await getUserMCPAuthMap({
userId: 'user123',
tools: [],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
expect(mockGetPluginAuthMap).not.toHaveBeenCalled();
result = await getUserMCPAuthMap({
userId: 'user123',
tools: undefined,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
expect(mockGetPluginAuthMap).not.toHaveBeenCalled();
});
it('should handle database errors gracefully', async () => {
const tools = [createMockTool('test_mcp_Server1', 'Server1')];
const dbError = new Error('Database connection failed');
mockGetPluginAuthMap.mockRejectedValue(dbError);
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
});
it('should handle non-Error exceptions gracefully', async () => {
const tools = [createMockTool('test_mcp_Server1', 'Server1')];
mockGetPluginAuthMap.mockRejectedValue('String error');
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
});
});
describe('Integration', () => {
it('should handle complete workflow with normalized tool names and original server names', async () => {
const originalServerName = 'Connector: Company';
const toolName = 'test_auth_mcp_Connector__Company';
const tools = [createMockTool(toolName, originalServerName)];
const mockCustomUserVars = {
'mcp_Connector: Company': {
API_KEY: 'test123',
SECRET_TOKEN: 'secret456',
},
};
mockGetPluginAuthMap.mockResolvedValue(mockCustomUserVars);
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Connector: Company'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual(mockCustomUserVars);
});
});
});

View file

@ -0,0 +1,76 @@
// Integration tests for OAuth detection against real public MCP servers
// These tests verify the actual behavior against live endpoints
//
// DEVELOPMENT ONLY: This file is excluded from the test suite (.dev.ts extension)
// Use this for development and debugging OAuth detection behavior
//
// To run manually from packages/api directory:
// npx jest --testMatch="**/detectOAuth.integration.dev.ts"
import { detectOAuthRequirement } from '~/mcp/oauth';
describe('OAuth Detection Integration Tests', () => {
const NETWORK_TIMEOUT = 10000;
interface TestServer {
name: string;
url: string;
expectedOAuth: boolean;
expectedMethod: string;
withMeta: boolean;
}
const testServers: TestServer[] = [
{
name: 'GitHub Copilot MCP Server',
url: 'https://api.githubcopilot.com/mcp',
expectedOAuth: true,
expectedMethod: '401-challenge-metadata',
withMeta: true,
},
{
name: 'GitHub API (401 without metadata)',
url: 'https://api.github.com/user',
expectedOAuth: true,
expectedMethod: 'no-metadata-found',
withMeta: false,
},
{
name: 'Stytch Todo MCP Server',
url: 'https://mcp-stytch-consumer-todo-list.maxwell-gerber42.workers.dev',
expectedOAuth: true,
expectedMethod: 'protected-resource-metadata',
withMeta: true,
},
{
name: 'HTTPBin (Non-OAuth)',
url: 'https://httpbin.org',
expectedOAuth: false,
expectedMethod: 'no-metadata-found',
withMeta: false,
},
{
name: 'Unreachable Server',
url: 'https://definitely-not-a-real-server-12345.com',
expectedOAuth: false,
expectedMethod: 'no-metadata-found',
withMeta: false,
},
];
describe('detectOAuthRequirement integration', () => {
testServers.forEach((server) => {
it(
`should handle ${server.name}`,
async () => {
const result = await detectOAuthRequirement(server.url);
expect(result.requiresOAuth).toBe(server.expectedOAuth);
expect(result.method).toBe(server.expectedMethod);
expect(result.metadata == null).toBe(!server.withMeta);
},
NETWORK_TIMEOUT,
);
});
});
});

View file

@ -0,0 +1,74 @@
# Expected parsed MCP server configurations after running initialize()
# These represent the expected state of parsedConfigs after all fetch functions complete
oauth_server:
_processed: true
type: "streamable-http"
url: "https://api.github.com/mcp"
headers:
Authorization: "Bearer {{GITHUB_TOKEN}}"
serverInstructions: "GitHub MCP server instructions"
requiresOAuth: true
oauthMetadata:
authorization_url: "https://github.com/login/oauth/authorize"
token_url: "https://github.com/login/oauth/access_token"
capabilities: '{"tools":{"listChanged":true},"resources":{},"prompts":{}}'
tools: "get_repository"
oauth_predefined:
_processed: true
type: "sse"
url: "https://api.example.com/sse"
requiresOAuth: true
oauthMetadata:
authorization_url: "https://example.com/oauth/authorize"
token_url: "https://example.com/oauth/token"
capabilities: '{"tools":{},"resources":{},"prompts":{}}'
tools: ""
stdio_server:
_processed: true
command: "node"
args: ["server.js"]
env:
API_KEY: "${TEST_API_KEY}"
startup: true
serverInstructions: "Follow these instructions for stdio server"
requiresOAuth: false
capabilities: '{"tools":{"listChanged":true},"resources":{"listChanged":true},"prompts":{}}'
tools: "file_read, file_write"
websocket_server:
_processed: true
type: "websocket"
url: "ws://localhost:3001/mcp"
startup: true
requiresOAuth: false
oauthMetadata: null
capabilities: '{"tools":{},"resources":{},"prompts":{}}'
tools: ""
disabled_server:
_processed: true
type: "streamable-http"
url: "https://api.disabled.com/mcp"
startup: false
requiresOAuth: false
oauthMetadata: null
non_oauth_server:
_processed: true
type: "streamable-http"
url: "https://api.public.com/mcp"
requiresOAuth: false
serverInstructions: "Public API instructions"
capabilities: '{"tools":{},"resources":{},"prompts":{}}'
tools: ""
oauth_startup_enabled:
_processed: true
type: "sse"
url: "https://api.oauth-startup.com/sse"
requiresOAuth: true
capabilities: '{"tools":{},"resources":{},"prompts":{}}'
tools: ""

View file

@ -0,0 +1,53 @@
# Raw MCP server configurations used as input to MCPServersRegistry constructor
# These configs test different code paths in the initialization process
# Test OAuth detection with URL - should trigger fetchOAuthRequirement
oauth_server:
type: "streamable-http"
url: "https://api.github.com/mcp"
headers:
Authorization: "Bearer {{GITHUB_TOKEN}}"
serverInstructions: true
# Test OAuth already specified - should skip OAuth detection
oauth_predefined:
type: "sse"
url: "https://api.example.com/sse"
requiresOAuth: true
oauthMetadata:
authorization_url: "https://example.com/oauth/authorize"
token_url: "https://example.com/oauth/token"
# Test stdio server without URL - should set requiresOAuth to false
stdio_server:
command: "node"
args: ["server.js"]
env:
API_KEY: "${TEST_API_KEY}"
startup: true
serverInstructions: "Follow these instructions for stdio server"
# Test websocket server with capabilities but no tools
websocket_server:
type: "websocket"
url: "ws://localhost:3001/mcp"
startup: true
# Test server with startup disabled - should not be included in appServerConfigs
disabled_server:
type: "streamable-http"
url: "https://api.disabled.com/mcp"
startup: false
# Test non-OAuth server - should be included in appServerConfigs
non_oauth_server:
type: "streamable-http"
url: "https://api.public.com/mcp"
requiresOAuth: false
serverInstructions: true
# Test server with OAuth but startup enabled - should not be in appServerConfigs
oauth_startup_enabled:
type: "sse"
url: "https://api.oauth-startup.com/sse"
requiresOAuth: true

View file

@ -0,0 +1,190 @@
import type { MCPOptions } from 'librechat-data-provider';
import { MCPOAuthHandler } from '~/mcp/oauth';
jest.mock('@librechat/data-schemas', () => ({
logger: {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({
startAuthorization: jest.fn(),
}));
import { startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
const mockStartAuthorization = startAuthorization as jest.MockedFunction<typeof startAuthorization>;
describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
const mockServerName = 'test-server';
const mockServerUrl = 'https://example.com/mcp';
const mockUserId = 'user-123';
beforeEach(() => {
jest.clearAllMocks();
process.env.DOMAIN_SERVER = 'http://localhost:3080';
// Mock startAuthorization to return a successful response
mockStartAuthorization.mockResolvedValue({
authorizationUrl: new URL('https://auth.example.com/oauth/authorize?client_id=test'),
codeVerifier: 'test-code-verifier',
});
});
afterEach(() => {
delete process.env.DOMAIN_SERVER;
});
describe('Pre-configured OAuth Metadata Fields', () => {
const baseConfig: MCPOptions['oauth'] = {
authorization_url: 'https://auth.example.com/oauth/authorize',
token_url: 'https://auth.example.com/oauth/token',
client_id: 'test-client-id',
client_secret: 'test-client-secret',
};
it('should use default values when OAuth metadata fields are not configured', async () => {
await MCPOAuthHandler.initiateOAuthFlow(
mockServerName,
mockServerUrl,
mockUserId,
baseConfig,
);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256', 'plain'],
}),
}),
);
});
it('should use custom grant_types_supported when provided', async () => {
const config = {
...baseConfig,
grant_types_supported: ['authorization_code'],
};
await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
grant_types_supported: ['authorization_code'],
}),
}),
);
});
it('should use custom token_endpoint_auth_methods_supported when provided', async () => {
const config = {
...baseConfig,
token_endpoint_auth_methods_supported: ['client_secret_post'],
};
await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
token_endpoint_auth_methods_supported: ['client_secret_post'],
}),
}),
);
});
it('should use custom response_types_supported when provided', async () => {
const config = {
...baseConfig,
response_types_supported: ['code', 'token'],
};
await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
response_types_supported: ['code', 'token'],
}),
}),
);
});
it('should use custom code_challenge_methods_supported when provided', async () => {
const config = {
...baseConfig,
code_challenge_methods_supported: ['S256'],
};
await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
code_challenge_methods_supported: ['S256'],
}),
}),
);
});
it('should use all custom OAuth metadata fields when provided together', async () => {
const config = {
...baseConfig,
grant_types_supported: ['authorization_code', 'client_credentials'],
token_endpoint_auth_methods_supported: ['none'],
response_types_supported: ['code', 'token', 'id_token'],
code_challenge_methods_supported: ['S256'],
};
await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
grant_types_supported: ['authorization_code', 'client_credentials'],
token_endpoint_auth_methods_supported: ['none'],
response_types_supported: ['code', 'token', 'id_token'],
code_challenge_methods_supported: ['S256'],
}),
}),
);
});
it('should handle empty arrays as valid custom values', async () => {
const config = {
...baseConfig,
grant_types_supported: [],
token_endpoint_auth_methods_supported: [],
response_types_supported: [],
code_challenge_methods_supported: [],
};
await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config);
expect(mockStartAuthorization).toHaveBeenCalledWith(
mockServerUrl,
expect.objectContaining({
metadata: expect.objectContaining({
grant_types_supported: [],
token_endpoint_auth_methods_supported: [],
response_types_supported: [],
code_challenge_methods_supported: [],
}),
}),
);
});
});
});

View file

@ -0,0 +1,858 @@
import {
MCPOptions,
StdioOptionsSchema,
StreamableHTTPOptionsSchema,
} from 'librechat-data-provider';
import type { TUser } from 'librechat-data-provider';
import { processMCPEnv } from '~/utils/env';
// Helper function to create test user objects
function createTestUser(
overrides: Partial<TUser> & Record<string, unknown> = {},
): TUser & Record<string, unknown> {
return {
id: 'test-user-id',
username: 'testuser',
email: 'test@example.com',
name: 'Test User',
avatar: 'https://example.com/avatar.png',
provider: 'email',
role: 'user',
createdAt: new Date('2021-01-01').toISOString(),
updatedAt: new Date('2021-01-01').toISOString(),
...overrides,
};
}
describe('Environment Variable Extraction (MCP)', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = {
...originalEnv,
TEST_API_KEY: 'test-api-key-value',
ANOTHER_SECRET: 'another-secret-value',
};
});
afterEach(() => {
process.env = originalEnv;
});
describe('StdioOptionsSchema', () => {
it('should transform environment variables in the env field', () => {
const options = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
ANOTHER_KEY: '${ANOTHER_SECRET}',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
},
};
const result = StdioOptionsSchema.parse(options);
expect(result.env).toEqual({
API_KEY: 'test-api-key-value',
ANOTHER_KEY: 'another-secret-value',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
});
});
it('should handle undefined env field', () => {
const options = {
command: 'node',
args: ['server.js'],
};
const result = StdioOptionsSchema.parse(options);
expect(result.env).toBeUndefined();
});
});
describe('StreamableHTTPOptionsSchema', () => {
it('should validate a valid streamable-http configuration', () => {
const options = {
type: 'streamable-http',
url: 'https://example.com/api',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json',
},
};
const result = StreamableHTTPOptionsSchema.parse(options);
expect(result).toEqual(options);
});
it('should reject websocket URLs', () => {
const options = {
type: 'streamable-http',
url: 'ws://example.com/socket',
};
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
});
it('should reject secure websocket URLs', () => {
const options = {
type: 'streamable-http',
url: 'wss://example.com/socket',
};
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
});
it('should require type field to be set explicitly', () => {
const options = {
url: 'https://example.com/api',
};
// Type is now required, so parsing should fail
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
// With type provided, it should pass
const validOptions = {
type: 'streamable-http' as const,
url: 'https://example.com/api',
};
const result = StreamableHTTPOptionsSchema.parse(validOptions);
expect(result.type).toBe('streamable-http');
});
it('should validate headers as record of strings', () => {
const options = {
type: 'streamable-http',
url: 'https://example.com/api',
headers: {
'X-API-Key': '123456',
'User-Agent': 'MCP Client',
},
};
const result = StreamableHTTPOptionsSchema.parse(options);
expect(result.headers).toEqual(options.headers);
});
it('should accept "http" as an alias for "streamable-http"', () => {
const options = {
type: 'http',
url: 'https://example.com/api',
headers: {
Authorization: 'Bearer token',
},
};
const result = StreamableHTTPOptionsSchema.parse(options);
expect(result.type).toBe('http');
expect(result.url).toBe('https://example.com/api');
expect(result.headers).toEqual(options.headers);
});
it('should reject websocket URLs with "http" type', () => {
const options = {
type: 'http',
url: 'ws://example.com/socket',
};
expect(() => StreamableHTTPOptionsSchema.parse(options)).toThrow();
});
});
describe('processMCPEnv', () => {
it('should create a deep clone of the input object', () => {
const originalObj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
PLAIN_VALUE: 'plain-value',
},
};
const result = processMCPEnv(originalObj);
// Verify it's not the same object reference
expect(result).not.toBe(originalObj);
// Modify the result and ensure original is unchanged
if ('env' in result && result.env) {
result.env.API_KEY = 'modified-value';
}
expect(originalObj.env?.API_KEY).toBe('${TEST_API_KEY}');
});
it('should process environment variables in env field', () => {
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
ANOTHER_KEY: '${ANOTHER_SECRET}',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
},
};
const result = processMCPEnv(obj);
expect('env' in result && result.env).toEqual({
API_KEY: 'test-api-key-value',
ANOTHER_KEY: 'another-secret-value',
PLAIN_VALUE: 'plain-value',
NON_EXISTENT: '${NON_EXISTENT_VAR}',
});
});
it('should process user ID in headers field', () => {
const user = createTestUser({ id: 'test-user-123' });
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
Authorization: '${TEST_API_KEY}',
'User-Id': '{{LIBRECHAT_USER_ID}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
Authorization: 'test-api-key-value',
'User-Id': 'test-user-123',
'Content-Type': 'application/json',
});
});
it('should handle null or undefined input', () => {
// @ts-ignore - Testing null/undefined handling
expect(processMCPEnv(null)).toBeNull();
// @ts-ignore - Testing null/undefined handling
expect(processMCPEnv(undefined)).toBeUndefined();
});
it('should not modify objects without env or headers', () => {
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
timeout: 5000,
};
const result = processMCPEnv(obj);
expect(result).toEqual(obj);
expect(result).not.toBe(obj); // Still a different object (deep clone)
});
it('should ensure different users with same starting config get separate values', () => {
// Create a single base configuration
const baseConfig: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
'API-Key': '${TEST_API_KEY}',
},
};
// Process for two different users
const user1 = createTestUser({ id: 'user-123' });
const user2 = createTestUser({ id: 'user-456' });
const resultUser1 = processMCPEnv(baseConfig, user1);
const resultUser2 = processMCPEnv(baseConfig, user2);
// Verify each has the correct user ID
expect('headers' in resultUser1 && resultUser1.headers?.['User-Id']).toBe('user-123');
expect('headers' in resultUser2 && resultUser2.headers?.['User-Id']).toBe('user-456');
// Verify they're different objects
expect(resultUser1).not.toBe(resultUser2);
// Modify one result and ensure it doesn't affect the other
if ('headers' in resultUser1 && resultUser1.headers) {
resultUser1.headers['User-Id'] = 'modified-user';
}
// Original config should be unchanged
expect(baseConfig.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
// Second user's config should be unchanged
expect('headers' in resultUser2 && resultUser2.headers?.['User-Id']).toBe('user-456');
});
it('should process headers in streamable-http options', () => {
const user = createTestUser({ id: 'test-user-123' });
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://example.com',
headers: {
Authorization: '${TEST_API_KEY}',
'User-Id': '{{LIBRECHAT_USER_ID}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
Authorization: 'test-api-key-value',
'User-Id': 'test-user-123',
'Content-Type': 'application/json',
});
});
it('should maintain streamable-http type in processed options', () => {
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://example.com/api',
};
const result = processMCPEnv(obj);
expect(result.type).toBe('streamable-http');
});
it('should maintain http type in processed options', () => {
const obj = {
type: 'http' as const,
url: 'https://example.com/api',
};
const result = processMCPEnv(obj as unknown as MCPOptions);
expect(result.type).toBe('http');
});
it('should process headers in http options', () => {
const user = createTestUser({ id: 'test-user-123' });
const obj = {
type: 'http' as const,
url: 'https://example.com',
headers: {
Authorization: '${TEST_API_KEY}',
'User-Id': '{{LIBRECHAT_USER_ID}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj as unknown as MCPOptions, user);
expect('headers' in result && result.headers).toEqual({
Authorization: 'test-api-key-value',
'User-Id': 'test-user-123',
'Content-Type': 'application/json',
});
});
it('should process dynamic user fields in headers', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
username: 'testuser',
openidId: 'openid-123',
googleId: 'google-456',
emailVerified: true,
role: 'admin',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Email': '{{LIBRECHAT_USER_EMAIL}}',
'User-Name': '{{LIBRECHAT_USER_USERNAME}}',
OpenID: '{{LIBRECHAT_USER_OPENIDID}}',
'Google-ID': '{{LIBRECHAT_USER_GOOGLEID}}',
'Email-Verified': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
'User-Role': '{{LIBRECHAT_USER_ROLE}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'User-Email': 'test@example.com',
'User-Name': 'testuser',
OpenID: 'openid-123',
'Google-ID': 'google-456',
'Email-Verified': 'true',
'User-Role': 'admin',
'Content-Type': 'application/json',
});
});
it('should handle missing user fields gracefully', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
username: undefined, // explicitly set to undefined to test missing field
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Email': '{{LIBRECHAT_USER_EMAIL}}',
'User-Name': '{{LIBRECHAT_USER_USERNAME}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'User-Email': 'test@example.com',
'User-Name': '', // Empty string for missing field
'Content-Type': 'application/json',
});
});
it('should process user fields in env variables', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
ldapId: 'ldap-user-123',
});
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
USER_EMAIL: '{{LIBRECHAT_USER_EMAIL}}',
LDAP_ID: '{{LIBRECHAT_USER_LDAPID}}',
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv(obj, user);
expect('env' in result && result.env).toEqual({
USER_EMAIL: 'test@example.com',
LDAP_ID: 'ldap-user-123',
API_KEY: 'test-api-key-value',
});
});
it('should process user fields in URL', () => {
const user = createTestUser({
id: 'user-123',
username: 'testuser',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api/{{LIBRECHAT_USER_USERNAME}}/stream',
};
const result = processMCPEnv(obj, user);
expect('url' in result && result.url).toBe('https://example.com/api/testuser/stream');
});
it('should handle boolean user fields', () => {
const user = createTestUser({
id: 'user-123',
emailVerified: true,
twoFactorEnabled: false,
termsAccepted: true,
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'Email-Verified': '{{LIBRECHAT_USER_EMAILVERIFIED}}',
'Two-Factor': '{{LIBRECHAT_USER_TWOFACTORENABLED}}',
'Terms-Accepted': '{{LIBRECHAT_USER_TERMSACCEPTED}}',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'Email-Verified': 'true',
'Two-Factor': 'false',
'Terms-Accepted': 'true',
});
});
it('should not process sensitive fields like password', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
password: 'secret-password',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Email': '{{LIBRECHAT_USER_EMAIL}}',
'User-Password': '{{LIBRECHAT_USER_PASSWORD}}', // This should not be processed
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'User-Email': 'test@example.com',
'User-Password': '{{LIBRECHAT_USER_PASSWORD}}', // Unchanged
});
});
it('should handle multiple occurrences of the same placeholder', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
});
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'Primary-Email': '{{LIBRECHAT_USER_EMAIL}}',
'Secondary-Email': '{{LIBRECHAT_USER_EMAIL}}',
'Backup-Email': '{{LIBRECHAT_USER_EMAIL}}',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
'Primary-Email': 'test@example.com',
'Secondary-Email': 'test@example.com',
'Backup-Email': 'test@example.com',
});
});
it('should support both id and _id properties for LIBRECHAT_USER_ID', () => {
// Test with 'id' property
const userWithId = createTestUser({
id: 'user-123',
email: 'test@example.com',
});
const obj1: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result1 = processMCPEnv(obj1, 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')
const userWithUnderscore = createTestUser({
id: undefined, // Remove default id to test _id
_id: 'user-456',
email: 'test@example.com',
});
const obj2: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result2 = processMCPEnv(obj2, userWithUnderscore);
// Since we don't check _id, the placeholder should remain unchanged
expect('headers' in result2 && result2.headers?.['User-Id']).toBe('{{LIBRECHAT_USER_ID}}');
// Test with both properties (id takes precedence)
const userWithBoth = createTestUser({
id: 'user-789',
_id: 'user-000',
email: 'test@example.com',
});
const obj3: MCPOptions = {
type: 'sse',
url: 'https://example.com',
headers: {
'User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result3 = processMCPEnv(obj3, userWithBoth);
expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789');
});
it('should process customUserVars in env field', () => {
const user = createTestUser();
const customUserVars = {
CUSTOM_VAR_1: 'custom-value-1',
CUSTOM_VAR_2: 'custom-value-2',
};
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
VAR_A: '{{CUSTOM_VAR_1}}',
VAR_B: 'Value with {{CUSTOM_VAR_2}}',
VAR_C: '${TEST_API_KEY}',
VAR_D: '{{LIBRECHAT_USER_EMAIL}}',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('env' in result && result.env).toEqual({
VAR_A: 'custom-value-1',
VAR_B: 'Value with custom-value-2',
VAR_C: 'test-api-key-value',
VAR_D: 'test@example.com',
});
});
it('should process customUserVars in headers field', () => {
const user = createTestUser();
const customUserVars = {
USER_TOKEN: 'user-specific-token',
REGION: 'us-west-1',
};
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api',
headers: {
Authorization: 'Bearer {{USER_TOKEN}}',
'X-Region': '{{REGION}}',
'X-System-Key': '${TEST_API_KEY}',
'X-User-Id': '{{LIBRECHAT_USER_ID}}',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers).toEqual({
Authorization: 'Bearer user-specific-token',
'X-Region': 'us-west-1',
'X-System-Key': 'test-api-key-value',
'X-User-Id': 'test-user-id',
});
});
it('should process customUserVars in URL field', () => {
const user = createTestUser();
const customUserVars = {
API_VERSION: 'v2',
TENANT_ID: 'tenant123',
};
const obj: 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);
expect('url' in result && result.url).toBe(
'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value',
);
});
it('should process customUserVars in args field', () => {
const user = createTestUser({
id: 'user-123',
email: 'test@example.com',
});
const customUserVars = {
MY_API_KEY: 'user-provided-api-key-12345',
PROFILE_NAME: 'production-profile',
};
const obj: MCPOptions = {
command: 'npx',
args: [
'-y',
'@smithery/cli@latest',
'run',
'@upstash/context7-mcp',
'--key',
'{{MY_API_KEY}}',
'--profile',
'{{PROFILE_NAME}}',
'--user',
'{{LIBRECHAT_USER_EMAIL}}',
],
};
const result = processMCPEnv(obj, user, customUserVars);
expect('args' in result && result.args).toEqual([
'-y',
'@smithery/cli@latest',
'run',
'@upstash/context7-mcp',
'--key',
'user-provided-api-key-12345',
'--profile',
'production-profile',
'--user',
'test@example.com',
]);
});
it('should prioritize customUserVars over user fields and system env vars if placeholders are the same (though not recommended)', () => {
// This tests the order of operations: customUserVars -> userFields -> systemEnv
// BUt it's generally not recommended to have overlapping placeholder names.
process.env.LIBRECHAT_USER_EMAIL = 'system-email-should-be-overridden';
const user = createTestUser({ email: 'user-email-should-be-overridden' });
const customUserVars = {
LIBRECHAT_USER_EMAIL: 'custom-email-wins',
};
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api',
headers: {
'Test-Email': '{{LIBRECHAT_USER_EMAIL}}', // Placeholder that could match custom, user, or system
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins');
// Clean up env var
delete process.env.LIBRECHAT_USER_EMAIL;
});
it('should handle customUserVars with no matching placeholders', () => {
const user = createTestUser();
const customUserVars = {
UNUSED_VAR: 'unused-value',
};
const obj: MCPOptions = {
command: 'node',
args: ['server.js'],
env: {
API_KEY: '${TEST_API_KEY}',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('env' in result && result.env).toEqual({
API_KEY: 'test-api-key-value',
});
});
it('should handle placeholders with no matching customUserVars (falling back to user/system vars)', () => {
const user = createTestUser({ email: 'user-provided-email@example.com' });
// No customUserVars provided or customUserVars is empty
const customUserVars = {};
const obj: MCPOptions = {
type: 'sse',
url: 'https://example.com/api',
headers: {
'User-Email-Header': '{{LIBRECHAT_USER_EMAIL}}', // Should use user.email
'System-Key-Header': '${TEST_API_KEY}', // Should use process.env.TEST_API_KEY
'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}', // Should remain as placeholder
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers).toEqual({
'User-Email-Header': 'user-provided-email@example.com',
'System-Key-Header': 'test-api-key-value',
'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}',
});
});
it('should correctly process a mix of all variable types', () => {
const user = createTestUser({ id: 'userXYZ', username: 'john.doe' });
const customUserVars = {
CUSTOM_ENDPOINT_ID: 'ep123',
ANOTHER_CUSTOM: 'another_val',
};
const obj = {
type: 'streamable-http' as const,
url: 'https://{{CUSTOM_ENDPOINT_ID}}.example.com/users/{{LIBRECHAT_USER_USERNAME}}',
headers: {
'X-Auth-Token': '{{CUSTOM_TOKEN_FROM_USER_SETTINGS}}', // Assuming this would be a custom var
'X-User-ID': '{{LIBRECHAT_USER_ID}}',
'X-System-Test-Key': '${TEST_API_KEY}', // Using existing env var from beforeEach
},
env: {
PROCESS_MODE: '{{PROCESS_MODE_CUSTOM}}', // Another custom var
USER_HOME_DIR: '/home/{{LIBRECHAT_USER_USERNAME}}',
SYSTEM_PATH: '${PATH}', // Example of a system env var
},
};
// Simulate customUserVars that would be passed, including those for headers and env
const allCustomVarsForCall = {
...customUserVars,
CUSTOM_TOKEN_FROM_USER_SETTINGS: 'secretToken123!',
PROCESS_MODE_CUSTOM: 'production',
};
// 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);
expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe');
expect('headers' in result && result.headers).toEqual({
'X-Auth-Token': 'secretToken123!',
'X-User-ID': 'userXYZ',
'X-System-Test-Key': 'test-api-key-value', // Expecting value of TEST_API_KEY
});
expect('env' in result && result.env).toEqual({
PROCESS_MODE: 'production',
USER_HOME_DIR: '/home/john.doe',
SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment
});
});
it('should process GitHub MCP server configuration with PAT_TOKEN placeholder', () => {
const user = createTestUser({ id: 'github-user-123', email: 'user@example.com' });
const customUserVars = {
PAT_TOKEN: 'ghp_1234567890abcdef1234567890abcdef12345678', // GitHub Personal Access Token
};
// Simulate the GitHub MCP server configuration from librechat.yaml
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://api.githubcopilot.com/mcp/',
headers: {
Authorization: '{{PAT_TOKEN}}',
'Content-Type': 'application/json',
'User-Agent': 'LibreChat-MCP-Client',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers).toEqual({
Authorization: 'ghp_1234567890abcdef1234567890abcdef12345678',
'Content-Type': 'application/json',
'User-Agent': 'LibreChat-MCP-Client',
});
expect('url' in result && result.url).toBe('https://api.githubcopilot.com/mcp/');
expect(result.type).toBe('streamable-http');
});
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 = {
type: 'streamable-http',
url: 'https://api.githubcopilot.com/mcp/',
headers: {
Authorization: '{{PAT_TOKEN}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
Authorization: '{{PAT_TOKEN}}', // Should remain unchanged since no customUserVars provided
'Content-Type': 'application/json',
});
});
});
});

View file

@ -0,0 +1,28 @@
import { normalizeServerName } from '../utils';
describe('normalizeServerName', () => {
it('should not modify server names that already match the pattern', () => {
const result = normalizeServerName('valid-server_name.123');
expect(result).toBe('valid-server_name.123');
});
it('should normalize server names with non-ASCII characters', () => {
const result = normalizeServerName('我的服务');
// Should generate a fallback name with a hash
expect(result).toMatch(/^server_\d+$/);
expect(result).toMatch(/^[a-zA-Z0-9_.-]+$/);
});
it('should normalize server names with special characters', () => {
const result = normalizeServerName('server@name!');
// The actual result doesn't have the trailing underscore after trimming
expect(result).toBe('server_name');
expect(result).toMatch(/^[a-zA-Z0-9_.-]+$/);
});
it('should trim leading and trailing underscores', () => {
const result = normalizeServerName('!server-name!');
expect(result).toBe('server-name');
expect(result).toMatch(/^[a-zA-Z0-9_.-]+$/);
});
});

File diff suppressed because it is too large Load diff