♻️ 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

@ -1,17 +1,18 @@
import { EventEmitter } from 'events';
import { logger } from '@librechat/data-schemas';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import {
StdioClientTransport,
getDefaultEnvironment,
} from '@modelcontextprotocol/sdk/client/stdio.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ResourceListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/websocket.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { logger } from '@librechat/data-schemas';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { MCPOAuthTokens } from './oauth/types';
import { mcpConfig } from './mcpConfig';
import type * as t from './types';
function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions {
@ -56,9 +57,17 @@ function isStreamableHTTPOptions(options: t.MCPOptions): options is t.Streamable
}
const FIVE_MINUTES = 5 * 60 * 1000;
interface MCPConnectionParams {
serverName: string;
serverConfig: t.MCPOptions;
userId?: string;
oauthTokens?: MCPOAuthTokens | null;
}
export class MCPConnection extends EventEmitter {
private static instance: MCPConnection | null = null;
public client: Client;
private options: t.MCPOptions;
private transport: Transport | null = null; // Make this nullable
private connectionState: t.ConnectionState = 'disconnected';
private connectPromise: Promise<void> | null = null;
@ -70,26 +79,23 @@ export class MCPConnection extends EventEmitter {
private reconnectAttempts = 0;
private readonly userId?: string;
private lastPingTime: number;
private lastConnectionCheckAt: number = 0;
private oauthTokens?: MCPOAuthTokens | null;
private oauthRequired = false;
iconPath?: string;
timeout?: number;
url?: string;
constructor(
serverName: string,
private readonly options: t.MCPOptions,
userId?: string,
oauthTokens?: MCPOAuthTokens | null,
) {
constructor(params: MCPConnectionParams) {
super();
this.serverName = serverName;
this.userId = userId;
this.iconPath = options.iconPath;
this.timeout = options.timeout;
this.options = params.serverConfig;
this.serverName = params.serverName;
this.userId = params.userId;
this.iconPath = params.serverConfig.iconPath;
this.timeout = params.serverConfig.timeout;
this.lastPingTime = Date.now();
if (oauthTokens) {
this.oauthTokens = oauthTokens;
if (params.oauthTokens) {
this.oauthTokens = params.oauthTokens;
}
this.client = new Client(
{
@ -110,28 +116,6 @@ export class MCPConnection extends EventEmitter {
return `[MCP]${userPart}[${this.serverName}]`;
}
public static getInstance(
serverName: string,
options: t.MCPOptions,
userId?: string,
): MCPConnection {
if (!MCPConnection.instance) {
MCPConnection.instance = new MCPConnection(serverName, options, userId);
}
return MCPConnection.instance;
}
public static getExistingInstance(): MCPConnection | null {
return MCPConnection.instance;
}
public static async destroyInstance(): Promise<void> {
if (MCPConnection.instance) {
await MCPConnection.instance.disconnect();
MCPConnection.instance = null;
}
}
private emitError(error: unknown, errorContext: string): void {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`${this.getLogPrefix()} ${errorContext}: ${errorMessage}`);
@ -589,6 +573,13 @@ export class MCPConnection extends EventEmitter {
return false;
}
// If we recently checked, skip expensive verification
const now = Date.now();
if (now - this.lastConnectionCheckAt < mcpConfig.CONNECTION_CHECK_TTL) {
return true;
}
this.lastConnectionCheckAt = now;
try {
// Try ping first as it's the lightest check
await this.client.ping();