mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-06 14:39:10 +00:00
* fix: recover missing MCP marketplace catalogs * fix: make MCP catalog recovery passive * test: type MCP catalog recovery fixtures * fix: bound and back off passive MCP catalog recovery Passive recovery runs inline on `GET /api/mcp/tools` and its results are request-local by design, so every list request re-dialed the same cold servers with the default connection timeout. Three limits keep that cost proportional to what recovery can actually recover: - Cap the discovery timeout at 5s instead of inheriting the connection default (`initTimeout ?? 30s`); a server configured to connect faster keeps its own shorter limit. - Skip a server the config tier already marked `inspectionFailed`, leaving it to that tier's retry window rather than re-dialing it per request. - Skip a server whose declared `customUserVars` are unset, matching the gate `reinitMCPServer` applies for issue #10969 — connecting without them fails auth, so the attempt is spent for nothing. Servers that still fail discovery enter a one-minute per-process cooldown, which is what stops an unreachable server from being re-dialed by every subsequent list request. A server that recovers clears its own entry, and expired entries are swept at most once per window so the map stays bounded. Skipped servers render exactly as they did before recovery existed: present in the catalog with an empty tool list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * fix: bound passive MCP recovery by deadline, key cooldowns by config Both follow-ups address the same mistake: recovery expressed its own request-level constraints in terms borrowed from other layers. `connectionTimeout` bounds one connection attempt, and `MCPConnectionFactory.discoverToolsInternal` spends it twice — once on the authenticated connection, then again in `attemptUnauthenticatedToolListing` — so capping it bounded no total this layer could reason about. Recovery now enforces its own wall-clock deadline per server with `withTimeout`, which holds however many attempts the factory makes; `connectionTimeout` is left to do only its own job, still honouring a shorter operator `initTimeout`. An attempt abandoned by the deadline disposes its own connection when it settles, and `Promise.race` keeps a handler on it, so a late rejection is not unhandled. A per-request budget now caps total recovery regardless of server count. A server is dialed only if the remaining budget can fund a full deadline; never dialing one is not evidence against it, so a skipped server records no cooldown and a later request reaches it once those ahead are cached or cooling down. Cooldown identity now includes the publication generation — the same effective-config identity the tool caches fence on — instead of just user and server name. Correcting a server's URL or transport keys a new entry, so the refetch the client issues on update is no longer skipped for up to a minute by the previous configuration's failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * refactor: keep passive MCP recovery stateless and bounded by its own work Reverts the cooldown, request budget and deadline race added in257d5cfandfc3e3c9, and keeps only the three stateless limits. The tool cache refuses unfenced writes (`tools.ts`), and a discovery connection owns no publication generation and is disposed, so a recovered catalog cannot be retained by design. Those commits responded by building a cache-shaped memory in front of it — per-process failure state, a scheduling budget, an identity, an eviction sweep — and each round of review found another way that hand-rolled cache differed from a real one: wrong identity for configuration, wrong identity for credentials, no fairness across requests, and a limiter slot released while its network operation was still running. None of that machinery was asked for; all of it was compensation for a result the architecture does not allow keeping. Recovery is now stateless. It skips only what configuration alone proves pointless — a server the config tier already marked `inspectionFailed`, and one whose declared `customUserVars` are unset — and bounds the work itself rather than racing it, so a limiter slot is held for exactly as long as its network operation runs and the concurrency limit of three is real. The attempt timeout is not a compromise: recovery exists for a server that is reachable and authorized but whose catalog cache expired, and such a server answers tools/list well inside 1.5s. Anything slower cannot be rescued here, so failing fast costs nothing. The factory spends that value per attempt, so a server's ceiling is it times the attempts made; the constant documents that rather than hiding it behind a number tuned to today's attempt count. Consequences that were bugs are now gone by construction: every cold server is attempted on every request, so none is starved by those ahead of it, and correcting a server's configuration or credentials takes effect on the next refetch instead of waiting out a stale cooldown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * fix: correct the inspection-failure skip and bound catalog fan-out Three fixes that belong to this layer; a fourth issue does not, and is described below. The `inspectionFailed` skip was too broad. `MCPServersInitializer` stores a YAML server that was unreachable at startup via `addServerStub`, which stamps `source: 'yaml'`, and only config-tier entries get the timed retry in `ensureSingleConfigServer`. Skipping every failed stub therefore hid a recoverable server from the marketplace permanently — the exact state this recovery exists to escape. It now defers only `source === 'config'`, matching what `reinitMCPServer` already does. Plugin auth is read only when some cold server actually declares `customUserVars`, and only for those servers. The common unauthenticated case no longer pays a MongoDB round trip whose result nothing can consume. Snapshot refreshes are now bounded by the same limiter as discovery. They are not local reads: both connection paths reach `fetchOrderedToolsSnapshot` and issue a real `tools/list`, so a cache reset across many servers previously burst unbounded outbound requests while discovery was capped at three. Not fixed here, because it cannot be: `connectionTimeout` does not bound discovery. It covers `connection.connect()` only, and `fetchToolsSnapshot` then applies its own `TOOLS_LIST_TIMEOUT_MS` (30s) to `tools/list`, so a server that connects fast and stalls while listing still holds its slot for that window. The factory also does not cancel a timed-out connect before starting the unauthenticated fallback. Bounding this end to end needs a deadline threaded through `MCPConnectionFactory` into both `connect()` and `fetchToolsSnapshot()`, which is a change to shared connection machinery rather than to this caller. The constant's comment now states what it does and does not bound instead of implying an end-to-end guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B * fix: thread live-session OBO context into passive catalog discovery The merge of #15334 sources OBO tokens from the live OpenID session via request-boundary closures. Passive catalog recovery is a discovery call site too; without these options an OBO server whose stored token went stale fails recovery — the exact cold-catalog class this PR fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xr1Dabvdn1mzzyYgpJgU5B --------- Co-authored-by: Claude <noreply@anthropic.com>
441 lines
16 KiB
JavaScript
441 lines
16 KiB
JavaScript
const { logger } = require('@librechat/data-schemas');
|
|
const {
|
|
formatMCPServerTools,
|
|
getUserMCPAuthMap,
|
|
getMissingCustomUserVars,
|
|
loadMCPServerCatalogs: loadCatalogs,
|
|
requiresEphemeralUserConnection,
|
|
getMissingRuntimeBodyPlaceholderFields,
|
|
} = require('@librechat/api');
|
|
const { CacheKeys, Constants } = require('librechat-data-provider');
|
|
const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config');
|
|
const {
|
|
findToken,
|
|
createToken,
|
|
updateToken,
|
|
deleteTokens,
|
|
findPluginAuthsByKeys,
|
|
} = require('~/models');
|
|
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
|
const { exchangeOboToken } = require('~/server/services/OboTokenService');
|
|
const { createOboTrustChecker } = require('~/server/services/OboPolicyService');
|
|
const {
|
|
getMCPServerTools,
|
|
cacheMCPServerTools,
|
|
getMCPToolsCacheGeneration,
|
|
updateMCPServerTools,
|
|
} = require('~/server/services/Config');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
const MCP_REINITIALIZE_FAILURE_REASONS = {
|
|
UNREACHABLE: 'unreachable',
|
|
MISSING_CUSTOM_USER_VARS: 'missing_custom_user_vars',
|
|
OAUTH_REQUIRED: 'oauth_required',
|
|
INITIALIZATION_FAILED: 'initialization_failed',
|
|
};
|
|
|
|
/** Wires application dependencies into the passive, request-local catalog recovery service.
|
|
* @param {Object} params
|
|
* @param {IUser} params.user
|
|
* @param {Array<{ serverName: string, serverConfig: object }>} params.servers
|
|
* @param {import('@librechat/api').UpstreamTokenProvider} [params.upstreamTokenProvider] - Live upstream-token closure for OBO discovery, built at the request boundary so this layer never receives the raw Express request.
|
|
* @param {import('@librechat/api').AuthIdentityContext} [params.oboIdentityContext] - Non-template-visible OBO identity context built from the real request user.
|
|
*/
|
|
async function loadMCPServerCatalogs({ user, servers, upstreamTokenProvider, oboIdentityContext }) {
|
|
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
|
const tokenMethods = { findToken, updateToken, createToken, deleteTokens };
|
|
const mcpManager = getMCPManager();
|
|
return loadCatalogs(
|
|
{ user, servers },
|
|
{
|
|
loadUserMCPAuthMap: (userId, serverNames) =>
|
|
getUserMCPAuthMap({
|
|
userId,
|
|
servers: serverNames,
|
|
findPluginAuthsByKeys,
|
|
}),
|
|
discoverServerTools: (options) =>
|
|
mcpManager.discoverServerTools({
|
|
...options,
|
|
flowManager,
|
|
tokenMethods,
|
|
graphTokenResolver: getGraphApiToken,
|
|
oboTokenResolver: exchangeOboToken,
|
|
oboTrustChecker: createOboTrustChecker(),
|
|
upstreamTokenProvider,
|
|
oboIdentityContext,
|
|
}),
|
|
formatServerTools: formatMCPServerTools,
|
|
getCachedServerTools: getMCPServerTools,
|
|
getServerToolFunctionsSnapshot: (userId, serverName, serverConfig) =>
|
|
mcpManager.getServerToolFunctionsSnapshot(userId, serverName, serverConfig),
|
|
cacheServerTools: cacheMCPServerTools,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reinitializes an MCP server connection and discovers available tools.
|
|
* When OAuth is required, uses discovery mode to list tools without full authentication
|
|
* (per MCP spec, tool listing should be possible without auth).
|
|
* @param {Object} params
|
|
* @param {IUser} params.user - The user from the request object.
|
|
* @param {import('@librechat/api').UpstreamTokenProvider} [params.upstreamTokenProvider] - Live upstream-token closure for OBO connection establishment, built at the request boundary so this layer never receives the raw Express request.
|
|
* @param {import('@librechat/api').AuthIdentityContext} [params.oboIdentityContext] - Non-template-visible OBO identity context built from the real request user.
|
|
* @param {string} params.serverName - The name of the MCP server
|
|
* @param {boolean} params.returnOnOAuth - Whether to initiate OAuth and return, or wait for OAuth flow to finish
|
|
* @param {AbortSignal} [params.signal] - The abort signal to handle cancellation.
|
|
* @param {boolean} [params.forceNew]
|
|
* @param {number} [params.connectionTimeout]
|
|
* @param {FlowStateManager<any>} [params.flowManager]
|
|
* @param {(authURL: string, options?: { expiresAt?: number }) => Promise<void>} [params.oauthStart]
|
|
* @param {() => Promise<void>} [params.oauthEnd]
|
|
* @param {import('@librechat/api').RequestBody} [params.requestBody]
|
|
* @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections]
|
|
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
|
|
*/
|
|
async function reinitMCPServer({
|
|
user,
|
|
signal,
|
|
forceNew,
|
|
serverName,
|
|
configServers,
|
|
userMCPAuthMap,
|
|
connectionTimeout,
|
|
returnOnOAuth = true,
|
|
oauthStart: _oauthStart,
|
|
flowManager: _flowManager,
|
|
serverConfig: providedConfig,
|
|
requestBody,
|
|
requestScopedConnections,
|
|
upstreamTokenProvider,
|
|
oboIdentityContext,
|
|
oauthEnd,
|
|
}) {
|
|
/** @type {MCPConnection | null} */
|
|
let connection = null;
|
|
let serverConfig = providedConfig;
|
|
/** @type {LCAvailableTools | null} */
|
|
let availableTools = null;
|
|
/** @type {ReturnType<MCPConnection['fetchTools']> | null} */
|
|
let tools = null;
|
|
let oauthRequired = false;
|
|
let oauthUrl = null;
|
|
let oauthExpiresAt;
|
|
let ephemeralServer = false;
|
|
let publicationGeneration;
|
|
let publicationRevision;
|
|
|
|
try {
|
|
const registry = getMCPServersRegistry();
|
|
serverConfig =
|
|
serverConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers));
|
|
ephemeralServer = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false;
|
|
if (serverConfig?.inspectionFailed) {
|
|
if (serverConfig.source === 'config') {
|
|
logger.info(
|
|
'[MCP Reinitialize] Config-source server inspection failed; retry handled by config cache',
|
|
);
|
|
return {
|
|
availableTools: null,
|
|
success: false,
|
|
message: `MCP server '${serverName}' is still unreachable`,
|
|
failureReason: MCP_REINITIALIZE_FAILURE_REASONS.UNREACHABLE,
|
|
oauthRequired: false,
|
|
serverName,
|
|
oauthUrl: null,
|
|
tools: null,
|
|
};
|
|
} else {
|
|
logger.info('[MCP Reinitialize] Server inspection failed; attempting reinspection');
|
|
try {
|
|
const storageLocation = serverConfig.source === 'user' ? 'DB' : 'CACHE';
|
|
await registry.reinspectServer(serverName, storageLocation, user?.id);
|
|
logger.info('[MCP Reinitialize] Server reinspection succeeded');
|
|
} catch {
|
|
logger.error('[MCP Reinitialize] Server reinspection failed');
|
|
return {
|
|
availableTools: null,
|
|
success: false,
|
|
message: `MCP server '${serverName}' is still unreachable`,
|
|
failureReason: MCP_REINITIALIZE_FAILURE_REASONS.UNREACHABLE,
|
|
oauthRequired: false,
|
|
serverName,
|
|
oauthUrl: null,
|
|
tools: null,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
|
|
|
const missingUserVars = getMissingCustomUserVars(serverConfig ?? {}, customUserVars);
|
|
if (missingUserVars.length > 0) {
|
|
logger.warn('[MCP Reinitialize] Skipping server with missing user configuration', {
|
|
missingVariableCount: missingUserVars.length,
|
|
});
|
|
return {
|
|
availableTools: null,
|
|
success: false,
|
|
message: `MCP server '${serverName}' requires user-provided variable(s) [${missingUserVars.join(
|
|
', ',
|
|
)}] which are not set`,
|
|
failureReason: MCP_REINITIALIZE_FAILURE_REASONS.MISSING_CUSTOM_USER_VARS,
|
|
missingUserVars,
|
|
oauthRequired: false,
|
|
serverName,
|
|
oauthUrl: null,
|
|
tools: null,
|
|
};
|
|
}
|
|
|
|
/** `{{LIBRECHAT_BODY_*}}` placeholders only resolve during a chat turn; connecting
|
|
* without them would fail, so defer the connection instead of reporting a failure. */
|
|
const missingBodyFields = serverConfig
|
|
? getMissingRuntimeBodyPlaceholderFields(serverConfig, requestBody)
|
|
: [];
|
|
if (missingBodyFields.length > 0) {
|
|
logger.info(
|
|
'[MCP Reinitialize] Runtime placeholders unresolved; connection deferred to first use',
|
|
{ missingBodyFieldCount: missingBodyFields.length },
|
|
);
|
|
return {
|
|
availableTools: null,
|
|
success: true,
|
|
/** Lets clients distinguish "connection deferred to a chat turn" from a
|
|
* plain success with no tools, e.g. to attach the server at the server
|
|
* level instead of waiting for a tool list that never arrives. */
|
|
connectionDeferred: true,
|
|
message: `MCP server '${serverName}' uses request-scoped placeholders; connection will be established on first use in a chat turn`,
|
|
oauthRequired: false,
|
|
serverName,
|
|
oauthUrl: null,
|
|
tools: null,
|
|
};
|
|
}
|
|
|
|
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
|
const mcpManager = getMCPManager();
|
|
const tokenMethods = { findToken, updateToken, createToken, deleteTokens };
|
|
|
|
if (!ephemeralServer) {
|
|
publicationGeneration = await getMCPToolsCacheGeneration({
|
|
userId: user.id,
|
|
serverName,
|
|
});
|
|
}
|
|
|
|
const oauthStart =
|
|
_oauthStart ??
|
|
(async (authURL, options) => {
|
|
logger.info('[MCP Reinitialize] OAuth URL received');
|
|
if (authURL !== oauthUrl) {
|
|
oauthExpiresAt = undefined;
|
|
}
|
|
oauthUrl = authURL;
|
|
if (typeof options?.expiresAt === 'number' && Number.isFinite(options.expiresAt)) {
|
|
oauthExpiresAt = options.expiresAt;
|
|
}
|
|
oauthRequired = true;
|
|
});
|
|
|
|
try {
|
|
connection = await mcpManager.getConnection({
|
|
user,
|
|
signal,
|
|
forceNew,
|
|
oauthStart,
|
|
serverName,
|
|
flowManager,
|
|
tokenMethods,
|
|
returnOnOAuth,
|
|
oauthEnd,
|
|
customUserVars,
|
|
requestBody,
|
|
requestScopedConnections,
|
|
connectionTimeout,
|
|
serverConfig,
|
|
graphTokenResolver: getGraphApiToken,
|
|
oboTokenResolver: exchangeOboToken,
|
|
oboTrustChecker: createOboTrustChecker(),
|
|
upstreamTokenProvider,
|
|
oboIdentityContext,
|
|
});
|
|
|
|
logger.info('[MCP Reinitialize] Successfully established connection');
|
|
} catch (err) {
|
|
logger.info('[MCP Reinitialize] Connection attempt failed');
|
|
logger.info(
|
|
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
|
);
|
|
|
|
const isOAuthError =
|
|
err.message?.includes('OAuth') ||
|
|
err.message?.includes('authentication') ||
|
|
err.message?.includes('401');
|
|
|
|
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
|
|
|
|
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
|
|
logger.info('[MCP Reinitialize] OAuth required; attempting tool discovery without auth');
|
|
oauthRequired = true;
|
|
|
|
try {
|
|
const discoveryResult = await mcpManager.discoverServerTools({
|
|
user,
|
|
signal,
|
|
serverName,
|
|
flowManager,
|
|
tokenMethods,
|
|
oauthStart,
|
|
customUserVars,
|
|
requestBody,
|
|
connectionTimeout,
|
|
configServers,
|
|
graphTokenResolver: getGraphApiToken,
|
|
oboTokenResolver: exchangeOboToken,
|
|
oboTrustChecker: createOboTrustChecker(),
|
|
upstreamTokenProvider,
|
|
oboIdentityContext,
|
|
});
|
|
|
|
if (discoveryResult.tools && discoveryResult.tools.length > 0) {
|
|
tools = discoveryResult.tools;
|
|
logger.info(
|
|
`[MCP Reinitialize] Discovered ${tools.length} tools without full authentication`,
|
|
);
|
|
}
|
|
} catch {
|
|
logger.debug('[MCP Reinitialize] Tool discovery failed');
|
|
}
|
|
} else {
|
|
logger.error('[MCP Reinitialize] Error initializing MCP server');
|
|
}
|
|
}
|
|
|
|
if (connection && !oauthRequired) {
|
|
publicationGeneration =
|
|
mcpManager.getToolPublicationGeneration(connection) ?? publicationGeneration;
|
|
let snapshot;
|
|
if (typeof connection.fetchOrderedToolsSnapshot === 'function') {
|
|
snapshot = await connection.fetchOrderedToolsSnapshot();
|
|
} else if (typeof connection.fetchToolsSnapshot === 'function') {
|
|
snapshot = await connection.fetchToolsSnapshot();
|
|
} else {
|
|
snapshot = { tools: await connection.fetchTools(), complete: true };
|
|
}
|
|
if (snapshot.complete) {
|
|
tools = snapshot.tools;
|
|
/** Reserved before this snapshot's tools/list; an app-level catalog cannot publish
|
|
* without it, and allocating a later one here would outrank fresher tools. */
|
|
publicationRevision = snapshot.publicationRevision;
|
|
if (snapshot.orderingUnavailable && typeof connection.refreshToolList === 'function') {
|
|
/** These tools still serve this request; the connection republishes the shared
|
|
* catalog under backoff rather than leaving it cold until the next reinitialize. */
|
|
connection
|
|
.refreshToolList()
|
|
.catch((err) =>
|
|
logger.debug(
|
|
`[MCP Reinitialize] Could not schedule a catalog republish for ${serverName}: ${err?.message ?? String(err)}`,
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
logger.warn(
|
|
`[MCP Reinitialize] Preserving cached tools for ${serverName} because tools/list returned an incomplete snapshot`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tools && !ephemeralServer && publicationGeneration) {
|
|
const currentGeneration = await getMCPToolsCacheGeneration({
|
|
userId: user.id,
|
|
serverName,
|
|
});
|
|
if (currentGeneration !== publicationGeneration) {
|
|
logger.warn(
|
|
`[MCP Reinitialize] Discarding stale tools for ${serverName} because its publication generation changed during discovery`,
|
|
);
|
|
tools = null;
|
|
}
|
|
}
|
|
|
|
if (tools) {
|
|
availableTools = await updateMCPServerTools({
|
|
userId: user.id,
|
|
serverName,
|
|
tools,
|
|
serverConfig,
|
|
...(publicationGeneration && { publicationGeneration }),
|
|
...(publicationRevision && { publicationRevision }),
|
|
});
|
|
if (availableTools == null) {
|
|
tools = null;
|
|
}
|
|
}
|
|
|
|
logger.debug('[MCP Reinitialize] Sending response', {
|
|
oauthRequired,
|
|
hasOauthUrl: Boolean(oauthUrl),
|
|
});
|
|
|
|
const getResponseMessage = () => {
|
|
if (oauthRequired && tools && tools.length > 0) {
|
|
return `MCP server '${serverName}' tools discovered, OAuth required for execution`;
|
|
}
|
|
if (oauthRequired) {
|
|
return `MCP server '${serverName}' ready for OAuth authentication`;
|
|
}
|
|
if (connection) {
|
|
return `MCP server '${serverName}' reinitialized successfully`;
|
|
}
|
|
return `Failed to reinitialize MCP server '${serverName}'`;
|
|
};
|
|
|
|
const success = Boolean(
|
|
(connection && !oauthRequired) || (oauthRequired && oauthUrl) || (tools && tools.length > 0),
|
|
);
|
|
let failureReason;
|
|
if (!success) {
|
|
failureReason = oauthRequired
|
|
? MCP_REINITIALIZE_FAILURE_REASONS.OAUTH_REQUIRED
|
|
: MCP_REINITIALIZE_FAILURE_REASONS.INITIALIZATION_FAILED;
|
|
}
|
|
const result = {
|
|
availableTools,
|
|
success,
|
|
message: getResponseMessage(),
|
|
failureReason,
|
|
oauthRequired,
|
|
serverName,
|
|
oauthUrl,
|
|
oauthExpiresAt,
|
|
tools,
|
|
};
|
|
|
|
logger.debug('[MCP Reinitialize] Response ready', {
|
|
success: result.success,
|
|
oauthRequired: result.oauthRequired,
|
|
hasOauthUrl: Boolean(result.oauthUrl),
|
|
toolsCount: tools?.length ?? 0,
|
|
});
|
|
|
|
return result;
|
|
} catch {
|
|
logger.error('[MCP Reinitialize] Error loading MCP tools; servers may still be initializing');
|
|
} finally {
|
|
if (connection && ephemeralServer && !requestScopedConnections) {
|
|
try {
|
|
await connection.dispose();
|
|
} catch {
|
|
logger.warn('[MCP Reinitialize] Failed to dispose ephemeral server');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
reinitMCPServer,
|
|
loadMCPServerCatalogs,
|
|
};
|