LibreChat/packages/api/src/mcp/ConnectionsRepository.ts
Atef Bellaaj ac68e629e6
📡 refactor: MCP Runtime Config Sync with Redis Distributed Locking (#10352)
* 🔄 Refactoring: MCP Runtime Configuration Reload
 - PrivateServerConfigs own cache classes (inMemory and Redis).
 - Connections staleness detection by comparing (connection.createdAt and config.LastUpdatedAt)
 - ConnectionsRepo access Registry instead of in memory config dict and renew stale connections
 - MCPManager: adjusted init of ConnectionsRepo (app level)
 - UserConnectionManager: renew stale connections
 - skipped test, to test "should only clear keys in its own namespace"
 - MCPPrivateServerLoader: new component to manage logic of loading / editing private servers on runtime
 - PrivateServersLoadStatusCache to track private server cache status
 - New unit and integration tests.
Misc:
 - add es lint rule to enforce line between class methods

* Fix cluster mode batch update and delete workarround. Fixed unit tests for cluster mode.

* Fix Keyv redis clear cache namespace  awareness issue + Integration tests fixes

* chore: address copilot comments

* Fixing rebase issue: removed the mcp config fallback in single getServerConfig method:
- to not to interfere with the logic of the right Tier (APP/USER/Private)
- If userId is null, the getServerConfig should not return configs that are a SharedUser tier and not APP tier

* chore: add dev-staging branch to workflow triggers for backend, cache integration, and ESLint checks

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-12-11 16:36:15 -05:00

120 lines
4.8 KiB
TypeScript

import { logger } from '@librechat/data-schemas';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { MCPConnection } from './connection';
import { mcpServersRegistry as registry } from '~/mcp/registry/MCPServersRegistry';
import type * as t from './types';
/**
* Manages MCP connections with lazy loading and reconnection.
* Maintains a pool of connections and handles connection lifecycle management.
* Queries server configurations dynamically from the MCPServersRegistry (single source of truth).
*
* Scope-aware: Each repository is tied to a specific owner scope:
* - ownerId = undefined → manages app-level servers only
* - ownerId = userId → manages user-level and private servers for that user
*/
export class ConnectionsRepository {
protected connections: Map<string, MCPConnection> = new Map();
protected oauthOpts: t.OAuthConnectionOptions | undefined;
private readonly ownerId: string | undefined;
constructor(ownerId?: string, oauthOpts?: t.OAuthConnectionOptions) {
this.ownerId = ownerId;
this.oauthOpts = oauthOpts;
}
/** Checks whether this repository can connect to a specific server */
async has(serverName: string): Promise<boolean> {
const config = await registry.getServerConfig(serverName, this.ownerId);
if (!config) {
//if the config does not exist, clean up any potential orphaned connections (caused by server tier migration)
await this.disconnect(serverName);
}
return !!config;
}
/** Gets or creates a connection for the specified server with lazy loading */
async get(serverName: string): Promise<MCPConnection | null> {
const serverConfig = await registry.getServerConfig(serverName, this.ownerId);
const existingConnection = this.connections.get(serverName);
if (!serverConfig) {
if (existingConnection) {
await existingConnection.disconnect();
}
return null;
}
if (existingConnection) {
// Check if config was cached/updated since connection was created
if (serverConfig.lastUpdatedAt && existingConnection.isStale(serverConfig.lastUpdatedAt)) {
logger.info(
`${this.prefix(serverName)} Existing connection for ${serverName} is outdated. Recreating a new connection.`,
{
connectionCreated: new Date(existingConnection.createdAt).toISOString(),
configCachedAt: new Date(serverConfig.lastUpdatedAt).toISOString(),
},
);
// Disconnect stale connection
await existingConnection.disconnect();
this.connections.delete(serverName);
// Fall through to create new connection
} else if (await existingConnection.isConnected()) {
return existingConnection;
} else {
await this.disconnect(serverName);
}
}
const connection = await MCPConnectionFactory.create(
{
serverName,
serverConfig,
},
this.oauthOpts,
);
this.connections.set(serverName, connection);
return connection;
}
/** Gets or creates connections for multiple servers concurrently */
async getMany(serverNames: string[]): Promise<Map<string, MCPConnection>> {
const connectionPromises = serverNames.map(async (name) => [name, await this.get(name)]);
const connections = await Promise.all(connectionPromises);
return new Map((connections as [string, MCPConnection][]).filter((v) => !!v[1]));
}
/** Returns all currently loaded connections without creating new ones */
async getLoaded(): Promise<Map<string, MCPConnection>> {
return this.getMany(Array.from(this.connections.keys()));
}
/** Gets or creates connections for all configured servers in this repository's scope */
async getAll(): Promise<Map<string, MCPConnection>> {
//TODO in the future we should use a scoped config getter (APPLevel, UserLevel, Private)
//for now the unexisting config will not throw error
const allConfigs = await registry.getAllServerConfigs(this.ownerId);
return this.getMany(Object.keys(allConfigs));
}
/** Disconnects and removes a specific server connection from the pool */
disconnect(serverName: string): Promise<void> {
const connection = this.connections.get(serverName);
if (!connection) return Promise.resolve();
this.connections.delete(serverName);
return connection.disconnect().catch((err) => {
logger.error(`${this.prefix(serverName)} Error disconnecting`, err);
});
}
/** Disconnects all active connections and returns array of disconnect promises */
disconnectAll(): Promise<void>[] {
const serverNames = Array.from(this.connections.keys());
return serverNames.map((serverName) => this.disconnect(serverName));
}
// Returns formatted log prefix for server messages
protected prefix(serverName: string): string {
return `[MCP][${serverName}]`;
}
}