mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
📡 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>
This commit is contained in:
parent
19b78ecd81
commit
36e42abce1
49 changed files with 5244 additions and 257 deletions
|
|
@ -1,37 +1,74 @@
|
|||
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 readonly serverConfigs: Record<string, t.MCPOptions>;
|
||||
protected connections: Map<string, MCPConnection> = new Map();
|
||||
protected oauthOpts: t.OAuthConnectionOptions | undefined;
|
||||
private readonly ownerId: string | undefined;
|
||||
|
||||
constructor(serverConfigs: t.MCPServers, oauthOpts?: t.OAuthConnectionOptions) {
|
||||
this.serverConfigs = serverConfigs;
|
||||
constructor(ownerId?: string, oauthOpts?: t.OAuthConnectionOptions) {
|
||||
this.ownerId = ownerId;
|
||||
this.oauthOpts = oauthOpts;
|
||||
}
|
||||
|
||||
/** Checks whether this repository can connect to a specific server */
|
||||
has(serverName: string): boolean {
|
||||
return !!this.serverConfigs[serverName];
|
||||
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> {
|
||||
async get(serverName: string): Promise<MCPConnection | null> {
|
||||
const serverConfig = await registry.getServerConfig(serverName, this.ownerId);
|
||||
const existingConnection = this.connections.get(serverName);
|
||||
if (existingConnection && (await existingConnection.isConnected())) return existingConnection;
|
||||
else await this.disconnect(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.getServerConfig(serverName),
|
||||
serverConfig,
|
||||
},
|
||||
this.oauthOpts,
|
||||
);
|
||||
|
|
@ -44,7 +81,7 @@ export class ConnectionsRepository {
|
|||
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][]);
|
||||
return new Map((connections as [string, MCPConnection][]).filter((v) => !!v[1]));
|
||||
}
|
||||
|
||||
/** Returns all currently loaded connections without creating new ones */
|
||||
|
|
@ -52,9 +89,12 @@ export class ConnectionsRepository {
|
|||
return this.getMany(Array.from(this.connections.keys()));
|
||||
}
|
||||
|
||||
/** Gets or creates connections for all configured servers */
|
||||
/** Gets or creates connections for all configured servers in this repository's scope */
|
||||
async getAll(): Promise<Map<string, MCPConnection>> {
|
||||
return this.getMany(Object.keys(this.serverConfigs));
|
||||
//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 */
|
||||
|
|
@ -73,13 +113,6 @@ export class ConnectionsRepository {
|
|||
return serverNames.map((serverName) => this.disconnect(serverName));
|
||||
}
|
||||
|
||||
// Retrieves server configuration by name or throws if not found
|
||||
protected getServerConfig(serverName: string): t.MCPOptions {
|
||||
const serverConfig = this.serverConfigs[serverName];
|
||||
if (serverConfig) return serverConfig;
|
||||
throw new Error(`${this.prefix(serverName)} Server not found in configuration`);
|
||||
}
|
||||
|
||||
// Returns formatted log prefix for server messages
|
||||
protected prefix(serverName: string): string {
|
||||
return `[MCP][${serverName}]`;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue