Compare commits

...

7 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
Marco Beretta
8a1a38f346 🔑 fix: Update Conversation Mutation to use ID from Payload (#8758) 2025-07-30 14:34:30 -04:00
Danny Avila
32081245da 🪵 chore: Remove Unnecessary Comments 2025-07-29 14:59:58 -04:00
Dustin Healy
6fd3b569ac ⚒️ fix: MCP Initialization Flows (#8734)
* fix: add OAuth flow back in to success state

* feat: disable server clicks during initialization to prevent spam

* fix: correct new tab behavior for OAuth between one-click and normal initialization flows

* fix: stop polling on error during oauth (was infinite popping toasts because we didn't clear interval)

* fix: cleanupServerState should be called after successful cancelOauth, not before

* fix: change from completeFlow to failFlow to avoid stale client IDs on OAuth after cancellation

* fix: add logic to differentiate between cancelled and failed flows when checking status for indicators (so error triangle indicator doesn't show up on cancellaiton)
2025-07-29 14:54:07 -04:00
Jakub Hrozek
6671fcb714 🛂 refactor: Use discoverAuthorizationServerMetadata for MCP OAuth (#8723)
* Use discoverAuthorizationServerMetadata instead of discoverMetadata

Uses the discoverAuthorizationServerMetadata function from the upstream
TS SDK. This has the advantage of falling back to OIDC discovery
metadata if the OAuth discovery metadata doesn't exist which is the case
with e.g. keycloak.

* chore: import order

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2025-07-29 09:09:52 -04:00
Dustin Healy
c4677ab3fb 🔑 refactor: MCP Settings Rendering Logic for OAuth Servers (#8718)
* feat: add OAuth servers to conditional rendering logic for MCPPanel in SideNav

* feat: add startup flag check to conditional rendering logic

* fix: correct improper handling of failure state in reinitialize endpoint

* fix: change MCP config components to better handle servers without customUserVars

- removes the subtle reinitialize button from config components of servers without customUserVars or OAuth
- adds a placeholder message for components where servers have no customUserVars configured

* style: swap CustomUserVarsSection and ServerInitializationSection positions

* style: fix coloring for light mode and align more with existing design patterns

* chore: remove extraneous comments

* chore: reorder imports and `isEnabled` from api package

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-07-29 09:08:46 -04:00
23 changed files with 412 additions and 108 deletions

View File

@@ -1,10 +1,11 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getProjectByName } = require('~/models/Project');
const { isEnabled } = require('~/server/utils');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = express.Router();
@@ -102,11 +103,16 @@ router.get('/', async function (req, res) {
payload.mcpServers = {};
const config = await getCustomConfig();
if (config?.mcpServers != null) {
const mcpManager = getMCPManager();
const oauthServers = mcpManager.getOAuthServers();
for (const serverName in config.mcpServers) {
const serverConfig = config.mcpServers[serverName];
payload.mcpServers[serverName] = {
customUserVars: serverConfig?.customUserVars || {},
chatMenu: serverConfig?.chatMenu,
isOAuth: oauthServers.has(serverName),
startup: serverConfig?.startup,
};
}
}

View File

@@ -93,7 +93,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
return res.redirect('/oauth/error?error=missing_state');
}
// Extract flow ID from state
const flowId = state;
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
@@ -116,22 +115,17 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
hasCodeVerifier: !!flowState.codeVerifier,
});
// Complete the OAuth flow
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
// Try to establish the MCP connection with the new tokens
try {
const mcpManager = getMCPManager(flowState.userId);
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
// For user-level OAuth, try to establish the connection
if (flowState.userId !== 'system') {
// We need to get the user object - in this case we'll need to reconstruct it
const user = { id: flowState.userId };
// Try to establish connection with the new tokens
const userConnection = await mcpManager.getUserConnection({
user,
serverName,
@@ -148,10 +142,8 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
// Fetch and cache tools now that we have a successful connection
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
// Remove any old tools from this server in the user's cache
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
@@ -159,7 +151,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
}
}
// Add the new tools from this server
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
@@ -173,7 +164,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
};
}
// Save the updated user tool cache
await setCachedTools(userTools, { userId: flowState.userId });
logger.debug(
@@ -183,7 +173,6 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
} catch (error) {
// Don't fail the OAuth callback if reconnection fails - the tokens are still saved
logger.warn(
`[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
error,
@@ -219,7 +208,6 @@ router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
return res.status(401).json({ error: 'User not authenticated' });
}
// Allow system flows or user-owned flows
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
return res.status(403).json({ error: 'Access denied' });
}
@@ -287,11 +275,7 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
// Generate the flow ID for this user/server combination
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
// Check if flow exists
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
if (!flowState) {
@@ -302,8 +286,7 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
});
}
// Cancel the flow by marking it as failed
await flowManager.completeFlow(flowId, 'mcp_oauth', null, 'User cancelled OAuth flow');
await flowManager.failFlow(flowId, 'mcp_oauth', 'User cancelled OAuth flow');
logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
@@ -317,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
@@ -379,8 +476,7 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
createToken,
deleteTokens,
},
returnOnOAuth: true, // Return immediately when OAuth is initiated
// Add OAuth handlers to capture the OAuth URL when needed
returnOnOAuth: true,
oauthStart: async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
oauthUrl = authURL;
@@ -395,7 +491,6 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
// Check if this is an OAuth error - if so, the flow state should be set up now
const isOAuthError =
err.message?.includes('OAuth') ||
err.message?.includes('authentication') ||
@@ -408,7 +503,6 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
);
oauthRequired = true;
// Don't return error - continue so frontend can handle OAuth
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
@@ -418,11 +512,9 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
}
}
// Only fetch and cache tools if we successfully connected (no OAuth required)
if (userConnection && !oauthRequired) {
const userTools = (await getCachedTools({ userId: user.id })) || {};
// Remove any old tools from this server in the user's cache
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
@@ -430,7 +522,6 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
}
}
// Add the new tools from this server
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
@@ -444,7 +535,6 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
};
}
// Save the updated user tool cache
await setCachedTools(userTools, { userId: user.id });
}
@@ -452,11 +542,19 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const getResponseMessage = () => {
if (oauthRequired) {
return `MCP server '${serverName}' ready for OAuth authentication`;
}
if (userConnection) {
return `MCP server '${serverName}' reinitialized successfully`;
}
return `Failed to reinitialize MCP server '${serverName}'`;
};
res.json({
success: true,
message: oauthRequired
? `MCP server '${serverName}' ready for OAuth authentication`
: `MCP server '${serverName}' reinitialized successfully`,
success: (userConnection && !oauthRequired) || (oauthRequired && oauthUrl),
message: getResponseMessage(),
serverName,
oauthRequired,
oauthUrl,
@@ -585,19 +683,16 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
const authValueFlags = {};
// Check existence of saved values for each custom user variable (don't fetch actual values)
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);
// Only store boolean flag indicating if value exists
authValueFlags[varName] = !!(value && value.length > 0);
} catch (err) {
logger.error(
`[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
err,
);
// Default to false if we can't check
authValueFlags[varName] = false;
}
}

View File

@@ -287,14 +287,26 @@ async function checkOAuthFlowStatus(userId, serverName) {
const flowTTL = flowState.ttl || 180000; // Default 3 minutes
if (flowState.status === 'FAILED' || flowAge > flowTTL) {
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
flowAge,
flowTTL,
timedOut: flowAge > flowTTL,
});
return { hasActiveFlow: false, hasFailedFlow: true };
const wasCancelled = flowState.error && flowState.error.includes('cancelled');
if (wasCancelled) {
logger.debug(`[MCP Connection Status] Found cancelled OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
error: flowState.error,
});
return { hasActiveFlow: false, hasFailedFlow: false };
} else {
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
flowId,
status: flowState.status,
flowAge,
flowTTL,
timedOut: flowAge > flowTTL,
error: flowState.error,
});
return { hasActiveFlow: false, hasFailedFlow: true };
}
}
if (flowState.status === 'PENDING') {
@@ -327,7 +339,7 @@ async function getServerConnectionStatus(
serverName,
appConnections,
userConnections,
oauthServers,
_oauthServers,
) {
const getConnectionState = () =>
appConnections.get(serverName)?.connectionState ??
@@ -337,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) {
@@ -347,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

@@ -13,6 +13,7 @@ function MCPSelect() {
batchToggleServers,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
localize,
} = useMCPServerManager();
@@ -32,14 +33,20 @@ function MCPSelect() {
const renderItemContent = useCallback(
(serverName: string, defaultContent: React.ReactNode) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isServerInitializing = isInitializing(serverName);
// Common wrapper for the main content (check mark + text)
// Ensures Check & Text are adjacent and the group takes available space.
/**
Common wrapper for the main content (check mark + text).
Ensures Check & Text are adjacent and the group takes available space.
*/
const mainContentWrapper = (
<button
type="button"
className="flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none"
className={`flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none ${
isServerInitializing ? 'opacity-50' : ''
}`}
tabIndex={0}
disabled={isServerInitializing}
>
{defaultContent}
</button>
@@ -58,15 +65,13 @@ function MCPSelect() {
return mainContentWrapper;
},
[getServerStatusIconProps],
[getServerStatusIconProps, isInitializing],
);
// Don't render if no servers are selected and not pinned
if ((!mcpValues || mcpValues.length === 0) && !isPinned) {
return null;
}
// Don't render if no MCP servers are configured
if (!configuredServers || configuredServers.length === 0) {
return null;
}

View File

@@ -22,6 +22,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
isInitializing,
} = useMCPServerManager();
const menuStore = Ariakit.useMenuStore({
@@ -86,6 +87,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
{configuredServers.map((serverName) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isSelected = mcpValues?.includes(serverName) ?? false;
const isServerInitializing = isInitializing(serverName);
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
@@ -96,12 +98,15 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
event.preventDefault();
toggleServerSelection(serverName);
}}
disabled={isServerInitializing}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 justify-between text-sm',
isServerInitializing &&
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
)}
>
<div className="flex flex-grow items-center gap-2">

View File

@@ -110,13 +110,15 @@ export default function CustomUserVarsSection({
const handleRevokeClick = () => {
onRevoke();
// Reset form after revoke
reset();
};
// Don't render if no fields to configure
if (!fields || Object.keys(fields).length === 0) {
return null;
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_custom_vars', { '0': serverName })}
</div>
);
}
return (

View File

@@ -128,10 +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={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

@@ -7,11 +7,13 @@ import { useLocalize } from '~/hooks';
interface ServerInitializationSectionProps {
serverName: string;
requiresOAuth: boolean;
hasCustomUserVars?: boolean;
}
export default function ServerInitializationSection({
serverName,
requiresOAuth,
hasCustomUserVars = false,
}: ServerInitializationSectionProps) {
const localize = useLocalize();
@@ -20,6 +22,7 @@ export default function ServerInitializationSection({
initializeServer,
connectionStatus,
cancelOAuthFlow,
revokeOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
@@ -32,15 +35,14 @@ export default function ServerInitializationSection({
const serverOAuthUrl = getOAuthUrl(serverName);
const handleInitializeClick = useCallback(() => {
initializeServer(serverName);
initializeServer(serverName, false);
}, [initializeServer, serverName]);
const handleCancelClick = useCallback(() => {
cancelOAuthFlow(serverName);
}, [cancelOAuthFlow, serverName]);
// Show subtle reinitialize option if connected
if (isConnected) {
if (isConnected && (requiresOAuth || hasCustomUserVars)) {
return (
<div className="flex justify-start">
<button
@@ -55,11 +57,15 @@ export default function ServerInitializationSection({
);
}
if (isConnected) {
return null;
}
return (
<div className="rounded-lg border border-[#991b1b] bg-[#2C1315] p-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{requiresOAuth
? localize('com_ui_mcp_not_authenticated', { 0: serverName })
: localize('com_ui_mcp_not_initialized', { 0: serverName })}
@@ -70,7 +76,7 @@ export default function ServerInitializationSection({
<Button
onClick={handleInitializeClick}
disabled={isServerInitializing}
className="flex items-center gap-2 bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
className="btn btn-primary focus:shadow-outline flex w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"
>
{isServerInitializing ? (
<>
@@ -103,7 +109,7 @@ export default function ServerInitializationSection({
<div className="flex items-center gap-2">
<Button
onClick={() => window.open(serverOAuthUrl, '_blank', 'noopener,noreferrer')}
className="flex-1 bg-blue-600 text-white hover:bg-blue-700 dark:hover:bg-blue-800"
className="flex-1 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
>
{localize('com_ui_continue_oauth')}
</Button>

View File

@@ -141,29 +141,31 @@ function MCPPanelContent() {
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
</h3>
{/* Server Initialization Section */}
<div className="mb-4">
<ServerInitializationSection
<CustomUserVarsSection
serverName={selectedServerNameForEditing}
requiresOAuth={serverStatus?.requiresOAuth || false}
fields={serverBeingEdited.config.customUserVars}
onSave={(authData) => {
if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData);
}
}}
onRevoke={() => {
if (selectedServerNameForEditing) {
handleConfigRevoke(selectedServerNameForEditing);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
/>
</div>
{/* Custom User Variables Section */}
<CustomUserVarsSection
<ServerInitializationSection
serverName={selectedServerNameForEditing}
fields={serverBeingEdited.config.customUserVars}
onSave={(authData) => {
if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData);
}
}}
onRevoke={() => {
if (selectedServerNameForEditing) {
handleConfigRevoke(selectedServerNameForEditing);
}
}}
isSubmitting={updateUserPluginsMutation.isLoading}
requiresOAuth={serverStatus?.requiresOAuth || false}
hasCustomUserVars={
serverBeingEdited.config.customUserVars &&
Object.keys(serverBeingEdited.config.customUserVars).length > 0
}
/>
</div>
);

View File

@@ -55,9 +55,10 @@ export const useUpdateConversationMutation = (
return useMutation(
(payload: t.TUpdateConversationRequest) => dataService.updateConversation(payload),
{
onSuccess: (updatedConvo) => {
queryClient.setQueryData([QueryKeys.conversation, id], updatedConvo);
updateConvoInAllQueries(queryClient, id, () => updatedConvo);
onSuccess: (updatedConvo, payload) => {
const targetId = payload.conversationId || id;
queryClient.setQueryData([QueryKeys.conversation, targetId], updatedConvo);
updateConvoInAllQueries(queryClient, targetId, () => updatedConvo);
},
},
);

View File

@@ -1,17 +1,17 @@
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,
useReinitializeMCPServerMutation,
useCancelMCPOAuthMutation,
} from 'librechat-data-provider/react-query';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
import { useLocalize } from '~/hooks';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import { useBadgeRowContext } from '~/Providers';
import { useLocalize } from '~/hooks';
interface ServerState {
isInitializing: boolean;
@@ -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' });
@@ -171,6 +182,7 @@ export function useMCPServerManager() {
message: localize('com_ui_mcp_oauth_timeout', { 0: serverName }),
status: 'error',
});
clearInterval(pollInterval);
cleanupServerState(serverName);
return;
}
@@ -180,10 +192,15 @@ export function useMCPServerManager() {
message: localize('com_ui_mcp_init_failed'),
status: 'error',
});
clearInterval(pollInterval);
cleanupServerState(serverName);
return;
}
} catch (error) {
console.error(`[MCP Manager] Error polling server ${serverName}:`, error);
clearInterval(pollInterval);
cleanupServerState(serverName);
return;
}
}, 3500);
@@ -201,7 +218,7 @@ export function useMCPServerManager() {
);
const initializeServer = useCallback(
async (serverName: string) => {
async (serverName: string, autoOpenOAuth: boolean = true) => {
updateServerState(serverName, { isInitializing: true });
try {
@@ -216,7 +233,9 @@ export function useMCPServerManager() {
isInitializing: true,
});
window.open(response.oauthUrl, '_blank', 'noopener,noreferrer');
if (autoOpenOAuth) {
window.open(response.oauthUrl, '_blank', 'noopener,noreferrer');
}
startServerPolling(serverName);
} else {
@@ -234,6 +253,12 @@ export function useMCPServerManager() {
cleanupServerState(serverName);
}
} else {
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
cleanupServerState(serverName);
}
} catch (error) {
console.error(`[MCP Manager] Failed to initialize ${serverName}:`, error);
@@ -259,18 +284,60 @@ export function useMCPServerManager() {
const cancelOAuthFlow = useCallback(
(serverName: string) => {
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
cleanupServerState(serverName);
cancelOAuthMutation.mutate(serverName);
cancelOAuthMutation.mutate(serverName, {
onSuccess: () => {
cleanupServerState(serverName);
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
showToast({
message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }),
status: 'warning',
showToast({
message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }),
status: 'warning',
});
},
onError: (error) => {
console.error(`[MCP Manager] Failed to cancel OAuth for ${serverName}:`, error);
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
},
});
},
[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;
@@ -303,6 +370,10 @@ export function useMCPServerManager() {
const disconnectedServers: string[] = [];
serverNames.forEach((serverName) => {
if (isInitializing(serverName)) {
return;
}
const serverStatus = connectionStatus[serverName];
if (serverStatus?.connectionState === 'connected') {
connectedServers.push(serverName);
@@ -317,11 +388,15 @@ export function useMCPServerManager() {
initializeServer(serverName);
});
},
[connectionStatus, setMCPValues, initializeServer],
[connectionStatus, setMCPValues, initializeServer, isInitializing],
);
const toggleServerSelection = useCallback(
(serverName: string) => {
if (isInitializing(serverName)) {
return;
}
const currentValues = mcpValues ?? [];
const isCurrentlySelected = currentValues.includes(serverName);
@@ -337,7 +412,7 @@ export function useMCPServerManager() {
}
}
},
[mcpValues, setMCPValues, connectionStatus, initializeServer],
[mcpValues, setMCPValues, connectionStatus, initializeServer, isInitializing],
);
const handleConfigSave = useCallback(
@@ -504,6 +579,7 @@ export function useMCPServerManager() {
connectionStatus,
initializeServer,
cancelOAuthFlow,
revokeOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,

View File

@@ -155,7 +155,10 @@ export default function useSideNavLinks({
if (
startupConfig?.mcpServers &&
Object.values(startupConfig.mcpServers).some(
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
(server: any) =>
(server.customUserVars && Object.keys(server.customUserVars).length > 0) ||
server.isOAuth ||
server.startup === false,
)
) {
links.push({

View File

@@ -506,6 +506,7 @@
"com_sidepanel_hide_panel": "Hide Panel",
"com_sidepanel_manage_files": "Manage Files",
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
"com_sidepanel_mcp_no_custom_vars": "No custom user variables set for {{0}}",
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
"com_sidepanel_parameters": "Parameters",
"com_sources_image_alt": "Search result image",
@@ -851,7 +852,6 @@
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
"com_ui_mcp_dialog_desc": "Please enter the necessary information below.",
"com_ui_mcp_enter_var": "Enter value for {{0}}",
"com_ui_mcp_init_cancelled": "MCP server '{{0}}' initialization was cancelled due to simultaneous request",
"com_ui_mcp_init_failed": "Failed to initialize MCP server",
"com_ui_mcp_initialize": "Initialize",
"com_ui_mcp_initialized_success": "MCP server '{{0}}' initialized successfully",

2
package-lock.json generated
View File

@@ -51804,7 +51804,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.7.902",
"version": "0.7.903",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",

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

@@ -1,10 +1,10 @@
import { randomBytes } from 'crypto';
import { logger } from '@librechat/data-schemas';
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
discoverAuthorizationServerMetadata,
discoverOAuthProtectedResourceMetadata,
} from '@modelcontextprotocol/sdk/client/auth.js';
import { OAuthMetadataSchema } from '@modelcontextprotocol/sdk/shared/auth.js';
@@ -61,7 +61,7 @@ export class MCPOAuthHandler {
// Discover OAuth metadata
logger.debug(`[MCPOAuth] Discovering OAuth metadata from ${authServerUrl}`);
const rawMetadata = await discoverOAuthMetadata(authServerUrl);
const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl);
if (!rawMetadata) {
logger.error(`[MCPOAuth] Failed to discover OAuth metadata from ${authServerUrl}`);
@@ -466,7 +466,10 @@ export class MCPOAuthHandler {
throw new Error('No token URL available for refresh');
} else {
/** Auto-discover OAuth configuration for refresh */
const { metadata: oauthMetadata } = await this.discoverMetadata(metadata.serverUrl);
const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl);
if (!oauthMetadata) {
throw new Error('Failed to discover OAuth metadata for token refresh');
}
if (!oauthMetadata.token_endpoint) {
throw new Error('No token endpoint found in OAuth metadata');
}
@@ -584,7 +587,7 @@ export class MCPOAuthHandler {
}
/** Auto-discover OAuth configuration for refresh */
const { metadata: oauthMetadata } = await this.discoverMetadata(metadata.serverUrl);
const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl);
if (!oauthMetadata.token_endpoint) {
throw new Error('No token endpoint found in OAuth metadata');

View File

@@ -1,6 +1,6 @@
{
"name": "librechat-data-provider",
"version": "0.7.902",
"version": "0.7.903",
"description": "data services for librechat apps",
"main": "dist/index.js",
"module": "dist/index.es.js",

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

@@ -613,6 +613,8 @@ export type TStartupConfig = {
}
>;
chatMenu?: boolean;
isOAuth?: boolean;
startup?: boolean;
}
>;
mcpPlaceholder?: string;

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;
}