2025-09-17 22:49:36 +02:00
|
|
|
import { logger } from '@librechat/data-schemas';
|
2025-11-21 14:25:05 -05:00
|
|
|
import type { TokenMethods, IUser } from '@librechat/data-schemas';
|
2025-09-17 22:49:36 +02:00
|
|
|
import type { MCPOAuthTokens } from './types';
|
|
|
|
|
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
|
|
|
|
|
import { FlowStateManager } from '~/flow/manager';
|
|
|
|
|
import { MCPManager } from '~/mcp/MCPManager';
|
2025-12-01 00:57:46 +01:00
|
|
|
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000; // ms
|
2026-02-17 22:33:57 -05:00
|
|
|
const RECONNECT_STAGGER_MS = 500; // ms between each server reconnection
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
export class OAuthReconnectionManager {
|
|
|
|
|
private static instance: OAuthReconnectionManager | null = null;
|
|
|
|
|
|
|
|
|
|
protected readonly flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
|
|
|
|
protected readonly tokenMethods: TokenMethods;
|
2025-09-20 17:06:23 +02:00
|
|
|
private readonly mcpManager: MCPManager | null;
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
private readonly reconnectionsTracker: OAuthReconnectionTracker;
|
|
|
|
|
|
|
|
|
|
public static getInstance(): OAuthReconnectionManager {
|
|
|
|
|
if (!OAuthReconnectionManager.instance) {
|
|
|
|
|
throw new Error('OAuthReconnectionManager not initialized');
|
|
|
|
|
}
|
|
|
|
|
return OAuthReconnectionManager.instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async createInstance(
|
|
|
|
|
flowManager: FlowStateManager<MCPOAuthTokens | null>,
|
|
|
|
|
tokenMethods: TokenMethods,
|
|
|
|
|
reconnections?: OAuthReconnectionTracker,
|
|
|
|
|
): Promise<OAuthReconnectionManager> {
|
|
|
|
|
if (OAuthReconnectionManager.instance != null) {
|
|
|
|
|
throw new Error('OAuthReconnectionManager already initialized');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const manager = new OAuthReconnectionManager(flowManager, tokenMethods, reconnections);
|
|
|
|
|
OAuthReconnectionManager.instance = manager;
|
|
|
|
|
|
|
|
|
|
return manager;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public constructor(
|
|
|
|
|
flowManager: FlowStateManager<MCPOAuthTokens | null>,
|
|
|
|
|
tokenMethods: TokenMethods,
|
|
|
|
|
reconnections?: OAuthReconnectionTracker,
|
|
|
|
|
) {
|
|
|
|
|
this.flowManager = flowManager;
|
|
|
|
|
this.tokenMethods = tokenMethods;
|
|
|
|
|
this.reconnectionsTracker = reconnections ?? new OAuthReconnectionTracker();
|
2025-09-20 17:06:23 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.mcpManager = MCPManager.getInstance();
|
|
|
|
|
} catch {
|
|
|
|
|
this.mcpManager = null;
|
|
|
|
|
}
|
2025-09-17 22:49:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public isReconnecting(userId: string, serverName: string): boolean {
|
2025-09-21 22:58:19 -04:00
|
|
|
// Clean up if timed out, then return whether still reconnecting
|
|
|
|
|
this.reconnectionsTracker.cleanupIfTimedOut(userId, serverName);
|
|
|
|
|
return this.reconnectionsTracker.isStillReconnecting(userId, serverName);
|
2025-09-17 22:49:36 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async reconnectServers(userId: string) {
|
2025-09-20 17:06:23 +02:00
|
|
|
// Check if MCPManager is available
|
|
|
|
|
if (this.mcpManager == null) {
|
|
|
|
|
logger.warn(
|
|
|
|
|
'[OAuthReconnectionManager] MCPManager not available, skipping OAuth MCP server reconnection',
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
// 1. derive the servers to reconnect
|
|
|
|
|
const serversToReconnect = [];
|
2025-12-01 00:57:46 +01:00
|
|
|
for (const serverName of await MCPServersRegistry.getInstance().getOAuthServers()) {
|
2025-09-17 22:49:36 +02:00
|
|
|
const canReconnect = await this.canReconnect(userId, serverName);
|
|
|
|
|
if (canReconnect) {
|
|
|
|
|
serversToReconnect.push(serverName);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. mark the servers as reconnecting
|
|
|
|
|
for (const serverName of serversToReconnect) {
|
|
|
|
|
this.reconnectionsTracker.setActive(userId, serverName);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-17 22:33:57 -05:00
|
|
|
// 3. attempt to reconnect the servers with staggered delays to avoid connection storms
|
|
|
|
|
for (let i = 0; i < serversToReconnect.length; i++) {
|
|
|
|
|
const serverName = serversToReconnect[i];
|
|
|
|
|
if (i === 0) {
|
|
|
|
|
void this.tryReconnect(userId, serverName);
|
|
|
|
|
} else {
|
|
|
|
|
setTimeout(() => void this.tryReconnect(userId, serverName), i * RECONNECT_STAGGER_MS);
|
|
|
|
|
}
|
2025-09-17 22:49:36 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 11:21:36 -07:00
|
|
|
/**
|
|
|
|
|
* Attempts to reconnect a single OAuth MCP server.
|
|
|
|
|
* @returns true if reconnection succeeded, false otherwise.
|
|
|
|
|
*/
|
|
|
|
|
public async reconnectServer(userId: string, serverName: string): Promise<boolean> {
|
|
|
|
|
if (this.mcpManager == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.reconnectionsTracker.setActive(userId, serverName);
|
|
|
|
|
try {
|
|
|
|
|
await this.tryReconnect(userId, serverName);
|
|
|
|
|
return !this.reconnectionsTracker.isFailed(userId, serverName);
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
public clearReconnection(userId: string, serverName: string) {
|
|
|
|
|
this.reconnectionsTracker.removeFailed(userId, serverName);
|
|
|
|
|
this.reconnectionsTracker.removeActive(userId, serverName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async tryReconnect(userId: string, serverName: string) {
|
2025-09-20 17:06:23 +02:00
|
|
|
if (this.mcpManager == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
const logPrefix = `[tryReconnectOAuthMCPServer][User: ${userId}][${serverName}]`;
|
|
|
|
|
|
|
|
|
|
logger.info(`${logPrefix} Attempting reconnection`);
|
|
|
|
|
|
2025-12-01 00:57:46 +01:00
|
|
|
const config = await MCPServersRegistry.getInstance().getServerConfig(serverName, userId);
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
const cleanupOnFailedReconnect = () => {
|
|
|
|
|
this.reconnectionsTracker.setFailed(userId, serverName);
|
|
|
|
|
this.reconnectionsTracker.removeActive(userId, serverName);
|
2025-09-20 17:06:23 +02:00
|
|
|
this.mcpManager?.disconnectUserConnection(userId, serverName);
|
2025-09-17 22:49:36 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// attempt to get connection (this will use existing tokens and refresh if needed)
|
2025-09-20 17:06:23 +02:00
|
|
|
const connection = await this.mcpManager.getUserConnection({
|
2025-09-17 22:49:36 +02:00
|
|
|
serverName,
|
2025-11-21 14:25:05 -05:00
|
|
|
user: { id: userId } as IUser,
|
2025-09-17 22:49:36 +02:00
|
|
|
flowManager: this.flowManager,
|
|
|
|
|
tokenMethods: this.tokenMethods,
|
|
|
|
|
// don't force new connection, let it reuse existing or create new as needed
|
|
|
|
|
forceNew: false,
|
|
|
|
|
// set a reasonable timeout for reconnection attempts
|
|
|
|
|
connectionTimeout: config?.initTimeout ?? DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
|
|
|
// don't trigger OAuth flow during reconnection
|
|
|
|
|
returnOnOAuth: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (connection && (await connection.isConnected())) {
|
|
|
|
|
logger.info(`${logPrefix} Successfully reconnected`);
|
|
|
|
|
this.clearReconnection(userId, serverName);
|
|
|
|
|
} else {
|
|
|
|
|
logger.warn(`${logPrefix} Failed to reconnect`);
|
|
|
|
|
await connection?.disconnect();
|
|
|
|
|
cleanupOnFailedReconnect();
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn(`${logPrefix} Failed to reconnect: ${error}`);
|
|
|
|
|
cleanupOnFailedReconnect();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
🪣 fix: Prevent Memory Retention from AsyncLocalStorage Context Propagation (#11942)
* fix: store hide_sequential_outputs before processStream clears config
processStream now clears config.configurable after completion to break
memory retention chains. Save hide_sequential_outputs to a local
variable before calling runAgents so the post-stream filter still works.
* feat: memory diagnostics
* chore: expose garbage collection in backend inspect command
Updated the backend inspect command in package.json to include the --expose-gc flag, enabling garbage collection diagnostics for improved memory management during development.
* chore: update @librechat/agents dependency to version 3.1.52
Bumped the version of @librechat/agents in package.json and package-lock.json to ensure compatibility and access to the latest features and fixes.
* fix: clear heavy config state after processStream to prevent memory leaks
Break the reference chain from LangGraph's internal __pregel_scratchpad
through @langchain/core RunTree.extra[lc:child_config] into the
AsyncLocalStorage context captured by timers and I/O handles.
After stream completion, null out symbol-keyed scratchpad properties
(currentTaskInput), config.configurable, and callbacks. Also call
Graph.clearHeavyState() to release config, signal, content maps,
handler registry, and tool sessions.
* chore: fix imports for memory utils
* chore: add circular dependency check in API build step
Enhanced the backend review workflow to include a check for circular dependencies during the API build process. If a circular dependency is detected, an error message is displayed, and the process exits with a failure status.
* chore: update API build step to include circular dependency detection
Modified the backend review workflow to rename the API package installation step to reflect its new functionality, which now includes detection of circular dependencies during the build process.
* chore: add memory diagnostics option to .env.example
Included a commented-out configuration option for enabling memory diagnostics in the .env.example file, which logs heap and RSS snapshots every 60 seconds when activated.
* chore: remove redundant agentContexts cleanup in disposeClient function
Streamlined the disposeClient function by eliminating duplicate cleanup logic for agentContexts, ensuring efficient memory management during client disposal.
* refactor: move runOutsideTracing utility to utils and update its usage
Refactored the runOutsideTracing function by relocating it to the utils module for better organization. Updated the tool execution handler to utilize the new import, ensuring consistent tracing behavior during tool execution.
* refactor: enhance connection management and diagnostics
Added a method to ConnectionsRepository for retrieving the active connection count. Updated UserConnectionManager to utilize this new method for app connection count reporting. Refined the OAuthReconnectionTracker's getStats method to improve clarity in diagnostics. Introduced a new tracing utility in the utils module to streamline tracing context management. Additionally, added a safeguard in memory diagnostics to prevent unnecessary snapshot collection for very short intervals.
* refactor: enhance tracing utility and add memory diagnostics tests
Refactored the runOutsideTracing function to improve warning logic when the AsyncLocalStorage context is missing. Added tests for memory diagnostics and tracing utilities to ensure proper functionality and error handling. Introduced a new test suite for memory diagnostics, covering snapshot collection and garbage collection behavior.
2026-02-25 17:41:23 -05:00
|
|
|
public getTrackerStats() {
|
|
|
|
|
return this.reconnectionsTracker.getStats();
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
private async canReconnect(userId: string, serverName: string) {
|
2025-09-20 17:06:23 +02:00
|
|
|
if (this.mcpManager == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2025-09-17 22:49:36 +02:00
|
|
|
|
|
|
|
|
// if the server has failed reconnection, don't attempt to reconnect
|
|
|
|
|
if (this.reconnectionsTracker.isFailed(userId, serverName)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-21 22:58:19 -04:00
|
|
|
if (this.reconnectionsTracker.isActive(userId, serverName)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
// if the server is already connected, don't attempt to reconnect
|
2025-09-20 17:06:23 +02:00
|
|
|
const existingConnections = this.mcpManager.getUserConnections(userId);
|
2025-09-17 22:49:36 +02:00
|
|
|
if (existingConnections?.has(serverName)) {
|
|
|
|
|
const isConnected = await existingConnections.get(serverName)?.isConnected();
|
|
|
|
|
if (isConnected) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 11:21:36 -07:00
|
|
|
// if the server has a valid (non-expired) access token, allow reconnect
|
2025-09-17 22:49:36 +02:00
|
|
|
const accessToken = await this.tokenMethods.findToken({
|
|
|
|
|
userId,
|
|
|
|
|
type: 'mcp_oauth',
|
|
|
|
|
identifier: `mcp:${serverName}`,
|
|
|
|
|
});
|
2026-03-10 11:21:36 -07:00
|
|
|
|
|
|
|
|
if (accessToken != null) {
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (!accessToken.expiresAt || accessToken.expiresAt >= now) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2025-09-17 22:49:36 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-10 11:21:36 -07:00
|
|
|
// if the access token is expired or TTL-deleted, fall back to refresh token
|
|
|
|
|
const refreshToken = await this.tokenMethods.findToken({
|
|
|
|
|
userId,
|
|
|
|
|
type: 'mcp_oauth',
|
|
|
|
|
identifier: `mcp:${serverName}:refresh`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (refreshToken == null) {
|
2025-09-17 22:49:36 +02:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|