LibreChat/api/server/routes/mcp.js
Danny Avila 49f4b659f6
🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart (#13814)
* 🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart

MCPServersRegistry was built once at boot from getAppConfig({ baseOnly:
true }), freezing allowedDomains/allowedAddresses to YAML. Admin-panel
mcpSettings overrides were ignored by both inspection (addServer/
reinspectServer/updateServer/lazyInitConfigServer) and runtime connection
enforcement (assertResolvedRuntimeConfigAllowed), so a domain allowed only
via the panel failed inspection and never connected.

Make the registry's effective allowlists mutable and refresh them from the
merged admin-panel config: seed at boot, and re-apply on every config
mutation via invalidateConfigCaches -> clearMcpConfigCache. Both inspection
and connection paths read the same getters, so both honor overrides without
a restart. Fail-safe: current allowlists are preserved when the merged read
fails.

* 🛡️ fix: Scope MCP allowlist refresh to global config, fail-safe on DB error

Address Codex P1 review findings on the allowlist-refresh path:

- Tenant-scoped config mutations no longer push one tenant's merged
  mcpSettings into the process-wide registry singleton (read by all MCP
  connection paths), which would leak allowlists across tenants. Only
  global (non-tenant) mutations refresh the registry; tenant mutations
  still evict the config-server cache.
- The refresh read now uses strictOverrides:true so a transient DB error
  throws instead of silently returning YAML base config — preserving the
  last-known allowlists rather than overwriting them with fallback values.
  Adds the strictOverrides option to getAppConfig (default off, no behavior
  change for existing callers).

* ♻️ refactor: Resolve MCP allowlists per-request (tenant-scoped) instead of a global singleton

Supersedes the prior global-mutation approach. MCP allowlists live in
mcpSettings, which is tenant/principal-scoped admin config, so a process-wide
singleton value is the wrong model — it caused cross-tenant bleed and stale
reads.

Instead, inject a resolver (from the app layer, where the merged config lives)
that the registry calls per inspection and per connection. It reads the ALS
tenant context via getAppConfig and accepts the acting user so user/role-scoped
overrides resolve; config-source inspection (no user) resolves at tenant scope.
Falls back to the YAML base allowlists when no resolver is set or the lookup
fails, so a transient error fails to the operator baseline rather than
disabling the allowlist.

Removes the now-unnecessary setAllowlists / boot-seed / invalidateConfigCaches
refresh / getAppConfig.strictOverrides machinery.

* 🔒 fix: Scope config-source cache by allowlist; resolve OAuth allowlists per-request

Address Codex review of the per-request resolver:

- Config-source cache key now folds in the resolved allowlists, not just the
  raw-config hash. Inspection results became allowlist-dependent, so without
  this a tenant whose allowlist rejects a URL could poison the shared key with
  an inspectionFailed stub for a tenant that allows it (and vice versa). The
  tenant-scoped allowlist is resolved once per ensureConfigServers pass and
  threaded through the cache key + inspection.
- The two remaining request-time OAuth allowlist reads now use the merged
  config instead of the YAML base getters: the fallback OAuth-initiate path
  (routes/mcp.js) via resolveAllowlists, and OAuth revocation
  (UserController.maybeUninstallOAuthMCP) via the request's already-merged
  appConfig.mcpSettings. Without this, an OAuth endpoint allowed only by an
  admin-panel override was rejected while inspection/connection allowed it.

*  test: Update MCP OAuth registry/config mocks for per-request allowlists

CI fix for the Finding-12 change. The OAuth-initiate route now calls
registry.resolveAllowlists() and the revocation path reads the merged
appConfig.mcpSettings, so the affected specs' mocks were asserting the old
base-getter values:
- routes/__tests__/mcp.spec.js: add resolveAllowlists to the registry mock.
- UserController.mcpOAuth.spec.js: provide mcpSettings on the getAppConfig
  mock so revokeOAuthToken still receives the expected allowlists.

* 🧪 test: e2e proof that admin-panel MCP allowlist override takes effect

Adds a Playwright mock-harness spec for #13809. A URL-based MCP fixture
(e2e-http, streamable-http SDK server) boots inspectionFailed because its
origin is omitted from the YAML mcpSettings.allowedDomains; the spec adds that
origin via an admin config override (PUT /api/admin/config/user/:id) and
asserts the server reinitializes — exercising the real resolver path through
the backend + DB. Before the fix, reinspection used the frozen YAML allowlist
and the server stayed unreachable.

- e2e/setup/fake-mcp-http-server.js: streamable-HTTP MCP fixture (health GET /).
- e2e/playwright.config.mock.ts: boot the fixture as a second webServer.
- e2e/config/librechat.e2e.yaml: mcpSettings.allowedDomains (excludes 127.0.0.1)
  + the e2e-http server.
- e2e/specs/mock/mcp-allowlist-override.spec.ts: login → baseline reinit fails →
  apply override → reinit succeeds.
2026-06-17 20:14:53 -04:00

981 lines
32 KiB
JavaScript

const { Router } = require('express');
const { logger, getTenantId, tenantStorage } = require('@librechat/data-schemas');
const {
CacheKeys,
Constants,
PermissionBits,
PermissionTypes,
Permissions,
} = require('librechat-data-provider');
const {
getBasePath,
createSafeUser,
MCPOAuthHandler,
MCPTokenStorage,
setOAuthSession,
PENDING_STALE_MS,
mcpConfig: mcpSettings,
getUserMCPAuthMap,
validateOAuthCsrf,
OAUTH_CSRF_COOKIE,
setOAuthCsrfCookie,
generateCheckAccess,
validateOAuthSession,
OAUTH_SESSION_COOKIE,
} = require('@librechat/api');
const {
createMCPServerController,
updateMCPServerController,
deleteMCPServerController,
getMCPServersList,
getMCPServerById,
getMCPTools,
} = require('~/server/controllers/mcp');
const {
getOAuthReconnectionManager,
getMCPServersRegistry,
getFlowStateManager,
getMCPManager,
} = require('~/config');
const {
getServerConnectionStatus,
resolveAllMcpConfigs,
resolveConfigServers,
getMCPSetupData,
} = require('~/server/services/MCP');
const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { getLogStores } = require('~/cache');
const db = require('~/models');
const router = Router();
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
const getOAuthFlowId = (userId, serverName) =>
MCPOAuthHandler.generateFlowId(userId, serverName, getTenantId());
const canAccessOAuthFlow = (flowId, userId) => {
const parsed = MCPOAuthHandler.parseFlowId(flowId);
if (!parsed) {
return false;
}
if (parsed.tenantId && parsed.tenantId !== getTenantId()) {
return false;
}
return parsed.userId === userId || parsed.userId === 'system';
};
const clearGetTokensFlow = async ({ flowManager, flowId, tokens }) => {
const state = await flowManager.getFlowState(flowId, 'mcp_get_tokens');
if (state?.type === 'mcp_get_tokens' && state.status === 'PENDING') {
await flowManager.completeFlow(flowId, 'mcp_get_tokens', tokens);
return;
}
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
};
const checkMCPUsePermissions = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE],
getRoleByName: db.getRoleByName,
});
const checkMCPCreate = generateCheckAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName: db.getRoleByName,
});
/**
* Get all MCP tools available to the user
* Returns only MCP tools, completely decoupled from regular LibreChat tools
*/
router.get('/tools', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
return getMCPTools(req, res);
});
/**
* Initiate OAuth flow
* This endpoint is called when the user clicks the auth link in the UI
*/
router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async (req, res) => {
try {
const { serverName } = req.params;
const { userId, flowId } = req.query;
const user = req.user;
// Verify the userId matches the authenticated user
if (typeof userId !== 'string' || userId !== user.id) {
return res.status(403).json({ error: 'User mismatch' });
}
const expectedFlowId = getOAuthFlowId(user.id, serverName);
if (typeof flowId !== 'string' || flowId !== expectedFlowId) {
logger.error('[MCP OAuth] Invalid flow ID for initiate request', {
serverName,
userId,
flowId,
expectedFlowId,
});
return res.status(403).json({ error: 'Flow mismatch' });
}
logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
/** Flow state to retrieve OAuth config */
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found', { flowId });
return res.status(404).json({ error: 'Flow not found' });
}
const {
authorizationUrl: storedAuthorizationUrl,
serverName: flowServerName,
userId: flowUserId,
serverUrl,
oauth: oauthConfig,
} = flowState.metadata || {};
if (flowUserId && flowUserId !== user.id) {
logger.error('[MCP OAuth] Flow user mismatch', { flowId, userId, flowUserId });
return res.status(403).json({ error: 'User mismatch' });
}
if (flowServerName && flowServerName !== serverName) {
logger.error('[MCP OAuth] Flow server mismatch', { flowId, serverName, flowServerName });
return res.status(400).json({ error: 'Invalid flow state' });
}
const pendingAge = flowState.createdAt ? Date.now() - flowState.createdAt : Infinity;
const isFreshPendingFlow = flowState.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
if (!isFreshPendingFlow) {
logger.error('[MCP OAuth] Flow is not active for initiation', {
flowId,
status: flowState.status,
pendingAge,
});
return res.status(400).json({ error: 'Invalid flow state' });
}
if (typeof storedAuthorizationUrl === 'string' && storedAuthorizationUrl.length > 0) {
logger.debug('[MCP OAuth] Reusing stored authorization URL', {
serverName,
userId,
flowId,
});
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
return res.redirect(storedAuthorizationUrl);
}
if (!serverUrl || !oauthConfig) {
logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
return res.status(400).json({ error: 'Invalid flow state' });
}
const configServers = await resolveConfigServers(req);
const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers);
const registry = getMCPServersRegistry();
const { allowedDomains, allowedAddresses } = await registry.resolveAllowlists({
userId,
role: req.user?.role,
});
const {
authorizationUrl,
flowId: oauthFlowId,
flowMetadata,
} = await MCPOAuthHandler.initiateOAuthFlow(
serverName,
serverUrl,
userId,
oauthHeaders,
oauthConfig,
allowedDomains,
undefined,
allowedAddresses,
getTenantId(),
);
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
const oldState = flowState.metadata?.state;
if (typeof oldState === 'string') {
await MCPOAuthHandler.deleteStateMapping(oldState, flowManager);
}
const metadataWithUrl = { ...flowMetadata, authorizationUrl, tenantId: getTenantId() };
await flowManager.initFlow(oauthFlowId, 'mcp_oauth', metadataWithUrl);
await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager);
setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
res.redirect(authorizationUrl);
} catch (error) {
logger.error('[MCP OAuth] Failed to initiate OAuth', error);
res.status(500).json({ error: 'Failed to initiate OAuth' });
}
});
/**
* OAuth callback handler
* This handles the OAuth callback after the user has authorized the application
*/
router.get('/:serverName/oauth/callback', async (req, res) => {
const basePath = getBasePath();
try {
const { serverName } = req.params;
const { code, state, error: oauthError } = req.query;
logger.debug('[MCP OAuth] Callback received', {
serverName,
code: code ? 'present' : 'missing',
state,
error: oauthError,
});
if (oauthError) {
logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
// Gate failFlow behind callback validation to prevent DoS via leaked state
if (state && typeof state === 'string') {
try {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
if (flowId) {
const parsed = MCPOAuthHandler.parseFlowId(flowId);
if (!parsed) {
logger.warn('[MCP OAuth] Invalid flow ID format for OAuth error callback', {
flowId,
});
} else {
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
const hasSession = !hasCsrf && validateOAuthSession(req, parsed.userId);
if (hasCsrf || hasSession) {
await flowManager.failFlow(flowId, 'mcp_oauth', String(oauthError));
logger.debug('[MCP OAuth] Marked flow as FAILED with OAuth error', {
flowId,
error: oauthError,
});
}
}
}
} catch (err) {
logger.debug('[MCP OAuth] Could not mark flow as failed', err);
}
}
return res.redirect(
`${basePath}/oauth/error?error=${encodeURIComponent(String(oauthError))}`,
);
}
if (!code || typeof code !== 'string') {
logger.error('[MCP OAuth] Missing or invalid code');
return res.redirect(`${basePath}/oauth/error?error=missing_code`);
}
if (!state || typeof state !== 'string') {
logger.error('[MCP OAuth] Missing or invalid state');
return res.redirect(`${basePath}/oauth/error?error=missing_state`);
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
if (!flowId) {
logger.error('[MCP OAuth] Could not resolve state to flow ID', { state });
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId });
const parsedFlowId = MCPOAuthHandler.parseFlowId(flowId);
if (!parsedFlowId) {
logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
const hasSession = !hasCsrf && validateOAuthSession(req, parsedFlowId.userId);
let hasActiveFlow = false;
if (!hasCsrf && !hasSession) {
const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
const pendingAge = pendingFlow?.createdAt ? Date.now() - pendingFlow.createdAt : Infinity;
hasActiveFlow = pendingFlow?.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
if (hasActiveFlow) {
logger.debug(
'[MCP OAuth] CSRF/session cookies absent, validating via active PENDING flow',
{
flowId,
},
);
}
}
if (!hasCsrf && !hasSession && !hasActiveFlow) {
logger.error(
'[MCP OAuth] CSRF validation failed: no valid CSRF cookie, session cookie, or active flow',
{
flowId,
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
},
);
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
}
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
if (!flowState) {
logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
logger.debug('[MCP OAuth] Flow state details', {
serverName: flowState.serverName,
userId: flowState.userId,
hasMetadata: !!flowState.metadata,
hasClientInfo: !!flowState.clientInfo,
hasCodeVerifier: !!flowState.codeVerifier,
});
/** Check if this flow has already been completed (idempotency protection) */
const currentFlowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (currentFlowState?.status === 'COMPLETED') {
logger.warn('[MCP OAuth] Flow already completed, preventing duplicate token exchange', {
flowId,
serverName,
});
return res.redirect(`${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`);
}
logger.debug('[MCP OAuth] Completing OAuth flow');
if (!flowState.oauthHeaders) {
logger.warn(
'[MCP OAuth] oauthHeaders absent from flow state — config-source server oauth_headers will be empty',
{ serverName, flowId },
);
}
/**
* Restore tenant context for the callback body. The callback is a cross-origin
* redirect from the OAuth provider, so SameSite=Strict cookies (including the
* JWT) are not sent. The tenantId was stored in the flow metadata at initiation
* time when the user was authenticated.
*/
const runWithTenant = async (fn) => {
const flowTenantId = flowState.tenantId;
if (flowTenantId && !getTenantId()) {
return tenantStorage.run({ tenantId: flowTenantId }, fn);
}
return fn();
};
await runWithTenant(async () => {
const oauthHeaders =
flowState.oauthHeaders ?? (await getOAuthHeaders(serverName, flowState.userId));
const tokens = await MCPOAuthHandler.completeOAuthFlow(
flowId,
code,
flowManager,
oauthHeaders,
);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
/** Persist tokens immediately so reconnection uses fresh credentials */
if (flowState?.userId && tokens) {
try {
await MCPTokenStorage.storeTokens({
userId: flowState.userId,
serverName,
tokens,
createToken: db.createToken,
updateToken: db.updateToken,
findToken: db.findToken,
clientInfo: flowState.clientInfo,
metadata: MCPOAuthHandler.buildStoredClientMetadata(
flowState.metadata,
flowState.resourceMetadata,
),
});
logger.debug('[MCP OAuth] Stored OAuth tokens prior to reconnection', {
serverName,
userId: flowState.userId,
});
} catch (error) {
logger.error('[MCP OAuth] Failed to store OAuth tokens after callback', error);
throw error;
}
/**
* Clear any cached `mcp_get_tokens` flow result so subsequent lookups
* re-fetch the freshly stored credentials instead of returning stale nulls.
*/
if (typeof flowManager?.deleteFlow === 'function') {
try {
const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(
flowState.userId,
serverName,
flowState.tenantId,
);
await clearGetTokensFlow({
flowManager,
flowId: tokenFlowId,
tokens,
});
if (tokenFlowId !== flowId) {
await clearGetTokensFlow({
flowManager,
flowId,
tokens,
});
}
} catch (error) {
logger.warn('[MCP OAuth] Failed to clear cached token flow state', error);
}
}
}
try {
const mcpManager = getMCPManager(flowState.userId);
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
if (flowState.userId !== 'system') {
const user = { id: flowState.userId };
/** Merged config (incl. Config-tier overlays) so the reconnection and
* the cache gate both see request-scoped servers the base registry
* lookup misses */
let serverConfig;
try {
const allConfigs = await resolveAllMcpConfigs(flowState.userId);
serverConfig = allConfigs?.[serverName];
} catch (error) {
logger.warn(
`[MCP OAuth] Could not resolve server config for ${serverName} before reconnecting:`,
error,
);
}
const userConnection = await mcpManager.getUserConnection({
user,
serverName,
flowManager,
serverConfig,
tokenMethods: {
findToken: db.findToken,
updateToken: db.updateToken,
createToken: db.createToken,
deleteTokens: db.deleteTokens,
},
});
logger.info(
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
const oauthReconnectionManager = getOAuthReconnectionManager();
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
const tools = await userConnection.fetchTools();
await updateMCPServerTools({
userId: flowState.userId,
serverName,
tools,
serverConfig,
});
} else {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
} catch (error) {
logger.warn(
`[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
error,
);
}
/** ID of the flow that the tool/connection is waiting for */
const toolFlowId = flowState.metadata?.toolFlowId;
if (toolFlowId) {
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
const completed = await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
if (!completed) {
logger.warn(
'[MCP OAuth] Tool flow state not found during completion — waiter will time out',
{ toolFlowId },
);
}
}
}); /* end runWithTenant */
/** Redirect to success page with flowId and serverName */
const redirectUrl = `${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`;
res.redirect(redirectUrl);
} catch (error) {
logger.error('[MCP OAuth] OAuth callback error', error);
res.redirect(`${basePath}/oauth/error?error=callback_failed`);
}
});
/**
* Get OAuth tokens for a completed flow
* This is primarily for user-level OAuth flows
*/
router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
try {
const { flowId } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
if (!canAccessOAuthFlow(flowId, user.id)) {
return res.status(403).json({ error: 'Access denied' });
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
if (flowState.status !== 'COMPLETED') {
return res.status(400).json({ error: 'Flow not completed' });
}
res.json({ tokens: flowState.result });
} catch (error) {
logger.error('[MCP OAuth] Failed to get tokens', error);
res.status(500).json({ error: 'Failed to get tokens' });
}
});
/**
* Set CSRF binding cookie for OAuth flows initiated outside of HTTP request/response
* (e.g. during chat via SSE). The frontend should call this before opening the OAuth URL
* so the callback can verify the browser matches the flow initiator.
*/
router.post('/:serverName/oauth/bind', requireJwtAuth, setOAuthSession, async (req, res) => {
try {
const { serverName } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
const flowId = getOAuthFlowId(user.id, serverName);
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
res.json({ success: true });
} catch (error) {
logger.error('[MCP OAuth] Failed to set CSRF binding cookie', error);
res.status(500).json({ error: 'Failed to bind OAuth flow' });
}
});
/**
* Check OAuth flow status
* This endpoint can be used to poll the status of an OAuth flow
*/
router.get('/oauth/status/:flowId', requireJwtAuth, async (req, res) => {
try {
const { flowId } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
if (!canAccessOAuthFlow(flowId, user.id)) {
return res.status(403).json({ error: 'Access denied' });
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
return res.status(404).json({ error: 'Flow not found' });
}
res.json({
status: flowState.status,
completed: flowState.status === 'COMPLETED',
failed: flowState.status === 'FAILED',
error: flowState.error,
});
} catch (error) {
logger.error('[MCP OAuth] Failed to get flow status', error);
res.status(500).json({ error: 'Failed to get flow status' });
}
});
/**
* Cancel OAuth flow
* This endpoint cancels a pending OAuth flow
*/
router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
try {
const { serverName } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
logger.info(`[MCP OAuth Cancel] Cancelling OAuth flow for ${serverName} by user ${user.id}`);
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const flowId = getOAuthFlowId(user.id, serverName);
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
logger.debug(`[MCP OAuth Cancel] No active flow found for ${serverName}`);
return res.json({
success: true,
message: 'No active OAuth flow to cancel',
});
}
await flowManager.failFlow(flowId, 'mcp_oauth', 'User cancelled OAuth flow');
logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
res.json({
success: true,
message: `OAuth flow for ${serverName} cancelled successfully`,
});
} catch (error) {
logger.error('[MCP OAuth Cancel] Failed to cancel OAuth flow', error);
res.status(500).json({ error: 'Failed to cancel OAuth flow' });
}
});
/**
* Reinitialize MCP server
* This endpoint allows reinitializing a specific MCP server
*/
router.post(
'/:serverName/reinitialize',
requireJwtAuth,
checkMCPUsePermissions,
setOAuthSession,
async (req, res) => {
try {
const { serverName } = req.params;
const user = createSafeUser(req.user);
if (!user.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
const mcpManager = getMCPManager();
const configServers = await resolveConfigServers(req);
const serverConfig = await getMCPServersRegistry().getServerConfig(
serverName,
user.id,
configServers,
);
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
});
}
await mcpManager.disconnectUserConnection(user.id, serverName);
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);
/** @type {Record<string, Record<string, string>> | undefined} */
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
userMCPAuthMap = await getUserMCPAuthMap({
userId: user.id,
servers: [serverName],
findPluginAuthsByKeys: db.findPluginAuthsByKeys,
});
}
const result = await reinitMCPServer({
user,
serverName,
serverConfig,
configServers,
userMCPAuthMap,
});
if (!result) {
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
const { success, message, oauthRequired, oauthUrl } = result;
if (oauthRequired) {
const flowId = getOAuthFlowId(user.id, serverName);
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
}
res.json({
success,
message,
oauthUrl,
serverName,
oauthRequired,
});
} catch (error) {
logger.error('[MCP Reinitialize] Unexpected error', error);
res.status(500).json({ error: 'Internal server error' });
}
},
);
/**
* Get connection status for all MCP servers
* This endpoint returns all app level and user-scoped connection statuses from MCPManager without disconnecting idle connections
*/
router.get('/connection/status', requireJwtAuth, async (req, res) => {
try {
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
user.id,
{ role: user.role, tenantId: getTenantId() },
);
const connectionStatus = {};
for (const [serverName, config] of Object.entries(mcpConfig)) {
try {
connectionStatus[serverName] = await getServerConnectionStatus(
user.id,
serverName,
config,
appConnections,
userConnections,
oauthServers,
);
} catch (error) {
const message = `Failed to get status for server "${serverName}"`;
logger.error(`[MCP Connection Status] ${message},`, error);
connectionStatus[serverName] = {
connectionState: 'error',
requiresOAuth: oauthServers.has(serverName),
error: message,
};
}
}
res.json({
success: true,
connectionStatus,
oauthTimeout: mcpSettings.OAUTH_HANDLING_TIMEOUT,
});
} catch (error) {
logger.error('[MCP Connection Status] Failed to get connection status', error);
res.status(500).json({ error: 'Failed to get connection status' });
}
});
/**
* Get connection status for a single MCP server
* This endpoint returns the connection status for a specific server for a given user
*/
router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => {
try {
const user = req.user;
const { serverName } = req.params;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
const { mcpConfig, appConnections, userConnections, oauthServers } = await getMCPSetupData(
user.id,
{ role: user.role, tenantId: getTenantId() },
);
if (!mcpConfig[serverName]) {
return res
.status(404)
.json({ error: `MCP server '${serverName}' not found in configuration` });
}
const serverStatus = await getServerConnectionStatus(
user.id,
serverName,
mcpConfig[serverName],
appConnections,
userConnections,
oauthServers,
);
res.json({
success: true,
serverName,
connectionStatus: serverStatus.connectionState,
requiresOAuth: serverStatus.requiresOAuth,
});
} catch (error) {
logger.error(
`[MCP Per-Server Status] Failed to get connection status for ${req.params.serverName}`,
error,
);
res.status(500).json({ error: 'Failed to get connection status' });
}
});
/**
* Check which authentication values exist for a specific MCP server
* This endpoint returns only boolean flags indicating if values are set, not the actual values
*/
router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
try {
const { serverName } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
const configServers = await resolveConfigServers(req);
const serverConfig = await getMCPServersRegistry().getServerConfig(
serverName,
user.id,
configServers,
);
if (!serverConfig) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
});
}
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
const authValueFlags = {};
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
for (const varName of Object.keys(serverConfig.customUserVars)) {
try {
const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
authValueFlags[varName] = !!(value && value.length > 0);
} catch (err) {
logger.error(
`[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
err,
);
authValueFlags[varName] = false;
}
}
}
res.json({
success: true,
serverName,
authValueFlags,
});
} catch (error) {
logger.error(
`[MCP Auth Value Flags] Failed to check auth value flags for ${req.params.serverName}`,
error,
);
res.status(500).json({ error: 'Failed to check auth value flags' });
}
});
async function getOAuthHeaders(serverName, userId, configServers) {
const serverConfig = await getMCPServersRegistry().getServerConfig(
serverName,
userId,
configServers,
);
return serverConfig?.oauth_headers ?? {};
}
/**
MCP Server CRUD Routes (User-Managed MCP Servers)
*/
/**
* Get list of accessible MCP servers
* @route GET /api/mcp/servers
* @param {Object} req.query - Query parameters for pagination and search
* @param {number} [req.query.limit] - Number of results per page
* @param {string} [req.query.after] - Pagination cursor
* @param {string} [req.query.search] - Search query for title/description
* @returns {MCPServerListResponse} 200 - Success response - application/json
*/
router.get('/servers', requireJwtAuth, checkMCPUsePermissions, getMCPServersList);
/**
* Create a new MCP server
* @route POST /api/mcp/servers
* @param {MCPServerCreateParams} req.body - The MCP server creation parameters.
* @returns {MCPServer} 201 - Success response - application/json
*/
router.post('/servers', requireJwtAuth, checkMCPCreate, createMCPServerController);
/**
* Get single MCP server by ID
* @route GET /api/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @returns {MCPServer} 200 - Success response - application/json
*/
router.get(
'/servers/:serverName',
requireJwtAuth,
checkMCPUsePermissions,
canAccessMCPServerResource({
requiredPermission: PermissionBits.VIEW,
resourceIdParam: 'serverName',
}),
getMCPServerById,
);
/**
* Update MCP server
* @route PATCH /api/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @param {MCPServerUpdateParams} req.body - The MCP server update parameters.
* @returns {MCPServer} 200 - Success response - application/json
*/
router.patch(
'/servers/:serverName',
requireJwtAuth,
checkMCPCreate,
canAccessMCPServerResource({
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'serverName',
}),
updateMCPServerController,
);
/**
* Delete MCP server
* @route DELETE /api/mcp/servers/:serverName
* @param {string} req.params.serverName - MCP server identifier.
* @returns {Object} 200 - Success response - application/json
*/
router.delete(
'/servers/:serverName',
requireJwtAuth,
checkMCPCreate,
canAccessMCPServerResource({
requiredPermission: PermissionBits.DELETE,
resourceIdParam: 'serverName',
}),
deleteMCPServerController,
);
module.exports = router;