Compare commits

...

2 Commits

Author SHA1 Message Date
Marco Beretta
8b1d804391 feat: Add OAuth token revocation endpoint and related functionality 2025-07-30 20:41:47 +02:00
Marco Beretta
f25407768e refactor: Improve OAuth server management and connection status handling 2025-07-30 20:40:22 +02:00
11 changed files with 254 additions and 10 deletions

View File

@@ -300,6 +300,120 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
}
});
/**
* Revoke OAuth tokens for an MCP server
* This endpoint revokes OAuth tokens and disconnects the server for a fresh start
*/
router.post('/:serverName/oauth/revoke', 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 Revoke] Revoking OAuth tokens for ${serverName} by user ${user.id}`);
const printConfig = false;
const config = await loadCustomConfig(printConfig);
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
});
}
const mcpManager = getMCPManager(user.id);
// Delete OAuth access and refresh tokens
const baseIdentifier = `mcp:${serverName}`;
try {
await deleteTokens({ identifier: baseIdentifier });
await deleteTokens({ identifier: `${baseIdentifier}:refresh` });
logger.info(`[MCP OAuth Revoke] Successfully cleared OAuth tokens for ${serverName}`);
} catch (error) {
logger.warn(`[MCP OAuth Revoke] Failed to clear OAuth tokens for ${serverName}:`, error);
}
// Disconnect the server and clear all connection state
try {
await mcpManager.disconnectServer(serverName);
logger.info(`[MCP OAuth Revoke] Disconnected server: ${serverName}`);
// Clear the server from OAuth servers set to prevent it from showing as requiring OAuth
if (mcpManager.removeOAuthServer) {
mcpManager.removeOAuthServer(serverName);
logger.info(`[MCP OAuth Revoke] Removed ${serverName} from OAuth servers set`);
} else {
// Fallback: clear the OAuth servers set entry manually (should not be needed now)
const oauthServers = mcpManager.getOAuthServers();
if (oauthServers && oauthServers.has(serverName)) {
oauthServers.delete(serverName);
logger.info(`[MCP OAuth Revoke] Manually removed ${serverName} from OAuth servers set`);
}
}
// Clear connection state from both app and user connection maps
const appConnections = mcpManager.getAllConnections();
const userConnections = mcpManager.getUserConnections(user.id);
if (appConnections && appConnections.has(serverName)) {
appConnections.delete(serverName);
logger.info(`[MCP OAuth Revoke] Cleared ${serverName} from app connections`);
}
if (userConnections && userConnections.has(serverName)) {
userConnections.delete(serverName);
logger.info(`[MCP OAuth Revoke] Cleared ${serverName} from user connections`);
}
} catch (error) {
logger.warn(`[MCP OAuth Revoke] Failed to disconnect server ${serverName}:`, error);
}
// Clear cached tools for this server
try {
const userTools = (await getCachedTools({ userId: user.id })) || {};
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
await setCachedTools(userTools, { userId: user.id });
logger.info(`[MCP OAuth Revoke] Cleared cached tools for ${serverName}`);
} catch (error) {
logger.warn(`[MCP OAuth Revoke] Failed to clear cached tools for ${serverName}:`, error);
}
// Cancel any pending OAuth flows
try {
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 && flowState.status === 'PENDING') {
await flowManager.failFlow(flowId, 'mcp_oauth', 'OAuth tokens revoked by user');
logger.info(`[MCP OAuth Revoke] Cancelled pending OAuth flow for ${serverName}`);
}
} catch (error) {
logger.warn(`[MCP OAuth Revoke] Failed to cancel OAuth flow for ${serverName}:`, error);
}
res.json({
success: true,
message: `OAuth tokens revoked for ${serverName}. Server can now be re-authenticated.`,
serverName,
});
} catch (error) {
logger.error('[MCP OAuth Revoke] Unexpected error', error);
res.status(500).json({ error: 'Failed to revoke OAuth tokens' });
}
});
/**
* Reinitialize MCP server
* This endpoint allows reinitializing a specific MCP server

View File

@@ -339,7 +339,7 @@ async function getServerConnectionStatus(
serverName,
appConnections,
userConnections,
oauthServers,
_oauthServers,
) {
const getConnectionState = () =>
appConnections.get(serverName)?.connectionState ??
@@ -349,7 +349,36 @@ async function getServerConnectionStatus(
const baseConnectionState = getConnectionState();
let finalConnectionState = baseConnectionState;
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
const mcpManager = getMCPManager(userId);
const hasOAuthConfig = mcpManager.serverRequiresOAuth(serverName);
if (hasOAuthConfig) {
const baseIdentifier = `mcp:${serverName}`;
try {
const accessToken = await findToken({ identifier: baseIdentifier });
if (!accessToken) {
// No tokens found, server should be considered disconnected regardless of in-memory state
finalConnectionState = 'disconnected';
logger.debug(
`[Connection Status] No OAuth tokens found for ${serverName}, marking as disconnected`,
);
} else if (baseConnectionState === 'disconnected') {
// Tokens exist but connection shows disconnected, check OAuth flow status
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
finalConnectionState = 'error';
} else if (hasActiveFlow) {
finalConnectionState = 'connecting';
}
}
} catch (error) {
logger.warn(`[Connection Status] Error checking tokens for ${serverName}:`, error);
finalConnectionState = 'disconnected';
}
} else if (baseConnectionState === 'disconnected') {
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
@@ -359,8 +388,19 @@ async function getServerConnectionStatus(
}
}
let requiresOAuth = hasOAuthConfig;
if (hasOAuthConfig) {
try {
const baseIdentifier = `mcp:${serverName}`;
const accessToken = await findToken({ identifier: baseIdentifier });
requiresOAuth = !accessToken;
} catch (_error) {
requiresOAuth = true;
}
}
return {
requiresOAuth: oauthServers.has(serverName),
requiresOAuth,
connectionState: finalConnectionState,
};
}

View File

@@ -128,11 +128,11 @@ export default function MCPConfigDialog({
/>
</div>
{/* Server Initialization Section */}
{/* Server Initialization Section - Always show for OAuth servers or when custom vars exist */}
<ServerInitializationSection
serverName={serverName}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={fieldsSchema && Object.keys(fieldsSchema).length > 0}
hasCustomUserVars={hasFields}
/>
</OGDialogContent>
</OGDialog>

View File

@@ -73,8 +73,8 @@ export default function MCPServerStatusIcon({
}
if (connectionState === 'connected') {
// Only show config button if there are customUserVars to configure
if (hasCustomUserVars) {
// Show config button if there are customUserVars to configure OR if it's an OAuth server (for revoke functionality)
if (hasCustomUserVars || requiresOAuth) {
const isAuthenticated = tool?.authenticated || requiresOAuth;
return (
<AuthenticatedStatusIcon
@@ -84,7 +84,7 @@ export default function MCPServerStatusIcon({
/>
);
}
return null; // No config button for connected servers without customUserVars
return null; // No config button for connected servers without customUserVars or OAuth
}
return null;

View File

@@ -22,6 +22,7 @@ export default function ServerInitializationSection({
initializeServer,
connectionStatus,
cancelOAuthFlow,
revokeOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,

View File

@@ -1,7 +1,7 @@
import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
import {
useCancelMCPOAuthMutation,
useUpdateUserPluginsMutation,
@@ -48,6 +48,17 @@ export function useMCPServerManager() {
const reinitializeMutation = useReinitializeMCPServerMutation();
const cancelOAuthMutation = useCancelMCPOAuthMutation();
// Create OAuth revoke mutation using the data service (which includes authentication)
const revokeOAuthMutation = useMutation(
(serverName: string) => dataService.revokeMCPOAuth(serverName),
{
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
queryClient.invalidateQueries([QueryKeys.tools]);
},
},
);
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: async () => {
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
@@ -295,6 +306,38 @@ export function useMCPServerManager() {
[queryClient, cleanupServerState, showToast, localize, cancelOAuthMutation],
);
const revokeOAuthFlow = useCallback(
(serverName: string) => {
revokeOAuthMutation.mutate(serverName, {
onSuccess: () => {
cleanupServerState(serverName);
// Remove server from selected values since OAuth tokens are revoked
const currentValues = mcpValues ?? [];
const filteredValues = currentValues.filter((name) => name !== serverName);
setMCPValues(filteredValues);
// Force refresh of connection status to reflect the revoked state
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
queryClient.invalidateQueries([QueryKeys.tools]);
showToast({
message: `OAuth tokens revoked for ${serverName}. You can now re-authenticate.`,
status: 'success',
});
},
onError: (error) => {
console.error(`[MCP Manager] Failed to revoke OAuth for ${serverName}:`, error);
showToast({
message: `Failed to revoke OAuth tokens for ${serverName}`,
status: 'error',
});
},
});
},
[revokeOAuthMutation, cleanupServerState, mcpValues, setMCPValues, showToast, queryClient],
);
const isInitializing = useCallback(
(serverName: string) => {
return serverStates[serverName]?.isInitializing || false;
@@ -536,6 +579,7 @@ export function useMCPServerManager() {
connectionStatus,
initializeServer,
cancelOAuthFlow,
revokeOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,

View File

@@ -494,6 +494,9 @@ export class MCPManager {
connection.on('oauthRequired', async (data) => {
logger.info(`[MCP][User: ${userId}][${serverName}] oauthRequired event received`);
// Add server to OAuth servers set
this.oauthServers.add(serverName);
// If we just want to initiate OAuth and return, handle it differently
if (returnOnOAuth) {
try {
@@ -1140,4 +1143,17 @@ ${logPrefix} Flow ID: ${newFlowId}
public getOAuthServers(): Set<string> {
return this.oauthServers;
}
/** Remove a server from OAuth servers set */
public removeOAuthServer(serverName: string): void {
this.oauthServers.delete(serverName);
}
/**
* Check if a server requires OAuth based on configuration
*/
public serverRequiresOAuth(serverName: string): boolean {
const config = this.mcpConfigs[serverName];
return !!config?.oauth;
}
}

View File

@@ -144,6 +144,10 @@ export const cancelMCPOAuth = (serverName: string) => {
return `/api/mcp/oauth/cancel/${serverName}`;
};
export const revokeMCPOAuth = (serverName: string) => {
return `/api/mcp/${serverName}/oauth/revoke`;
};
export const config = () => '/api/config';
export const prompts = () => '/api/prompts';

View File

@@ -163,6 +163,10 @@ export function cancelMCPOAuth(serverName: string): Promise<m.CancelMCPOAuthResp
return request.post(endpoints.cancelMCPOAuth(serverName), {});
}
export function revokeMCPOAuth(serverName: string): Promise<m.RevokeMCPOAuthResponse> {
return request.post(endpoints.revokeMCPOAuth(serverName), {});
}
/* Config */
export const getStartupConfig = (): Promise<

View File

@@ -351,6 +351,21 @@ export const useCancelMCPOAuthMutation = (): UseMutationResult<
});
};
export const useRevokeMCPOAuthMutation = (): UseMutationResult<
m.RevokeMCPOAuthResponse,
unknown,
string,
unknown
> => {
const queryClient = useQueryClient();
return useMutation((serverName: string) => dataService.revokeMCPOAuth(serverName), {
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
queryClient.invalidateQueries([QueryKeys.tools]);
},
});
};
export const useGetCustomConfigSpeechQuery = (
config?: UseQueryOptions<t.TCustomConfigSpeechResponse>,
): QueryObserverResult<t.TCustomConfigSpeechResponse> => {

View File

@@ -378,3 +378,9 @@ export interface CancelMCPOAuthResponse {
success: boolean;
message: string;
}
export interface RevokeMCPOAuthResponse {
success: boolean;
message: string;
serverName: string;
}