LibreChat/api/server/services/Tools/mcp.js
Danny Avila eb3b353712
📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision (#14858)
* 📡 fix: Publish App-Level MCP Tool Catalogs Without a Reserved Revision

Shared MCP servers advertised no tools to agents, so every turn failed with
"configured to use MCP tools, but none are available" (#14857).

`replaceAppServerTools` returned false whenever a publication carried no
`publicationRevision`, but only `refreshChangedTools` reserves one. Every other
app-level publisher — the first-connect snapshot, reinitialization, on-demand
catalog reads, the retained-catalog restore — was silently dropped. The agent
path fails closed on that drop: the skipped write returns null, so reinitialize
yields no tools and the turn 503s.

Startup hid it. `connectAppServers()` defers the initial refresh and calls
`refreshToolList()` itself, which does reserve, so a boot that reaches its MCP
servers looks healthy. Only a lazily created app connection — the server not yet
up when LibreChat boots, a dropped connection, a cold cache — takes the
unreserved path.

`ConnectionsRepository` now reserves before its own `tools/list`, matching the
list_changed path; a failed reservation publishes unordered rather than failing
the connection. Publishers with no pre-fetch reservation point have already
fetched by the time they reach the cache, so they take the next revision at write
time instead of being discarded. `mergeAppTools` still publishes at revision 0 and
stays deferential to a live catalog.

* 📡 fix: Bind App Catalog Ordering to the Fetch That Produced It

Addresses review feedback on the previous commit: allocating a revision at
publish time lets a slow `tools/list` of an old catalog outrank a newer one that
reserved after it started, and it would let the retained-catalog restore — which
republishes deliberately pre-mutation data — outrank a live catalog.

Ordering now travels with the data. `fetchToolsSnapshot` reserves before its
first page and returns the ticket on the snapshot, so every app-level publisher
reads the revision belonging to the read it is publishing rather than one
allocated at an unrelated moment. `fetchOrderedToolsSnapshot` carries the
refresh's revision when it defers to one, since that is whose catalog it returns.

With the reservation at the single point where app-level tools are read, no
publisher can forget it, so `replaceAppServerTools` goes back to refusing an
unordered write: a publication that lost its ticket fetched at an unknown time
and cannot be ordered.

A failed reservation is reported as `orderingUnavailable` rather than swallowed,
which keeps the list_changed path retrying instead of publishing a catalog that
would be silently dropped, and leaves inspection unaffected by a transient cache
outage.

`MCPServerInspector.getToolFunctions` becomes `getToolCatalog` and returns the
revision with the tools, so there is no variant that quietly discards ordering.

* 📡 fix: Retry an Empty App Catalog That Could Not Reserve Ordering

Review follow-up. The no-tools-capability branch destructured the reservation
result and dropped `orderingUnavailable`, publishing without a revision when the
revision store was transiently unavailable. That write is rejected in silence,
and unlike the snapshot branch this one returned without reaching
`refreshToolList()`, so whatever the server last advertised stayed in place until
the connection was recreated or the cache expired.

Both branches now route an unreservable catalog through the same retry path.

* 📡 fix: Serve Tools Whose Shared Catalog Write Could Not Be Ordered

Review follow-up. Only the shared catalog write needs ordering; the tools
themselves were just read from the server and are correct to serve. Discarding
them because the write could not be ordered is what turns a cache failure into a
server that appears to have no tools at all, which is the reported symptom.

`updateMCPServerTools` now returns the tools it built when the publication has no
reserved revision, instead of null. A superseded write still discards — there
another replica holds something newer.

Reinitialization also asks the connection to republish under backoff when its
snapshot could not reserve ordering, so the shared catalog does not stay cold
until something else triggers a refresh.

* 📡 fix: Surface a Discarded App Catalog Instead of Debug-Logging It

#14857 went a release without a diagnostic because the only trace of a dropped
app-level catalog was a debug line no deployment runs. Operators saw agents fail
every turn with nothing in the logs to explain it, and the reporter had to read
the source to find the cause.

A publication discarded because it cannot be addressed or ordered means this
server's tools are unavailable to every agent that selected them, and serving an
unpublished catalog means every request re-fetches it. Both are warnings now. A
superseded write stays at debug: concurrent replicas produce it routinely and the
winner already holds newer tools.

Tests pin the level, so a later refactor cannot quietly make the failure silent
again.

* 🧪 test: Pin the Reinitialize Path's Catalog Ordering

Reinitialization is the path an agent falls back to when the shared catalog is
cold, so it is where #14857 surfaced as "configured to use MCP tools, but none
are available". Nothing pinned that it forwards the ordering its snapshot was
fetched with, nor that it asks the connection to republish a catalog it could
not order.

Both assertions fail against the pre-fix source.
2026-08-15 12:48:23 -04:00

395 lines
14 KiB
JavaScript

const { logger } = require('@librechat/data-schemas');
const {
getMissingCustomUserVars,
requiresEphemeralUserConnection,
getMissingRuntimeBodyPlaceholderFields,
} = require('@librechat/api');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
const { exchangeOboToken } = require('~/server/services/OboTokenService');
const { createOboTrustChecker } = require('~/server/services/OboPolicyService');
const { 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',
};
/**
* 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 {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,
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 ${serverName} has inspectionFailed — 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 ${serverName} had failed inspection, attempting reinspection`,
);
try {
const storageLocation = serverConfig.source === 'user' ? 'DB' : 'CACHE';
await registry.reinspectServer(serverName, storageLocation, user?.id);
logger.info(`[MCP Reinitialize] Reinspection succeeded for server: ${serverName}`);
} catch (reinspectError) {
logger.error(
`[MCP Reinitialize] Reinspection failed for server ${serverName}:`,
reinspectError,
);
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 '${serverName}': required user-provided variable(s) not set: ${missingUserVars.join(
', ',
)}. Tools will not be exposed until the user configures them.`,
);
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] Server '${serverName}' requires request body field(s) [${missingBodyFields.join(
', ',
)}] for runtime placeholders; connection deferred to first use in a chat turn`,
);
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 for ${serverName}`);
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(),
});
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
} catch (err) {
logger.info(`[MCP Reinitialize] getConnection threw error: ${err.message}`);
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 for ${serverName}, 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(),
});
if (discoveryResult.tools && discoveryResult.tools.length > 0) {
tools = discoveryResult.tools;
logger.info(
`[MCP Reinitialize] Discovered ${tools.length} tools for ${serverName} without full auth`,
);
}
} catch (discoveryErr) {
logger.debug(
`[MCP Reinitialize] Tool discovery failed for ${serverName}: ${discoveryErr?.message ?? String(discoveryErr)}`,
);
}
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
err,
);
}
}
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 for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
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 for ${serverName}:`, {
success: result.success,
oauthRequired: result.oauthRequired,
oauthUrl: result.oauthUrl ? 'present' : null,
toolsCount: tools?.length ?? 0,
});
return result;
} catch (error) {
logger.error(
'[MCP Reinitialize] Error loading MCP Tools, servers may still be initializing:',
error,
);
} finally {
if (connection && ephemeralServer && !requestScopedConnections) {
try {
await connection.dispose();
} catch (error) {
logger.warn(`[MCP Reinitialize] Failed to dispose ephemeral server ${serverName}`, error);
}
}
}
}
module.exports = {
reinitMCPServer,
};