♻️ 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,120 @@
// ATTENTION: If you modify OAuth detection logic in this file, run the integration tests to verify:
// npx jest --testMatch="**/detectOAuth.integration.dev.ts" (from packages/api directory)
//
// These tests are excluded from CI because they make live HTTP requests to external services,
// which could cause flaky builds due to network issues or changes in third-party endpoints.
// Manual testing ensures the OAuth detection still works against real MCP servers.
import { discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk/client/auth.js';
import { mcpConfig } from '../mcpConfig';
export interface OAuthDetectionResult {
requiresOAuth: boolean;
method: 'protected-resource-metadata' | '401-challenge-metadata' | 'no-metadata-found';
metadata?: Record<string, unknown> | null;
}
/**
* Detects if an MCP server requires OAuth authentication using proactive discovery methods.
*
* This function implements a comprehensive OAuth detection strategy:
* 1. Standard Protected Resource Metadata (RFC 9728) - checks /.well-known/oauth-protected-resource
* 2. 401 Challenge Method - checks WWW-Authenticate header for resource_metadata URL
* 3. Optional fallback: treat any 401/403 response as OAuth requirement (if MCP_OAUTH_ON_AUTH_ERROR=true)
*
* @param serverUrl - The MCP server URL to check for OAuth requirements
* @returns Promise<OAuthDetectionResult> - OAuth requirement details
*/
export async function detectOAuthRequirement(serverUrl: string): Promise<OAuthDetectionResult> {
const protectedResourceResult = await checkProtectedResourceMetadata(serverUrl);
if (protectedResourceResult) return protectedResourceResult;
const challengeResult = await check401ChallengeMetadata(serverUrl);
if (challengeResult) return challengeResult;
const fallbackResult = await checkAuthErrorFallback(serverUrl);
if (fallbackResult) return fallbackResult;
// No OAuth detected
return {
requiresOAuth: false,
method: 'no-metadata-found',
metadata: null,
};
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// ------------------------ Private helper functions for OAuth detection -------------------------//
////////////////////////////////////////////////////////////////////////////////////////////////////
// Checks for OAuth using standard protected resource metadata (RFC 9728)
async function checkProtectedResourceMetadata(
serverUrl: string,
): Promise<OAuthDetectionResult | null> {
try {
const resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
if (!resourceMetadata?.authorization_servers?.length) return null;
return {
requiresOAuth: true,
method: 'protected-resource-metadata',
metadata: resourceMetadata,
};
} catch {
return null;
}
}
// Checks for OAuth using 401 challenge with resource metadata URL
async function check401ChallengeMetadata(serverUrl: string): Promise<OAuthDetectionResult | null> {
try {
const response = await fetch(serverUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT),
});
if (response.status !== 401) return null;
const wwwAuth = response.headers.get('www-authenticate');
const metadataUrl = wwwAuth?.match(/resource_metadata="([^"]+)"/)?.[1];
if (!metadataUrl) return null;
const metadataResponse = await fetch(metadataUrl, {
signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT),
});
const metadata = await metadataResponse.json();
if (!metadata?.authorization_servers?.length) return null;
return {
requiresOAuth: true,
method: '401-challenge-metadata',
metadata,
};
} catch {
return null;
}
}
// Fallback method: treats any auth error as OAuth requirement if configured
async function checkAuthErrorFallback(serverUrl: string): Promise<OAuthDetectionResult | null> {
try {
if (!mcpConfig.OAUTH_ON_AUTH_ERROR) return null;
const response = await fetch(serverUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT),
});
if (response.status !== 401 && response.status !== 403) return null;
return {
requiresOAuth: true,
method: 'no-metadata-found',
metadata: null,
};
} catch {
return null;
}
}

View file

@ -1,190 +0,0 @@
import { MCPOAuthHandler } from './handler';
import type { MCPOptions } from 'librechat-data-provider';
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

@ -1,3 +1,4 @@
export * from './types';
export * from './handler';
export * from './tokens';
export * from './detectOAuth';