LibreChat/api/server/services/Tools/mcp.js
adamscross04 4182f9094f
🃏 fix: Attach Request-Scoped MCP Servers From the Builder via the mcp_all Wildcard (#14177)
* fix: Attach Request-Scoped MCP Servers from the Agent Builder via mcp_all

Follow-up to #14148 / #14074: request-scoped MCP servers (runtime
{{LIBRECHAT_BODY_*}} placeholder headers) defer their connection on
reinitialize, so their tools are never enumerable in the agent builder
and the attach flow (which waits for isConnected && hasTools) silently
attaches nothing. The runtime already resolves an mcp_all
(sys__all__sys_mcp_<server>) tool entry into the server's full tool set
at chat-turn time - the builder just never writes that token.

- reinitMCPServer returns connectionDeferred: true on the deferred
  branch so clients can distinguish it from a plain empty success
  (server configs are sanitized client-side, so the response is the
  only reliable signal)
- /mcp/:serverName/reinitialize forwards the flag; data-provider
  mutation type includes it
- McpSection attaches [mcp_server, mcp_all] tokens on a deferred
  connect (idempotent) and shows a "tools are resolved at runtime"
  hint instead of "no tools yet" when wildcard-attached
- selectors: mcpAllToken() helper beside mcpServerToken()

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address review — deferred attach via init state; strip stale wildcard

Two review findings:

1. Servers with customUserVars route Connect through the config dialog,
   whose save path calls initializeServer inside the manager — the
   McpSection never awaits that response, so the deferred attach was
   unreachable. Record connectionDeferred in the shared per-server init
   state (MCPServerInitState) on every initialize attempt and key the
   attach off that state in the auto-select effect: one attach site now
   covers both the direct Connect and the config-dialog path.

2. updateFormTools kept an existing mcp_all wildcard when rewriting a
   per-tool selection, so a server that later exposes a normal tool list
   would still grant every tool at runtime while the UI showed a subset.
   The wildcard is now stripped unless explicitly re-passed, making
   per-tool selection always supersede it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: address review — stale deferred state; fold wildcard into display

Second review round:

1. connectionDeferred persisted across attempts, so a later Connect
   click could attach the wildcard from a stale flag before the new
   attempt reported. Reset it at the start of every initializeServer
   call, and clear it before routing into the customUserVars config
   dialog (resetConnectionDeferred) so only the current attempt's
   outcome can trigger the auto-attach effect.

2. With a wildcard attached and the server's tools later enumerable,
   the dialog showed every tool unchecked while runtime granted all of
   them. getSelectedTools now folds the wildcard into the display (all
   tools selected); any selection interaction rewrites the form with
   concrete ids and drops the wildcard, converting the attachment on
   first touch.

Also sorts imports in McpSection.tsx (CI sort-imports gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 08:10:01 -04:00

316 lines
11 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 { updateMCPServerTools } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
/**
* 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 ephemeralServer = false;
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`,
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`,
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`,
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 };
const oauthStart =
_oauthStart ??
(async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received for ${serverName}`);
oauthUrl = authURL;
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) {
tools = await connection.fetchTools();
}
if (tools && tools.length > 0) {
availableTools = await updateMCPServerTools({
userId: user.id,
serverName,
tools,
serverConfig,
});
}
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 result = {
availableTools,
success: Boolean(
(connection && !oauthRequired) ||
(oauthRequired && oauthUrl) ||
(tools && tools.length > 0),
),
message: getResponseMessage(),
oauthRequired,
serverName,
oauthUrl,
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.disconnect();
} catch (error) {
logger.warn(
`[MCP Reinitialize] Failed to disconnect ephemeral server ${serverName}`,
error,
);
}
}
}
}
module.exports = {
reinitMCPServer,
};