Compare commits
2 Commits
fix/favori
...
feat/revok
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b1d804391 | ||
|
|
f25407768e |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@ export default function ServerInitializationSection({
|
||||
initializeServer,
|
||||
connectionStatus,
|
||||
cancelOAuthFlow,
|
||||
revokeOAuthFlow,
|
||||
isInitializing,
|
||||
isCancellable,
|
||||
getOAuthUrl,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -378,3 +378,9 @@ export interface CancelMCPOAuthResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RevokeMCPOAuthResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user