2025-09-17 22:49:36 +02:00
|
|
|
import { logger } from '@librechat/data-schemas';
|
|
|
|
|
import type { TokenMethods } from '@librechat/data-schemas';
|
|
|
|
|
import type { TUser } from 'librechat-data-provider';
|
|
|
|
|
import type { MCPOAuthTokens } from './types';
|
|
|
|
|
import { OAuthReconnectionTracker } from './OAuthReconnectionTracker';
|
|
|
|
|
import { FlowStateManager } from '~/flow/manager';
|
|
|
|
|
import { MCPManager } from '~/mcp/MCPManager';
|
|
|
|
|
|
|
|
|
|
const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000; // ms
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
return this.reconnectionsTracker.isActive(userId, serverName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-09-20 17:06:23 +02:00
|
|
|
for (const serverName of this.mcpManager.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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. attempt to reconnect the servers
|
|
|
|
|
for (const serverName of serversToReconnect) {
|
|
|
|
|
void this.tryReconnect(userId, serverName);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-09-20 17:06:23 +02:00
|
|
|
const config = this.mcpManager.getRawConfig(serverName);
|
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,
|
|
|
|
|
user: { id: userId } as TUser,
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// if the server has no tokens for the user, don't attempt to reconnect
|
|
|
|
|
const accessToken = await this.tokenMethods.findToken({
|
|
|
|
|
userId,
|
|
|
|
|
type: 'mcp_oauth',
|
|
|
|
|
identifier: `mcp:${serverName}`,
|
|
|
|
|
});
|
|
|
|
|
if (accessToken == null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// if the token has expired, don't attempt to reconnect
|
|
|
|
|
const now = new Date();
|
|
|
|
|
if (accessToken.expiresAt && accessToken.expiresAt < now) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// …otherwise, we're good to go with the reconnect attempt
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|