mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-26 01:16:24 +00:00
Some checks failed
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Publish `librechat-data-provider` to NPM / pack (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
* Add OBO (On-Behalf-Of) token exchange support for MCP server connections Enables transparent authentication to Entra ID-backed MCP servers using the logged-in user's federated token via the OAuth 2.0 jwt-bearer grant. Configured via obo.scopes in librechat.yaml server config. - Extract generic OboTokenService from GraphTokenService (jwt-bearer grant + cache) - Refactor GraphTokenService to thin wrapper delegating to OboTokenService - Add obo schema field to BaseOptionsSchema in data-provider - Add resolveOboToken in packages/api/src/mcp/oauth/obo.ts (validates federated token, calls resolver, returns MCPOAuthTokens) - Wire oboTokenResolver through MCPConnectionFactory, MCPManager, UserConnectionManager - OBO tokens injected via request headers (not OAuth transport), refreshed on each tool call - Explicit error on OBO failure (no fallthrough to standard OAuth redirect) - Add unit tests for both resolveOboToken (9 tests) and exchangeOboToken (14 tests) * Add OBO authentication option to MCP server UI configuration Enable users to configure On-Behalf-Of (OBO) token exchange for MCP servers created via the UI (MongoDB-stored), in addition to the existing YAML-based configuration. - Add "On-Behalf-Of (OBO)" radio option to MCP server auth section with scopes input field - Remove obo from omitServerManagedFields so the field passes UI schema validation - Add OBO to AuthTypeEnum, obo_scopes to AuthConfig, and OBO handling in form defaults and submission - Add .min(1) validation on obo.scopes to reject empty strings - Add English localization keys: com_ui_obo, com_ui_obo_scopes, com_ui_obo_scopes_description - Add 5 schema validation tests for OBO field acceptance, transport compatibility, and edge cases * 🧊 fix: Add obo to safe properties in redactServerSecrets. Fixes the OBO configuration not showing up in the MCP UI after app restart * Address linter errors * 🧊 fix: fail closed on OBO refresh errors and retry transient token exchange failures - stop tool calls from falling back to stale Authorization headers when per-call OBO refresh fails - add one-time retry for transient Entra OBO exchange failures (network/429/5xx) - preserve structured OBO failure reasons and retryability in resolveOboToken - improve OBO auth error messaging for connection setup and tool execution - add tests for transient vs permanent OBO failure paths * Addressing linting errors / warnings * 🧊 fix: isolate OBO MCP auth to user-scoped connections - block OBO-enabled servers from app-level shared MCP connections - bypass shared connection lookup for OBO servers in MCPManager.getConnection - add regressions covering OBO connection scoping and preserve non-OBO app connection reuse * 🛠️ refactor: centralize MCP user-scoped connection policy - add shared requiresUserScopedConnection helper for OAuth, OBO, and customUserVars - use the shared predicate in MCPManager and ConnectionsRepository - add utils coverage for user-scoped connection policy * 🧊 fix: restrict MCP OBO config to header-capable transports - Move OBO configuration out of the shared MCP base options schema and allow it only on SSE and streamable-http transports, where request headers are applied. - Explicitly reject OBO on stdio and websocket configs to avoid accepted-but- nonfunctional server definitions. Add schema coverage for admin/config parsing and user-input websocket validation. * 🧊 fix: single-flight concurrent OBO token exchanges Concurrent tool calls that arrive on a cache miss were each issuing their own jwt-bearer request to the IdP. Under that fan-out, Entra intermittently returned errors that the retry classifier saw as non-retryable, surfacing as: "The identity provider rejected the OBO token exchange. Cannot execute tool <name>. Re-authenticate the user or verify the configured OBO scopes and retry." A user retry then hit the populated cache and succeeded, which matches the observed flakiness — the cache was empty at the moment of fan-out but populated by the time the user clicked retry. - Coalesce concurrent exchanges in `OboTokenService.exchangeOboToken` keyed by `${openidId}:${scopes}`. Callers that arrive while an exchange is in flight share the same upstream request and receive the same result. `fromCache=false` continues to force a fresh, independent exchange (and is not joined by `fromCache=true` callers). The IdP call, single-retry path, and cache write are unchanged — they were moved into a `performOboExchange` helper so the coalescing wrapper stays small. - Tests cover: coalescing on the same key, isolation between different keys, cleanup on success, cleanup on failure, and the `fromCache=false` bypass. * 🔒 feat: gate MCP OBO config behind MCP_SERVERS.CONFIGURE_OBO permission OBO silently mints per-user delegated tokens from the caller's federated access token and forwards them to whatever URL the server config points at. Previously, anyone with MCP_SERVERS.CREATE could configure obo.scopes — so if server creation is ever delegated beyond admins, a user could stand up an attacker-controlled server, attach it to a shared agent, and exfiltrate other users' downstream tokens on tool invocation. Add a dedicated MCP_SERVERS.CONFIGURE_OBO permission (ADMIN: true, USER: false by default) and enforce it at three layers so the safety property no longer depends on CREATE staying admin-only: - Create/update: POST/PATCH /api/mcp/servers returns 403 when the body carries `obo` and the caller's role lacks the permission. - Runtime fail-closed: for DB-sourced configs, MCPConnectionFactory and MCPManager.callTool re-check the original author's role before each OBO exchange. If the author has been downgraded, the exchange is skipped (factory) or refused (callTool) — retained configs lose their privileges automatically. - UI: the OBO option is hidden in the MCP server dialog for users without the permission; a CONFIGURE_OBO toggle is exposed in the MCP admin role editor. Existing role docs receive the new sub-key via the permission backfill in updateInterfacePermissions on next startup, preserving any operator-set values. YAML/Config-sourced server configs are unaffected since they're admin-controlled at the deployment level. * 🧊 fix: wire OBO machinery for servers with requiresOAuth: false The discovery and user-connection paths gated OAuth wiring (flow manager, token methods, oboTokenResolver, oboTrustChecker) behind isOAuthServer(), which only considers requiresOAuth/oauth fields. A DB-stored OBO server with requiresOAuth: false therefore landed in the non-OAuth branch, never received an oboTokenResolver, and the factory's usesObo getter evaluated to false — sending a bare request that the upstream rejected with invalid_token. Add requiresOAuthMachinery() (OAuth OR OBO) and use it at those two gates. isOAuthServer remains for the OAuth-handshake-only check (shouldInitiateOAuthBeforeConnect), where OBO must not initiate a handshake. Plumb the OBO resolver/trust-checker through ToolDiscoveryOptions so reinitMCPServer can pass them on the discovery path. * 🧊 fix: lock all OBO-target fields (URL, proxy, headers, auth) without CONFIGURE_OBO The CONFIGURE_OBO permission was meant to gate control of the endpoint that receives OBO-minted per-user delegated tokens and the scopes that are requested. The previous frontend lock + backend gate only covered obo.scopes and the auth section, leaving url/proxy/headers/etc. editable by anyone with UPDATE — meaning a non-permission user could still redirect an existing OBO server's token flow to an attacker endpoint. Switch to an allowlist policy: when editing an OBO server without CONFIGURE_OBO, only title/description/iconPath are mutable. Backend rejects any other field change with 403; frontend disables the non-allowlist sections (URL, transport, auth, trust) via fieldset. The comparison surface (MCP_USER_INPUT_FIELDS) is derived from MCPServerUserInputSchema's union members so it stays in sync with the schema. New schema fields land in the locked set by default — adding to the allowlist is the only way to unlock them, which preserves the security-review boundary. * 🧊 fix: skip unauthenticated MCP inspection for OBO-only servers MCPServerInspector.inspectServer() ran an unauthenticated temp connection unless the config had requiresOAuth or customUserVars set. For OBO-only servers without standard MCP OAuth advertisement, this caused MCPConnectionFactory.create to attempt the connection without a user or oboTokenResolver — failing on servers that reject the MCP initialize handshake without a valid bearer token, which surfaced as MCP_INSPECTION_FAILED on create/update. Add `obo` to the skip list alongside requiresOAuth and customUserVars, matching the existing pattern for user-scoped auth modes. * Addressed linting error: watchedTitle is declared but never referenced (the auto-fill logic at line 156 uses getValues('title') instead). Deleted constant.
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { logger } from '@librechat/data-schemas';
|
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
import type * as t from './types';
|
|
import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry';
|
|
import { ConnectionsRepository } from '~/mcp/ConnectionsRepository';
|
|
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
|
|
import { isUserSourced, requiresOAuthMachinery } from './utils';
|
|
import { MCPConnection } from './connection';
|
|
import { mcpConfig } from './mcpConfig';
|
|
|
|
/**
|
|
* Abstract base class for managing user-specific MCP connections with lifecycle management.
|
|
* Only meant to be extended by MCPManager.
|
|
* Much of the logic was move here from the old MCPManager to make it more manageable.
|
|
* User connections will soon be ephemeral and not cached anymore:
|
|
* https://github.com/danny-avila/LibreChat/discussions/8790
|
|
*/
|
|
export abstract class UserConnectionManager {
|
|
// Connections shared by all users.
|
|
public appConnections: ConnectionsRepository | null = null;
|
|
// Connections per userId -> serverName -> connection
|
|
protected userConnections: Map<string, Map<string, MCPConnection>> = new Map();
|
|
/** Last activity timestamp for users (not per server) */
|
|
protected userLastActivity: Map<string, number> = new Map();
|
|
/** In-flight connection promises keyed by `userId:serverName` — coalesces concurrent attempts */
|
|
protected pendingConnections: Map<string, Promise<MCPConnection>> = new Map();
|
|
|
|
/** Updates the last activity timestamp for a user */
|
|
protected updateUserLastActivity(userId: string): void {
|
|
const now = Date.now();
|
|
this.userLastActivity.set(userId, now);
|
|
logger.debug(
|
|
`[MCP][User: ${userId}] Updated last activity timestamp: ${new Date(now).toISOString()}`,
|
|
);
|
|
}
|
|
|
|
/** Gets or creates a connection for a specific user, coalescing concurrent attempts */
|
|
public async getUserConnection(opts: t.UserMCPConnectionOptions): Promise<MCPConnection> {
|
|
const { serverName, forceNew, user } = opts;
|
|
const userId = user?.id;
|
|
if (!userId) {
|
|
throw new McpError(ErrorCode.InvalidRequest, `[MCP] User object missing id property`);
|
|
}
|
|
|
|
const lockKey = `${userId}:${serverName}`;
|
|
|
|
if (!forceNew) {
|
|
const pending = this.pendingConnections.get(lockKey);
|
|
if (pending) {
|
|
logger.debug(`[MCP][User: ${userId}][${serverName}] Joining in-flight connection attempt`);
|
|
return pending;
|
|
}
|
|
}
|
|
|
|
const connectionPromise = this.createUserConnectionInternal(opts, userId);
|
|
|
|
if (!forceNew) {
|
|
this.pendingConnections.set(lockKey, connectionPromise);
|
|
}
|
|
|
|
try {
|
|
return await connectionPromise;
|
|
} finally {
|
|
if (!forceNew && this.pendingConnections.get(lockKey) === connectionPromise) {
|
|
this.pendingConnections.delete(lockKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async createUserConnectionInternal(
|
|
{
|
|
serverName,
|
|
forceNew,
|
|
user,
|
|
flowManager,
|
|
customUserVars,
|
|
requestBody,
|
|
tokenMethods,
|
|
oauthStart,
|
|
oauthEnd,
|
|
oboTokenResolver,
|
|
oboTrustChecker,
|
|
signal,
|
|
returnOnOAuth = false,
|
|
connectionTimeout,
|
|
serverConfig: providedConfig,
|
|
}: t.UserMCPConnectionOptions,
|
|
userId: string,
|
|
): Promise<MCPConnection> {
|
|
if (await this.appConnections!.has(serverName)) {
|
|
throw new McpError(
|
|
ErrorCode.InvalidRequest,
|
|
`[MCP][User: ${userId}] Trying to create user-specific connection for app-level server "${serverName}"`,
|
|
);
|
|
}
|
|
|
|
const config =
|
|
providedConfig ??
|
|
(await MCPServersRegistry.getInstance().getServerConfig(serverName, userId));
|
|
|
|
const userServerMap = this.userConnections.get(userId);
|
|
let connection = forceNew ? undefined : userServerMap?.get(serverName);
|
|
if (forceNew) {
|
|
MCPConnection.clearCooldown(serverName);
|
|
}
|
|
const now = Date.now();
|
|
|
|
// Check if user is idle
|
|
const lastActivity = this.userLastActivity.get(userId);
|
|
if (lastActivity && now - lastActivity > mcpConfig.USER_CONNECTION_IDLE_TIMEOUT) {
|
|
logger.info(`[MCP][User: ${userId}] User idle for too long. Disconnecting all connections.`);
|
|
// Disconnect all user connections
|
|
try {
|
|
await this.disconnectUserConnections(userId);
|
|
} catch (err) {
|
|
logger.error(`[MCP][User: ${userId}] Error disconnecting idle connections:`, err);
|
|
}
|
|
connection = undefined; // Force creation of a new connection
|
|
} else if (connection) {
|
|
if (!config || (config.updatedAt && connection.isStale(config.updatedAt))) {
|
|
if (config) {
|
|
logger.info(
|
|
`[MCP][User: ${userId}][${serverName}] Config was updated, disconnecting stale connection`,
|
|
);
|
|
}
|
|
await this.disconnectUserConnection(userId, serverName);
|
|
connection = undefined;
|
|
} else if (await connection.isConnected()) {
|
|
logger.debug(`[MCP][User: ${userId}][${serverName}] Reusing active connection`);
|
|
this.updateUserLastActivity(userId);
|
|
return connection;
|
|
} else {
|
|
// Connection exists but is not connected, attempt to remove potentially stale entry
|
|
logger.warn(
|
|
`[MCP][User: ${userId}][${serverName}] Found existing but disconnected connection object. Cleaning up.`,
|
|
);
|
|
this.removeUserConnection(userId, serverName); // Clean up maps
|
|
connection = undefined;
|
|
}
|
|
}
|
|
|
|
// Now check if config exists for new connection creation
|
|
if (!config) {
|
|
throw new McpError(
|
|
ErrorCode.InvalidRequest,
|
|
`[MCP][User: ${userId}] Configuration for server "${serverName}" not found.`,
|
|
);
|
|
}
|
|
|
|
// If no valid connection exists, create a new one
|
|
logger.info(`[MCP][User: ${userId}][${serverName}] Establishing new connection`);
|
|
|
|
try {
|
|
const registry = MCPServersRegistry.getInstance();
|
|
const basic: t.BasicConnectionOptions = {
|
|
serverConfig: config,
|
|
serverName: serverName,
|
|
dbSourced: isUserSourced(config),
|
|
useSSRFProtection: registry.shouldEnableSSRFProtection(),
|
|
allowedDomains: registry.getAllowedDomains(),
|
|
allowedAddresses: registry.getAllowedAddresses(),
|
|
};
|
|
|
|
const useOAuth = requiresOAuthMachinery(config);
|
|
let connectionOptions: t.OAuthConnectionOptions | t.UserConnectionContext;
|
|
if (useOAuth) {
|
|
if (!flowManager) {
|
|
throw new McpError(
|
|
ErrorCode.InvalidRequest,
|
|
`[MCP][User: ${userId}] OAuth server "${serverName}" requires a flowManager`,
|
|
);
|
|
}
|
|
|
|
connectionOptions = {
|
|
useOAuth: true,
|
|
user: user,
|
|
customUserVars: customUserVars,
|
|
flowManager: flowManager,
|
|
tokenMethods: tokenMethods,
|
|
signal: signal,
|
|
oauthStart: oauthStart,
|
|
oauthEnd: oauthEnd,
|
|
oboTokenResolver: oboTokenResolver,
|
|
oboTrustChecker: oboTrustChecker,
|
|
returnOnOAuth: returnOnOAuth,
|
|
requestBody: requestBody,
|
|
connectionTimeout: connectionTimeout,
|
|
};
|
|
} else {
|
|
connectionOptions = {
|
|
user,
|
|
customUserVars,
|
|
requestBody,
|
|
connectionTimeout,
|
|
};
|
|
}
|
|
|
|
connection = await MCPConnectionFactory.create(basic, connectionOptions);
|
|
|
|
if (!(await connection?.isConnected())) {
|
|
throw new Error('Failed to establish connection after initialization attempt.');
|
|
}
|
|
|
|
if (!this.userConnections.has(userId)) {
|
|
this.userConnections.set(userId, new Map());
|
|
}
|
|
this.userConnections.get(userId)?.set(serverName, connection);
|
|
|
|
logger.info(`[MCP][User: ${userId}][${serverName}] Connection successfully established`);
|
|
// Update timestamp on creation
|
|
this.updateUserLastActivity(userId);
|
|
return connection;
|
|
} catch (error) {
|
|
logger.error(`[MCP][User: ${userId}][${serverName}] Failed to establish connection`, error);
|
|
// Ensure partial connection state is cleaned up if initialization fails
|
|
await connection?.disconnect().catch((disconnectError) => {
|
|
logger.error(
|
|
`[MCP][User: ${userId}][${serverName}] Error during cleanup after failed connection`,
|
|
disconnectError,
|
|
);
|
|
});
|
|
// Ensure cleanup even if connection attempt fails
|
|
this.removeUserConnection(userId, serverName);
|
|
throw error; // Re-throw the error to the caller
|
|
}
|
|
}
|
|
|
|
/** Returns all connections for a specific user */
|
|
public getUserConnections(userId: string) {
|
|
return this.userConnections.get(userId);
|
|
}
|
|
|
|
/** Removes a specific user connection entry */
|
|
protected removeUserConnection(userId: string, serverName: string): void {
|
|
const userMap = this.userConnections.get(userId);
|
|
if (userMap) {
|
|
userMap.delete(serverName);
|
|
if (userMap.size === 0) {
|
|
this.userConnections.delete(userId);
|
|
// Only remove user activity timestamp if all connections are gone
|
|
this.userLastActivity.delete(userId);
|
|
}
|
|
}
|
|
|
|
logger.debug(`[MCP][User: ${userId}][${serverName}] Removed connection entry.`);
|
|
}
|
|
|
|
/** Disconnects and removes a specific user connection */
|
|
public async disconnectUserConnection(userId: string, serverName: string): Promise<void> {
|
|
this.pendingConnections.delete(`${userId}:${serverName}`);
|
|
const userMap = this.userConnections.get(userId);
|
|
const connection = userMap?.get(serverName);
|
|
if (connection) {
|
|
logger.info(`[MCP][User: ${userId}][${serverName}] Disconnecting...`);
|
|
await connection.disconnect();
|
|
this.removeUserConnection(userId, serverName);
|
|
}
|
|
}
|
|
|
|
/** Disconnects and removes all connections for a specific user */
|
|
public async disconnectUserConnections(userId: string): Promise<void> {
|
|
const userMap = this.userConnections.get(userId);
|
|
const disconnectPromises: Promise<void>[] = [];
|
|
if (userMap) {
|
|
logger.info(`[MCP][User: ${userId}] Disconnecting all servers...`);
|
|
const userServers = Array.from(userMap.keys());
|
|
for (const serverName of userServers) {
|
|
disconnectPromises.push(
|
|
this.disconnectUserConnection(userId, serverName).catch((error) => {
|
|
logger.error(
|
|
`[MCP][User: ${userId}][${serverName}] Error during disconnection:`,
|
|
error,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
await Promise.allSettled(disconnectPromises);
|
|
// Clean up any pending connection promises for this user
|
|
for (const key of this.pendingConnections.keys()) {
|
|
if (key.startsWith(`${userId}:`)) {
|
|
this.pendingConnections.delete(key);
|
|
}
|
|
}
|
|
logger.info(`[MCP][User: ${userId}] All connections processed for disconnection.`);
|
|
}
|
|
/**
|
|
* Always clear the activity timestamp, even when userMap was missing.
|
|
* `updateUserLastActivity` can be called before a connection is established
|
|
* (e.g. in MCPManager.callTool prior to getConnection); if that connection
|
|
* attempt fails, the activity entry would otherwise leak and trigger the
|
|
* idle check repeatedly for the same userId.
|
|
*/
|
|
this.userLastActivity.delete(userId);
|
|
}
|
|
|
|
/** Check for and disconnect idle connections */
|
|
protected checkIdleConnections(currentUserId?: string): void {
|
|
const now = Date.now();
|
|
|
|
// Iterate through all users to check for idle ones
|
|
for (const [userId, lastActivity] of this.userLastActivity.entries()) {
|
|
if (currentUserId && currentUserId === userId) {
|
|
continue;
|
|
}
|
|
if (now - lastActivity > mcpConfig.USER_CONNECTION_IDLE_TIMEOUT) {
|
|
logger.info(
|
|
`[MCP][User: ${userId}] User idle for too long. Disconnecting all connections...`,
|
|
);
|
|
// Disconnect all user connections asynchronously (fire and forget)
|
|
this.disconnectUserConnections(userId).catch((err) =>
|
|
logger.error(`[MCP][User: ${userId}] Error disconnecting idle connections:`, err),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Returns counts of tracked users and connections for diagnostics */
|
|
public getConnectionStats(): {
|
|
trackedUsers: number;
|
|
totalConnections: number;
|
|
activityEntries: number;
|
|
appConnectionCount: number;
|
|
} {
|
|
let totalConnections = 0;
|
|
for (const serverMap of this.userConnections.values()) {
|
|
totalConnections += serverMap.size;
|
|
}
|
|
return {
|
|
trackedUsers: this.userConnections.size,
|
|
totalConnections,
|
|
activityEntries: this.userLastActivity.size,
|
|
appConnectionCount: this.appConnections?.getConnectionCount() ?? 0,
|
|
};
|
|
}
|
|
}
|