mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-11 10:32:37 +01:00
* fix: Implement race conditions in MCP OAuth flow - Added connection mutex to coalesce concurrent `getUserConnection` calls, preventing multiple simultaneous attempts. - Enhanced flow state management to retry once when a flow state is missing, improving resilience against race conditions. - Introduced `ReauthenticationRequiredError` for better error handling when access tokens are expired or missing. - Updated tests to cover new race condition scenarios and ensure proper handling of OAuth flows. * fix: Stale PENDING flow detection and OAuth URL re-issuance PENDING flows in handleOAuthRequired now check createdAt age — flows older than 2 minutes are treated as stale and replaced instead of joined. Fixes the case where a leftover PENDING flow from a previous session blocks new OAuth initiation. authorizationUrl is now stored in MCPOAuthFlowMetadata so that when a second caller joins an active PENDING flow (e.g., the SSE-emitting path in ToolService), it can re-issue the URL to the user via oauthStart. * fix: CSRF fallback via active PENDING flow in OAuth callback When the OAuth callback arrives without CSRF or session cookies (common in the chat/SSE flow where cookies can't be set on streaming responses), fall back to validating that a PENDING flow exists for the flowId. This is safe because the flow was created server-side after JWT authentication and the authorization code is PKCE-protected. * test: Extract shared OAuth test server helpers Move MockKeyv, getFreePort, trackSockets, and createOAuthMCPServer into a shared helpers/oauthTestServer module. Enhance the test server with refresh token support, token rotation, metadata discovery, and dynamic client registration endpoints. Add InMemoryTokenStore for token storage tests. Refactor MCPOAuthRaceCondition.test.ts to import from shared helpers. * test: Add comprehensive MCP OAuth test modules MCPOAuthTokenStorage — 21 tests for storeTokens/getTokens with InMemoryTokenStore: encrypt/decrypt round-trips, expiry calculation, refresh callback wiring, ReauthenticationRequiredError paths. MCPOAuthFlow — 10 tests against real HTTP server: token refresh with stored client info, refresh token rotation, metadata discovery, dynamic client registration, full store/retrieve/expire/refresh lifecycle. MCPOAuthConnectionEvents — 5 tests for MCPConnection OAuth event cycle with real OAuth-gated MCP server: oauthRequired emission on 401, oauthHandled reconnection, oauthFailed rejection, token expiry detection. MCPOAuthTokenExpiry — 12 tests for the token expiry edge case: refresh success/failure paths, ReauthenticationRequiredError, PENDING flow CSRF fallback, authorizationUrl metadata storage, full re-auth cycle after refresh failure, concurrent expired token coalescing, stale PENDING flow detection. * test: Enhance MCP OAuth connection tests with cooldown reset Added a `beforeEach` hook to clear the cooldown for `MCPConnection` before each test, ensuring a clean state. Updated the race condition handling in the tests to properly clear the timeout, improving reliability in the event data retrieval process. * refactor: PENDING flow management and state recovery in MCP OAuth - Introduced a constant `PENDING_STALE_MS` to define the age threshold for PENDING flows, improving the handling of stale flows. - Updated the logic in `MCPConnectionFactory` and `FlowStateManager` to check the age of PENDING flows before joining or reusing them. - Modified the `completeFlow` method to return false when the flow state is deleted, ensuring graceful handling of race conditions. - Enhanced tests to validate the new behavior and ensure robustness against state recovery issues. * refactor: MCP OAuth flow management and testing - Updated the `completeFlow` method to log warnings when a tool flow state is not found during completion, improving error handling. - Introduced a new `normalizeExpiresAt` function to standardize expiration timestamp handling across the application. - Refactored token expiration checks in `MCPConnectionFactory` to utilize the new normalization function, ensuring consistent behavior. - Added a comprehensive test suite for OAuth callback CSRF fallback logic, validating the handling of PENDING flows and their staleness. - Enhanced existing tests to cover new expiration normalization logic and ensure robust flow state management. * test: Add CSRF fallback tests for active PENDING flows in MCP OAuth - Introduced new tests to validate CSRF fallback behavior when a fresh PENDING flow exists without cookies, ensuring successful OAuth callback handling. - Added scenarios to reject requests when no PENDING flow exists, when only a COMPLETED flow is present, and when a PENDING flow is stale, enhancing the robustness of flow state management. - Improved overall test coverage for OAuth callback logic, reinforcing the handling of CSRF validation failures. * chore: imports order * refactor: Update UserConnectionManager to conditionally manage pending connections - Modified the logic in `UserConnectionManager` to only set pending connections if `forceNew` is false, preventing unnecessary overwrites. - Adjusted the cleanup process to ensure pending connections are only deleted when not forced, enhancing connection management efficiency. * refactor: MCP OAuth flow state management - Introduced a new method `storeStateMapping` in `MCPOAuthHandler` to securely map the OAuth state parameter to the flow ID, improving callback resolution and security against forgery. - Updated the OAuth initiation and callback handling in `mcp.js` to utilize the new state mapping functionality, ensuring robust flow management. - Refactored `MCPConnectionFactory` to store state mappings during flow initialization, enhancing the integrity of the OAuth process. - Adjusted comments to clarify the purpose of state parameters in authorization URLs, reinforcing code readability. * refactor: MCPConnection with OAuth recovery handling - Added `oauthRecovery` flag to manage OAuth recovery state during connection attempts. - Introduced `decrementCycleCount` method to reduce the circuit breaker's cycle count upon successful reconnection after OAuth recovery. - Updated connection logic to reset the `oauthRecovery` flag after handling OAuth, improving state management and connection reliability. * chore: Add debug logging for OAuth recovery cycle count decrement - Introduced a debug log statement in the `MCPConnection` class to track the decrement of the cycle count after a successful reconnection during OAuth recovery. - This enhancement improves observability and aids in troubleshooting connection issues related to OAuth recovery. * test: Add OAuth recovery cycle management tests - Introduced new tests for the OAuth recovery cycle in `MCPConnection`, validating the decrement of cycle counts after successful reconnections. - Added scenarios to ensure that the cycle count is not decremented on OAuth failures, enhancing the robustness of connection management. - Improved test coverage for OAuth reconnect scenarios, ensuring reliable behavior under various conditions. * feat: Implement circuit breaker configuration in MCP - Added circuit breaker settings to `.env.example` for max cycles, cycle window, and cooldown duration. - Refactored `MCPConnection` to utilize the new configuration values from `mcpConfig`, enhancing circuit breaker management. - Improved code maintainability by centralizing circuit breaker parameters in the configuration file. * refactor: Update decrementCycleCount method for circuit breaker management - Changed the visibility of the `decrementCycleCount` method in `MCPConnection` from private to public static, allowing it to be called with a server name parameter. - Updated calls to `decrementCycleCount` in `MCPConnectionFactory` to use the new static method, improving clarity and consistency in circuit breaker management during connection failures and OAuth recovery. - Enhanced the handling of circuit breaker state by ensuring the method checks for the existence of the circuit breaker before decrementing the cycle count. * refactor: cycle count decrement on tool listing failure - Added a call to `MCPConnection.decrementCycleCount` in the `MCPConnectionFactory` to handle cases where unauthenticated tool listing fails, improving circuit breaker management. - This change ensures that the cycle count is decremented appropriately, maintaining the integrity of the connection recovery process. * refactor: Update circuit breaker configuration and logic - Enhanced circuit breaker settings in `.env.example` to include new parameters for failed rounds and backoff strategies. - Refactored `MCPConnection` to utilize the updated configuration values from `mcpConfig`, improving circuit breaker management. - Updated tests to reflect changes in circuit breaker logic, ensuring accurate validation of connection behavior under rapid reconnect scenarios. * feat: Implement state mapping deletion in MCP flow management - Added a new method `deleteStateMapping` in `MCPOAuthHandler` to remove orphaned state mappings when a flow is replaced, preventing old authorization URLs from resolving after a flow restart. - Updated `MCPConnectionFactory` to call `deleteStateMapping` during flow cleanup, ensuring proper management of OAuth states. - Enhanced test coverage for state mapping functionality to validate the new deletion logic.
783 lines
25 KiB
JavaScript
783 lines
25 KiB
JavaScript
const { Router } = require('express');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const {
|
|
CacheKeys,
|
|
Constants,
|
|
PermissionBits,
|
|
PermissionTypes,
|
|
Permissions,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
getBasePath,
|
|
createSafeUser,
|
|
MCPOAuthHandler,
|
|
MCPTokenStorage,
|
|
setOAuthSession,
|
|
PENDING_STALE_MS,
|
|
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 { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
|
const { requireJwtAuth, canAccessMCPServerResource } = require('~/server/middleware');
|
|
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
|
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
|
const { findPluginAuthsByKeys } = require('~/models');
|
|
const { getRoleByName } = require('~/models/Role');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
const router = Router();
|
|
|
|
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
|
|
|
|
/**
|
|
* Get all MCP tools available to the user
|
|
* Returns only MCP tools, completely decoupled from regular LibreChat tools
|
|
*/
|
|
router.get('/tools', requireJwtAuth, 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 (userId !== user.id) {
|
|
return res.status(403).json({ error: 'User 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 { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
|
|
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 oauthHeaders = await getOAuthHeaders(serverName, userId);
|
|
const {
|
|
authorizationUrl,
|
|
flowId: oauthFlowId,
|
|
flowMetadata,
|
|
} = await MCPOAuthHandler.initiateOAuthFlow(
|
|
serverName,
|
|
serverUrl,
|
|
userId,
|
|
oauthHeaders,
|
|
oauthConfig,
|
|
);
|
|
|
|
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
|
|
|
|
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 });
|
|
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 flowParts = flowId.split(':');
|
|
if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) {
|
|
logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
|
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
|
}
|
|
|
|
const [flowUserId] = flowParts;
|
|
|
|
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
|
|
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');
|
|
const 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,
|
|
updateToken,
|
|
findToken,
|
|
clientInfo: flowState.clientInfo,
|
|
metadata: flowState.metadata,
|
|
});
|
|
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 {
|
|
await flowManager.deleteFlow(flowId, 'mcp_get_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 };
|
|
|
|
const userConnection = await mcpManager.getUserConnection({
|
|
user,
|
|
serverName,
|
|
flowManager,
|
|
tokenMethods: {
|
|
findToken,
|
|
updateToken,
|
|
createToken,
|
|
deleteTokens,
|
|
},
|
|
});
|
|
|
|
logger.info(
|
|
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
|
|
);
|
|
|
|
// clear any reconnection attempts
|
|
const oauthReconnectionManager = getOAuthReconnectionManager();
|
|
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
|
|
|
|
const tools = await userConnection.fetchTools();
|
|
await updateMCPServerTools({
|
|
userId: flowState.userId,
|
|
serverName,
|
|
tools,
|
|
});
|
|
} 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 },
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 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 (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
|
|
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 = MCPOAuthHandler.generateFlowId(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 (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
|
|
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 = MCPOAuthHandler.generateFlowId(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, 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 serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
|
|
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,
|
|
});
|
|
}
|
|
|
|
const result = await reinitMCPServer({
|
|
user,
|
|
serverName,
|
|
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 = MCPOAuthHandler.generateFlowId(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,
|
|
);
|
|
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,
|
|
});
|
|
} catch (error) {
|
|
if (error.message === 'MCP config not found') {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
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,
|
|
);
|
|
|
|
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) {
|
|
if (error.message === 'MCP config not found') {
|
|
return res.status(404).json({ error: error.message });
|
|
}
|
|
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, 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 serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
|
|
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) {
|
|
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
|
|
return serverConfig?.oauth_headers ?? {};
|
|
}
|
|
|
|
/**
|
|
MCP Server CRUD Routes (User-Managed MCP Servers)
|
|
*/
|
|
|
|
// Permission checkers for MCP server management
|
|
const checkMCPUsePermissions = generateCheckAccess({
|
|
permissionType: PermissionTypes.MCP_SERVERS,
|
|
permissions: [Permissions.USE],
|
|
getRoleByName,
|
|
});
|
|
|
|
const checkMCPCreate = generateCheckAccess({
|
|
permissionType: PermissionTypes.MCP_SERVERS,
|
|
permissions: [Permissions.USE, Permissions.CREATE],
|
|
getRoleByName,
|
|
});
|
|
|
|
/**
|
|
* 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;
|