diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js
index 99230afd00..e5c480c639 100644
--- a/api/server/controllers/UserController.js
+++ b/api/server/controllers/UserController.js
@@ -1,5 +1,10 @@
const { logger } = require('@librechat/data-schemas');
-const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
+const {
+ webSearchKeys,
+ extractWebSearchEnvVars,
+ normalizeHttpError,
+ MCPTokenStorage,
+} = require('@librechat/api');
const {
getFiles,
updateUser,
@@ -16,11 +21,17 @@ const { verifyEmail, resendVerificationEmail } = require('~/server/services/Auth
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { Tools, Constants, FileSources } = require('librechat-data-provider');
const { processDeleteRequest } = require('~/server/services/Files/process');
-const { Transaction, Balance, User } = require('~/db/models');
+const { Transaction, Balance, User, Token } = require('~/db/models');
const { getAppConfig } = require('~/server/services/Config');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteAllSharedLinks } = require('~/models');
const { getMCPManager } = require('~/config');
+const { MCPOAuthHandler } = require('@librechat/api');
+const { getFlowStateManager } = require('~/config');
+const { CacheKeys } = require('librechat-data-provider');
+const { getLogStores } = require('~/cache');
+const { clearMCPServerTools } = require('~/server/services/Config/mcpToolsCache');
+const { findToken } = require('~/models');
const getUserController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
@@ -162,6 +173,15 @@ const updateUserPluginsController = async (req, res) => {
);
({ status, message } = normalizeHttpError(authService));
}
+ try {
+ // if the MCP server uses OAuth, perform a full cleanup and token revocation
+ await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
+ } catch (error) {
+ logger.error(
+ `[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
+ error,
+ );
+ }
} else {
// This handles:
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
@@ -269,6 +289,97 @@ const resendVerificationController = async (req, res) => {
}
};
+/**
+ * OAuth MCP specific uninstall logic
+ */
+const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
+ if (!pluginKey.startsWith(Constants.mcp_prefix)) {
+ // this is not an MCP server, so nothing to do here
+ return;
+ }
+
+ const serverName = pluginKey.replace(Constants.mcp_prefix, '');
+ const mcpManager = getMCPManager(userId);
+ const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName];
+
+ if (!mcpManager.getOAuthServers().has(serverName)) {
+ // this server does not use OAuth, so nothing to do here as well
+ return;
+ }
+
+ // 1. get client info used for revocation (client id, secret)
+ const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken,
+ });
+ if (clientTokenData == null) {
+ return;
+ }
+ const { clientInfo, clientMetadata } = clientTokenData;
+
+ // 2. get decrypted tokens before deletion
+ const tokens = await MCPTokenStorage.getTokens({
+ userId,
+ serverName,
+ findToken,
+ });
+
+ // 3. revoke OAuth tokens at the provider
+ const revocationEndpoint =
+ serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
+ const revocationEndpointAuthMethodsSupported =
+ serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
+ clientMetadata.revocation_endpoint_auth_methods_supported;
+
+ if (tokens?.access_token) {
+ try {
+ await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', {
+ serverUrl: serverConfig.url,
+ clientId: clientInfo.client_id,
+ clientSecret: clientInfo.client_secret ?? '',
+ revocationEndpoint,
+ revocationEndpointAuthMethodsSupported,
+ });
+ } catch (error) {
+ logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
+ }
+ }
+
+ if (tokens?.refresh_token) {
+ try {
+ await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', {
+ serverUrl: serverConfig.url,
+ clientId: clientInfo.client_id,
+ clientSecret: clientInfo.client_secret ?? '',
+ revocationEndpoint,
+ revocationEndpointAuthMethodsSupported,
+ });
+ } catch (error) {
+ logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
+ }
+ }
+
+ // 4. delete tokens from the DB after revocation attempts
+ await MCPTokenStorage.deleteUserTokens({
+ userId,
+ serverName,
+ deleteToken: async (filter) => {
+ await Token.deleteOne(filter);
+ },
+ });
+
+ // 5. clear the flow state for the OAuth tokens
+ const flowsCache = getLogStores(CacheKeys.FLOWS);
+ const flowManager = getFlowStateManager(flowsCache);
+ const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
+ await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
+ await flowManager.deleteFlow(flowId, 'mcp_oauth');
+
+ // 6. clear the tools cache for the server
+ await clearMCPServerTools({ userId, serverName });
+};
+
module.exports = {
getUserController,
getTermsStatusController,
diff --git a/client/src/components/SidePanel/MCP/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPPanel.tsx
index 16e30e7526..7beee34d0c 100644
--- a/client/src/components/SidePanel/MCP/MCPPanel.tsx
+++ b/client/src/components/SidePanel/MCP/MCPPanel.tsx
@@ -1,5 +1,5 @@
import React, { useState, useMemo, useCallback } from 'react';
-import { ChevronLeft } from 'lucide-react';
+import { ChevronLeft, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Button, useToastContext } from '@librechat/client';
import { Constants, QueryKeys } from 'librechat-data-provider';
@@ -123,6 +123,7 @@ function MCPPanelContent() {
}
const serverStatus = connectionStatus?.[selectedServerNameForEditing];
+ const isConnected = serverStatus?.connectionState === 'connected';
return (
@@ -159,6 +160,17 @@ function MCPPanelContent() {
Object.keys(serverBeingEdited.config.customUserVars).length > 0
}
/>
+ {serverStatus?.requiresOAuth && isConnected && (
+
+ )}
);
} else {
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 75219abb88..60e226364f 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1046,6 +1046,7 @@
"com_ui_oauth_error_title": "Authentication Failed",
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
"com_ui_oauth_success_title": "Authentication Successful",
+ "com_ui_oauth_revoke": "Revoke",
"com_ui_of": "of",
"com_ui_off": "Off",
"com_ui_offline": "Offline",
diff --git a/packages/api/src/flow/manager.spec.ts b/packages/api/src/flow/manager.spec.ts
index af6420ffc4..b2be45acf9 100644
--- a/packages/api/src/flow/manager.spec.ts
+++ b/packages/api/src/flow/manager.spec.ts
@@ -149,4 +149,36 @@ describe('FlowStateManager', () => {
await expect(flowPromise).rejects.toThrow('failure');
}, 15000);
});
+
+ describe('deleteFlow', () => {
+ const flowId = 'test-flow-123';
+ const type = 'test-type';
+ const flowKey = `${type}:${flowId}`;
+
+ it('deletes an existing flow', async () => {
+ await store.set(flowKey, { type, status: 'PENDING', metadata: {}, createdAt: Date.now() });
+ expect(await store.get(flowKey)).toBeDefined();
+
+ const result = await flowManager.deleteFlow(flowId, type);
+
+ expect(result).toBe(true);
+ expect(await store.get(flowKey)).toBeUndefined();
+ });
+
+ it('returns false if the deletion errors', async () => {
+ jest.spyOn(store, 'delete').mockRejectedValue(new Error('Deletion failed'));
+
+ const result = await flowManager.deleteFlow(flowId, type);
+
+ expect(result).toBe(false);
+ });
+
+ it('does nothing if the flow does not exist', async () => {
+ expect(await store.get(flowKey)).toBeUndefined();
+
+ const result = await flowManager.deleteFlow(flowId, type);
+
+ expect(result).toBe(true);
+ });
+ });
});
diff --git a/packages/api/src/flow/manager.ts b/packages/api/src/flow/manager.ts
index f27dfbb64b..09ef40ec81 100644
--- a/packages/api/src/flow/manager.ts
+++ b/packages/api/src/flow/manager.ts
@@ -241,4 +241,19 @@ export class FlowStateManager {
throw error;
}
}
+
+ /**
+ * Deletes a flow state
+ */
+ async deleteFlow(flowId: string, type: string): Promise {
+ const flowKey = this.getFlowKey(flowId, type);
+ try {
+ await this.keyv.delete(flowKey);
+ logger.debug(`[${flowKey}] Flow deleted`);
+ return true;
+ } catch (error) {
+ logger.error(`[${flowKey}] Error deleting flow:`, error);
+ return false;
+ }
+ }
}
diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts
index 6be5c26a3b..2c63b81294 100644
--- a/packages/api/src/mcp/MCPConnectionFactory.ts
+++ b/packages/api/src/mcp/MCPConnectionFactory.ts
@@ -1,7 +1,7 @@
import { logger } from '@librechat/data-schemas';
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { TokenMethods } from '@librechat/data-schemas';
-import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from '~/mcp/oauth';
+import type { MCPOAuthTokens, MCPOAuthFlowMetadata, OAuthMetadata } from '~/mcp/oauth';
import type { FlowStateManager } from '~/flow/manager';
import type { FlowMetadata } from '~/flow/types';
import type * as t from './types';
@@ -186,6 +186,7 @@ export class MCPConnectionFactory {
updateToken: this.tokenMethods.updateToken,
findToken: this.tokenMethods.findToken,
clientInfo: result.clientInfo,
+ metadata: result.metadata,
});
logger.info(`${this.logPrefix} OAuth tokens saved to storage`);
} catch (error) {
@@ -284,6 +285,7 @@ export class MCPConnectionFactory {
protected async handleOAuthRequired(): Promise<{
tokens: MCPOAuthTokens | null;
clientInfo?: OAuthClientInformation;
+ metadata?: OAuthMetadata;
} | null> {
const serverUrl = (this.serverConfig as t.SSEOptions | t.StreamableHTTPOptions).url;
logger.debug(`${this.logPrefix} \`handleOAuthRequired\` called with serverUrl: ${serverUrl}`);
@@ -359,8 +361,13 @@ export class MCPConnectionFactory {
/** Client information from the flow metadata */
const clientInfo = flowMetadata?.clientInfo;
+ const metadata = flowMetadata?.metadata;
- return { tokens, clientInfo };
+ return {
+ tokens,
+ clientInfo,
+ metadata,
+ };
} catch (error) {
logger.error(`${this.logPrefix} Failed to complete OAuth flow for ${this.serverName}`, error);
return null;
diff --git a/packages/api/src/mcp/__tests__/handler.test.ts b/packages/api/src/mcp/__tests__/handler.test.ts
index e4a4075aaf..ce49d7eaf9 100644
--- a/packages/api/src/mcp/__tests__/handler.test.ts
+++ b/packages/api/src/mcp/__tests__/handler.test.ts
@@ -187,4 +187,195 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => {
);
});
});
+
+ describe('revokeOAuthToken', () => {
+ const mockServerName = 'test-server';
+ const mockToken = 'test-token-12345';
+
+ const originalFetch = global.fetch;
+ const mockFetch = jest.fn() as unknown as jest.MockedFunction;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ global.fetch = mockFetch;
+ });
+
+ afterEach(() => {
+ mockFetch.mockClear();
+ });
+
+ afterAll(() => {
+ global.fetch = originalFetch;
+ });
+
+ it('should successfully revoke an access token with client_secret_basic auth', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ revocationEndpoint: 'https://auth.example.com/oauth/revoke',
+ revocationEndpointAuthMethodsSupported: ['client_secret_basic'],
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ } as Response);
+
+ await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'access', metadata);
+
+ expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), {
+ method: 'POST',
+ body: 'token=test-token-12345&token_type_hint=access_token',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`,
+ },
+ });
+ });
+
+ it('should successfully revoke a refresh token with client_secret_basic auth', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ revocationEndpoint: 'https://auth.example.com/oauth/revoke',
+ revocationEndpointAuthMethodsSupported: ['client_secret_basic'],
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ } as Response);
+
+ await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
+
+ expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), {
+ method: 'POST',
+ body: 'token=test-token-12345&token_type_hint=refresh_token',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`,
+ },
+ });
+ });
+
+ it('should successfully revoke an access token with client_secret_post auth', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ revocationEndpoint: 'https://auth.example.com/oauth/revoke',
+ revocationEndpointAuthMethodsSupported: ['client_secret_post'],
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ } as Response);
+
+ await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'access', metadata);
+
+ expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), {
+ method: 'POST',
+ body: 'token=test-token-12345&token_type_hint=access_token&client_secret=test-client-secret&client_id=test-client-id',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ });
+ });
+
+ it('should fallback to /revoke endpoint when revocationEndpoint is not provided', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ } as Response);
+
+ await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ new URL('https://auth.example.com/revoke'),
+ expect.any(Object),
+ );
+ });
+
+ it('should default to client_secret_basic auth when revocationEndpointAuthMethodsSupported is not provided', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ revocationEndpoint: 'https://auth.example.com/oauth/revoke',
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ } as Response);
+
+ await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(URL),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: expect.stringMatching(/^Basic /),
+ }),
+ }),
+ );
+ });
+
+ it('should throw an error when the revocation request fails', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ revocationEndpoint: 'https://auth.example.com/oauth/revoke',
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 418,
+ } as Response);
+
+ await expect(
+ MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata),
+ ).rejects.toThrow('Token revocation failed: HTTP 418');
+ });
+
+ it('should prioritize client_secret_basic over other auth methods', async () => {
+ const metadata = {
+ serverUrl: 'https://auth.example.com',
+ clientId: 'test-client-id',
+ clientSecret: 'test-client-secret',
+ revocationEndpoint: 'https://auth.example.com/oauth/revoke',
+ revocationEndpointAuthMethodsSupported: [
+ 'client_secret_post',
+ 'client_secret_basic',
+ 'some_other_method',
+ ],
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ } as Response);
+
+ await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(URL),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: expect.stringMatching(/^Basic /),
+ }),
+ }),
+ );
+ });
+ });
});
diff --git a/packages/api/src/mcp/__tests__/tokens.test.ts b/packages/api/src/mcp/__tests__/tokens.test.ts
new file mode 100644
index 0000000000..f3f91afd1e
--- /dev/null
+++ b/packages/api/src/mcp/__tests__/tokens.test.ts
@@ -0,0 +1,193 @@
+import { MCPTokenStorage } from '~/mcp/oauth/tokens';
+import { decryptV2 } from '~/crypto';
+import type { TokenMethods, IToken } from '@librechat/data-schemas';
+import { Types } from 'mongoose';
+
+jest.mock('~/crypto', () => ({
+ decryptV2: jest.fn(),
+}));
+
+const mockDecryptV2 = decryptV2 as jest.MockedFunction;
+
+describe('MCPTokenStorage', () => {
+ afterAll(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('deleteUserTokens', () => {
+ const userId = '000000001111111122222222';
+ const serverName = 'test-server';
+ let mockDeleteToken: jest.MockedFunction<
+ (filter: { userId: string; type: string; identifier: string }) => Promise
+ >;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockDeleteToken = jest.fn().mockResolvedValue(undefined);
+ });
+
+ it('should delete all OAuth-related tokens for a user and server', async () => {
+ await MCPTokenStorage.deleteUserTokens({
+ userId,
+ serverName,
+ deleteToken: mockDeleteToken,
+ });
+
+ // Verify all three token types were deleted with correct identifiers
+ expect(mockDeleteToken).toHaveBeenCalledTimes(3);
+ expect(mockDeleteToken).toHaveBeenCalledWith({
+ userId,
+ type: 'mcp_oauth_client',
+ identifier: `mcp:${serverName}:client`,
+ });
+ expect(mockDeleteToken).toHaveBeenCalledWith({
+ userId,
+ type: 'mcp_oauth',
+ identifier: `mcp:${serverName}`,
+ });
+ expect(mockDeleteToken).toHaveBeenCalledWith({
+ userId,
+ type: 'mcp_oauth_refresh',
+ identifier: `mcp:${serverName}:refresh`,
+ });
+ });
+
+ it('should handle deletion errors gracefully', async () => {
+ mockDeleteToken.mockRejectedValueOnce(new Error('Deletion failed'));
+
+ await expect(
+ MCPTokenStorage.deleteUserTokens({
+ userId,
+ serverName,
+ deleteToken: mockDeleteToken,
+ }),
+ ).rejects.toThrow('Deletion failed');
+
+ expect(mockDeleteToken).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('getClientInfoAndMetadata', () => {
+ const userId = '000000001111111122222222';
+ const serverName = 'test-server';
+ const identifier = `mcp:${serverName}`;
+ let mockFindToken: jest.MockedFunction;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFindToken = jest.fn();
+ });
+
+ it('should return null when no client info token exists', async () => {
+ mockFindToken.mockResolvedValue(null);
+
+ const result = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken: mockFindToken,
+ });
+
+ expect(result).toBeNull();
+ expect(mockFindToken).toHaveBeenCalledWith({
+ userId,
+ type: 'mcp_oauth_client',
+ identifier: `${identifier}:client`,
+ });
+ });
+
+ it('should return client info and metadata when token exists', async () => {
+ const clientInfo = {
+ client_id: 'test-client-id',
+ client_secret: 'test-secret',
+ };
+
+ const metadata = new Map([
+ ['serverUrl', 'https://test.example.com'],
+ ['state', 'test-state'],
+ ]);
+
+ const mockToken: IToken = {
+ userId: new Types.ObjectId(userId),
+ type: 'mcp_oauth_client',
+ identifier: `${identifier}:client`,
+ token: 'encrypted-token',
+ metadata,
+ } as IToken;
+
+ mockFindToken.mockResolvedValue(mockToken);
+ mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
+
+ const result = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken: mockFindToken,
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.clientInfo).toEqual(clientInfo);
+ expect(result?.clientMetadata).toEqual({
+ serverUrl: 'https://test.example.com',
+ state: 'test-state',
+ });
+ expect(mockDecryptV2).toHaveBeenCalledWith('encrypted-token');
+ });
+
+ it('should handle empty metadata', async () => {
+ const clientInfo = {
+ client_id: 'test-client-id',
+ };
+
+ const mockToken: IToken = {
+ userId: new Types.ObjectId(userId),
+ type: 'mcp_oauth_client',
+ identifier: `${identifier}:client`,
+ token: 'encrypted-token',
+ } as IToken;
+
+ mockFindToken.mockResolvedValue(mockToken);
+ mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
+
+ const result = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken: mockFindToken,
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.clientInfo).toEqual(clientInfo);
+ expect(result?.clientMetadata).toEqual({});
+ });
+
+ it('should handle metadata as plain object', async () => {
+ const clientInfo = {
+ client_id: 'test-client-id',
+ };
+
+ const metadata = {
+ serverUrl: 'https://test.example.com',
+ state: 'test-state',
+ };
+
+ const mockToken: IToken = {
+ userId: new Types.ObjectId(userId),
+ type: 'mcp_oauth_client',
+ identifier: `${identifier}:client`,
+ token: 'encrypted-token',
+ metadata: metadata as unknown, // runtime check
+ } as IToken;
+
+ mockFindToken.mockResolvedValue(mockToken);
+ mockDecryptV2.mockResolvedValue(JSON.stringify(clientInfo));
+
+ const result = await MCPTokenStorage.getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken: mockFindToken,
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.clientInfo).toEqual(clientInfo);
+ expect(result?.clientMetadata).toEqual(metadata);
+ });
+ });
+});
diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts
index 5b61006a81..89ad0e2a6c 100644
--- a/packages/api/src/mcp/oauth/handler.ts
+++ b/packages/api/src/mcp/oauth/handler.ts
@@ -643,4 +643,68 @@ export class MCPOAuthHandler {
throw error;
}
}
+
+ /**
+ * Revokes OAuth tokens at the authorization server (RFC 7009)
+ */
+ public static async revokeOAuthToken(
+ serverName: string,
+ token: string,
+ tokenType: 'refresh' | 'access',
+ metadata: {
+ serverUrl: string;
+ clientId: string;
+ clientSecret: string;
+ revocationEndpoint?: string;
+ revocationEndpointAuthMethodsSupported?: string[];
+ },
+ ): Promise {
+ // build the revoke URL, falling back to the server URL + /revoke if no revocation endpoint is provided
+ const revokeUrl: URL =
+ metadata.revocationEndpoint != null
+ ? new URL(metadata.revocationEndpoint)
+ : new URL('/revoke', metadata.serverUrl);
+
+ // detect auth method to use
+ const authMethods = metadata.revocationEndpointAuthMethodsSupported ?? [
+ 'client_secret_basic', // RFC 8414 (https://datatracker.ietf.org/doc/html/rfc8414)
+ ];
+ const usesBasicAuth = authMethods.includes('client_secret_basic');
+ const usesClientSecretPost = authMethods.includes('client_secret_post');
+
+ // init the request headers
+ const headers: Record = {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ };
+
+ // init the request body
+ const body = new URLSearchParams({ token });
+ body.set('token_type_hint', tokenType === 'refresh' ? 'refresh_token' : 'access_token');
+
+ // process auth method
+ if (usesBasicAuth) {
+ // encode the client id and secret and add to the headers
+ const credentials = Buffer.from(`${metadata.clientId}:${metadata.clientSecret}`).toString(
+ 'base64',
+ );
+ headers['Authorization'] = `Basic ${credentials}`;
+ } else if (usesClientSecretPost) {
+ // add the client id and secret to the body
+ body.set('client_secret', metadata.clientSecret);
+ body.set('client_id', metadata.clientId);
+ }
+
+ // perform the revoke request
+ logger.info(`[MCPOAuth] Revoking tokens for ${serverName} via ${revokeUrl.toString()}`);
+ const response = await fetch(revokeUrl, {
+ method: 'POST',
+ body: body.toString(),
+ headers,
+ });
+
+ if (!response.ok) {
+ logger.error(`[MCPOAuth] Token revocation failed for ${serverName}: HTTP ${response.status}`);
+ throw new Error(`Token revocation failed: HTTP ${response.status}`);
+ }
+ }
}
diff --git a/packages/api/src/mcp/oauth/tokens.ts b/packages/api/src/mcp/oauth/tokens.ts
index 4aeb11482a..a212a7f333 100644
--- a/packages/api/src/mcp/oauth/tokens.ts
+++ b/packages/api/src/mcp/oauth/tokens.ts
@@ -1,7 +1,7 @@
import { logger } from '@librechat/data-schemas';
import type { OAuthTokens, OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
import type { TokenMethods, IToken } from '@librechat/data-schemas';
-import type { MCPOAuthTokens, ExtendedOAuthTokens } from './types';
+import type { MCPOAuthTokens, ExtendedOAuthTokens, OAuthMetadata } from './types';
import { encryptV2, decryptV2 } from '~/crypto';
import { isSystemUserId } from '~/mcp/enum';
@@ -13,6 +13,7 @@ interface StoreTokensParams {
updateToken?: TokenMethods['updateToken'];
findToken?: TokenMethods['findToken'];
clientInfo?: OAuthClientInformation;
+ metadata?: OAuthMetadata;
/** Optional: Pass existing token state to avoid duplicate DB calls */
existingTokens?: {
accessToken?: IToken | null;
@@ -55,6 +56,7 @@ export class MCPTokenStorage {
findToken,
clientInfo,
existingTokens,
+ metadata,
}: StoreTokensParams): Promise {
const logPrefix = this.getLogPrefix(userId, serverName);
@@ -188,6 +190,7 @@ export class MCPTokenStorage {
identifier: `${identifier}:client`,
token: encryptedClientInfo,
expiresIn: 365 * 24 * 60 * 60,
+ metadata,
};
// Check if client info already exists and update if it does
@@ -379,4 +382,86 @@ export class MCPTokenStorage {
return null;
}
}
+
+ static async getClientInfoAndMetadata({
+ userId,
+ serverName,
+ findToken,
+ }: {
+ userId: string;
+ serverName: string;
+ findToken: TokenMethods['findToken'];
+ }): Promise<{
+ clientInfo: OAuthClientInformation;
+ clientMetadata: Record;
+ } | null> {
+ const identifier = `mcp:${serverName}`;
+
+ const clientInfoData: IToken | null = await findToken({
+ userId,
+ type: 'mcp_oauth_client',
+ identifier: `${identifier}:client`,
+ });
+ if (clientInfoData == null) {
+ return null;
+ }
+
+ const tokenData = await decryptV2(clientInfoData.token);
+ const clientInfo = JSON.parse(tokenData);
+
+ // get metadata from the token as a plain object. While it's defined as a Map in the database type, it's a plain object at runtime.
+ function getMetadata(
+ metadata: Map | Record | null,
+ ): Record {
+ if (metadata == null) {
+ return {};
+ }
+ if (metadata instanceof Map) {
+ return Object.fromEntries(metadata);
+ }
+ return { ...(metadata as Record) };
+ }
+ const clientMetadata = getMetadata(clientInfoData.metadata ?? null);
+
+ return {
+ clientInfo,
+ clientMetadata,
+ };
+ }
+
+ /**
+ * Deletes all OAuth-related tokens for a specific user and server
+ */
+ static async deleteUserTokens({
+ userId,
+ serverName,
+ deleteToken,
+ }: {
+ userId: string;
+ serverName: string;
+ deleteToken: (filter: { userId: string; type: string; identifier: string }) => Promise;
+ }): Promise {
+ const identifier = `mcp:${serverName}`;
+
+ // delete client info token
+ await deleteToken({
+ userId,
+ type: 'mcp_oauth_client',
+ identifier: `${identifier}:client`,
+ });
+
+ // delete access token
+ await deleteToken({
+ userId,
+ type: 'mcp_oauth',
+ identifier,
+ });
+
+ // delete refresh token
+ await deleteToken({
+ userId,
+ type: 'mcp_oauth_refresh',
+ identifier: `${identifier}:refresh`,
+ });
+ }
}
diff --git a/packages/api/src/mcp/oauth/types.ts b/packages/api/src/mcp/oauth/types.ts
index 7fa9d69921..2ea4916807 100644
--- a/packages/api/src/mcp/oauth/types.ts
+++ b/packages/api/src/mcp/oauth/types.ts
@@ -18,6 +18,10 @@ export interface OAuthMetadata {
token_endpoint_auth_methods_supported?: string[];
/** Code challenge methods supported */
code_challenge_methods_supported?: string[];
+ /** Revocation endpoint */
+ revocation_endpoint?: string;
+ /** Revocation endpoint auth methods supported */
+ revocation_endpoint_auth_methods_supported?: string[];
}
export interface OAuthProtectedResourceMetadata {
diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts
index e908d9f90a..58d70ac118 100644
--- a/packages/data-provider/src/mcp.ts
+++ b/packages/data-provider/src/mcp.ts
@@ -56,6 +56,10 @@ const BaseOptionsSchema = z.object({
response_types_supported: z.array(z.string()).optional(),
/** Supported code challenge methods (defaults to ['S256', 'plain']) */
code_challenge_methods_supported: z.array(z.string()).optional(),
+ /** OAuth revocation endpoint (optional - can be auto-discovered) */
+ revocation_endpoint: z.string().url().optional(),
+ /** OAuth revocation endpoint authentication methods supported (optional - can be auto-discovered) */
+ revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
})
.optional(),
customUserVars: z