mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-20 17:26:12 +01:00
♻️ 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:
parent
9dbf153489
commit
8780a78165
32 changed files with 2571 additions and 1468 deletions
212
packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts
Normal file
212
packages/api/src/mcp/__tests__/ConnectionsRepository.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
347
packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts
Normal file
347
packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts
Normal file
287
packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
packages/api/src/mcp/__tests__/auth.test.ts
Normal file
168
packages/api/src/mcp/__tests__/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: ""
|
||||
|
|
@ -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
|
||||
190
packages/api/src/mcp/__tests__/handler.test.ts
Normal file
190
packages/api/src/mcp/__tests__/handler.test.ts
Normal 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: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
858
packages/api/src/mcp/__tests__/mcp.spec.ts
Normal file
858
packages/api/src/mcp/__tests__/mcp.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
28
packages/api/src/mcp/__tests__/utils.test.ts
Normal file
28
packages/api/src/mcp/__tests__/utils.test.ts
Normal 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_.-]+$/);
|
||||
});
|
||||
});
|
||||
1818
packages/api/src/mcp/__tests__/zod.spec.ts
Normal file
1818
packages/api/src/mcp/__tests__/zod.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue