mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-25 19:56:13 +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
120
packages/api/src/mcp/oauth/detectOAuth.ts
Normal file
120
packages/api/src/mcp/oauth/detectOAuth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './types';
|
||||
export * from './handler';
|
||||
export * from './tokens';
|
||||
export * from './detectOAuth';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue