🎟️ fix: Reconcile MCP OAuth Readiness Across Pods (#14629)

* fix: stabilize MCP OAuth readiness across pods

* fix: harden MCP readiness review findings

* fix: resolve CI type check and terminal OAuth polling

* fix: address MCP OAuth readiness review

* fix: align MCP OAuth readiness state

* test: stabilize MCP OAuth readiness assertion

* fix: reject stale MCP OAuth callbacks

* fix: close distributed MCP OAuth readiness gaps

* style: sort Redis MCP test imports

* fix: preserve MCP OAuth polling across rolling pods

* fix: finalize distributed MCP OAuth readiness

* fix: preserve runtime-detected MCP OAuth

* fix: report runtime MCP OAuth readiness

* fix: preserve live MCP OAuth classification

* style: sort MCP connection imports
This commit is contained in:
Danny Avila 2026-08-05 19:42:26 -04:00 committed by GitHub
parent 9b7a30743b
commit 56175af0b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2352 additions and 111 deletions

View file

@ -5,10 +5,12 @@
* @import { MCPServerRegistry } from '@librechat/api'
* @import { MCPServerDocument } from 'librechat-data-provider'
*/
const { randomUUID } = require('crypto');
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const {
checkAccess,
isUserSourced,
MCPConnection,
MCPErrorCodes,
splitMCPToolKey,
normalizeServerName,
@ -403,13 +405,19 @@ const createMCPServerController = async (req, res) => {
const reservedServerNames = [
...new Set([...configNames, ...configNames.map(normalizeServerName)]),
];
const result = await getMCPServersRegistry().addServer(
'temp_server_name',
validation.data,
'DB',
userId,
reservedServerNames,
);
const inspectionServerName = `temp_server_${randomUUID()}`;
let result;
try {
result = await getMCPServersRegistry().addServer(
inspectionServerName,
validation.data,
'DB',
userId,
reservedServerNames,
);
} finally {
MCPConnection.clearCooldown(inspectionServerName);
}
res.status(201).json({
serverName: result.serverName,
...redactServerSecrets(result.config, { canEdit: true }),

View file

@ -61,6 +61,9 @@ jest.mock('@librechat/api', () => {
getTokens: jest.fn(),
deleteUserTokens: jest.fn(),
},
MCPConnection: {
clearCooldown: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
generateCheckAccess: jest.fn(({ permissionType, permissions }) => (req, res, next) => {
const { PermissionTypes, Permissions } = require('librechat-data-provider');
@ -1181,7 +1184,7 @@ describe('MCP Routes', () => {
it('should handle OAuth callback successfully', async () => {
// mockRegistryInstance is defined at the top of the file
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -1275,7 +1278,7 @@ describe('MCP Routes', () => {
it('should clear tenant-scoped token flow state after storing callback tokens', async () => {
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -1349,7 +1352,7 @@ describe('MCP Routes', () => {
status: 'PENDING',
});
}
return Promise.resolve({ status: 'PENDING' });
return Promise.resolve({ status: 'PENDING', createdAt: Date.now() });
}),
completeFlow: jest.fn().mockResolvedValue(true),
deleteFlow: jest.fn().mockResolvedValue(true),
@ -1415,7 +1418,7 @@ describe('MCP Routes', () => {
it('should use oauthHeaders from flow state when present', async () => {
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -1467,7 +1470,7 @@ describe('MCP Routes', () => {
it('should fall back to registry oauth_headers when flow state lacks them', async () => {
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -1544,7 +1547,7 @@ describe('MCP Routes', () => {
it('should handle system-level OAuth completion', async () => {
// mockRegistryInstance is defined at the top of the file
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -1588,7 +1591,7 @@ describe('MCP Routes', () => {
it('should handle reconnection failure after OAuth', async () => {
// mockRegistryInstance is defined at the top of the file
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn().mockResolvedValue(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -1716,7 +1719,7 @@ describe('MCP Routes', () => {
// First call checks idempotency (status PENDING = not completed)
// Second call retrieves flow state for processing
mockFlowManager.getFlowState
.mockResolvedValueOnce({ status: 'PENDING' })
.mockResolvedValueOnce({ status: 'PENDING', createdAt: Date.now() })
.mockResolvedValueOnce(flowState);
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
@ -1803,6 +1806,72 @@ describe('MCP Routes', () => {
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
});
it('should reject a retained failed flow without exchanging or storing tokens', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'FAILED',
error: 'mcp_oauth flow timed out',
}),
};
MCPOAuthHandler.getFlowState.mockResolvedValue({
state: flowId,
serverName: 'test-server',
userId: 'test-user-id',
metadata: {},
clientInfo: {},
codeVerifier: 'test-verifier',
});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({ code: 'late-auth-code', state: flowId });
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
});
it('should reject an over-age pending flow without relying on its original monitor', async () => {
const flowId = 'test-user-id:test-server';
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'PENDING',
createdAt: Date.now() - PENDING_STALE_MS - 1000,
}),
};
MCPOAuthHandler.getFlowState.mockResolvedValue({
state: flowId,
serverName: 'test-server',
userId: 'test-user-id',
metadata: {},
clientInfo: {},
codeVerifier: 'test-verifier',
});
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const csrfToken = generateTestCsrfToken(flowId);
const response = await request(app)
.get('/api/mcp/test-server/oauth/callback')
.set('Cookie', [`oauth_csrf=${csrfToken}`])
.query({ code: 'late-auth-code', state: flowId });
const basePath = getBasePath();
expect(response.status).toBe(302);
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
});
});
describe('GET /oauth/tokens/:flowId', () => {
@ -1968,6 +2037,28 @@ describe('MCP Routes', () => {
});
});
it('should return retained timeout failures as terminal status', async () => {
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({
status: 'FAILED',
error: 'mcp_oauth flow timed out',
}),
};
getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
const response = await request(app).get('/api/mcp/oauth/status/test-user-id:test-server');
expect(response.status).toBe(200);
expect(response.body).toEqual({
status: 'FAILED',
completed: false,
failed: true,
error: 'mcp_oauth flow timed out',
});
});
it('should return flow status for a tenant-prefixed flow owned by the user', async () => {
const { getTenantId } = require('@librechat/data-schemas');
const mockFlowManager = {
@ -2183,9 +2274,36 @@ describe('MCP Routes', () => {
serverName: 'oauth-server',
oauthRequired: true,
oauthUrl: 'https://oauth.example.com/auth',
flowId: 'test-user-id:oauth-server',
oauthTimeout: expect.any(Number),
});
});
it('should return the remaining lifetime for a reused OAuth flow', async () => {
const now = 1_800_000_000_000;
const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(now);
const mockMcpManager = {
disconnectUserConnection: jest.fn().mockResolvedValue(),
};
mockRegistryInstance.getServerConfig.mockResolvedValue({ customUserVars: {} });
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'oauth-server' ready for OAuth authentication",
serverName: 'oauth-server',
oauthRequired: true,
oauthUrl: 'https://oauth.example.com/auth',
oauthExpiresAt: now + 45_000,
});
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
dateNowSpy.mockRestore();
expect(response.status).toBe(200);
expect(response.body.oauthTimeout).toBe(45_000);
});
it('should return structured reinitialization failure details', async () => {
const mockMcpManager = {
disconnectUserConnection: jest.fn().mockResolvedValue(),
@ -2386,17 +2504,19 @@ describe('MCP Routes', () => {
mcpConfig: mockMcpConfig,
appConnections: {},
userConnections: {},
oauthServers: [],
oauthServers: new Set(),
});
getServerConnectionStatus
.mockResolvedValueOnce({
connectionState: 'connected',
requiresOAuth: false,
authorizationState: 'not_required',
})
.mockResolvedValueOnce({
connectionState: 'disconnected',
requiresOAuth: true,
authorizationState: 'needs_authorization',
});
const response = await request(app).get('/api/mcp/connection/status');
@ -2409,10 +2529,12 @@ describe('MCP Routes', () => {
server1: {
connectionState: 'connected',
requiresOAuth: false,
authorizationState: 'not_required',
},
server2: {
connectionState: 'disconnected',
requiresOAuth: true,
authorizationState: 'needs_authorization',
},
},
});
@ -2421,6 +2543,113 @@ describe('MCP Routes', () => {
expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
});
it('should batch user variables and pass runtime context into durable status checks', async () => {
currentUser = { id: 'test-user-id', email: 'user@example.com' };
const mcpConfig = {
server1: {
url: 'https://mcp.example.com/{{LIBRECHAT_USER_ID}}',
customUserVars: { API_KEY: { title: 'API key' } },
},
};
const userMCPAuthMap = {
'mcp:server1': { API_KEY: 'secret' },
};
getMCPSetupData.mockResolvedValue({
mcpConfig,
appConnections: new Map(),
userConnections: new Map(),
oauthServers: new Set(),
});
require('@librechat/api').getUserMCPAuthMap.mockResolvedValue(userMCPAuthMap);
getServerConnectionStatus.mockResolvedValue({
connectionState: 'connected',
requiresOAuth: true,
authorizationState: 'authorized',
});
const response = await request(app).get('/api/mcp/connection/status');
expect(response.status).toBe(200);
expect(require('@librechat/api').getUserMCPAuthMap).not.toHaveBeenCalled();
const runtimeContext = getServerConnectionStatus.mock.calls[0][6];
await expect(
Promise.all([runtimeContext.loadUserMCPAuthMap(), runtimeContext.loadUserMCPAuthMap()]),
).resolves.toEqual([userMCPAuthMap, userMCPAuthMap]);
await expect(
Promise.all([runtimeContext.loadMCPAllowlists(), runtimeContext.loadMCPAllowlists()]),
).resolves.toEqual([
{ allowedDomains: null, allowedAddresses: null, useSSRFProtection: true },
{ allowedDomains: null, allowedAddresses: null, useSSRFProtection: true },
]);
expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
userId: 'test-user-id',
servers: ['server1'],
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
});
expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledTimes(1);
expect(mockRegistryInstance.resolveAllowlists).toHaveBeenCalledTimes(1);
expect(mockRegistryInstance.resolveAllowlists).toHaveBeenCalledWith({
userId: 'test-user-id',
role: undefined,
});
expect(getServerConnectionStatus).toHaveBeenCalledWith(
'test-user-id',
'server1',
mcpConfig.server1,
expect.any(Map),
expect.any(Map),
expect.any(Set),
{
user: expect.objectContaining({ id: 'test-user-id', email: 'user@example.com' }),
loadUserMCPAuthMap: expect.any(Function),
loadMCPAllowlists: expect.any(Function),
},
);
});
it('should resolve independent server statuses concurrently', async () => {
let releaseFirst;
let markSecondStarted;
const firstRelease = new Promise((resolve) => {
releaseFirst = resolve;
});
const secondStarted = new Promise((resolve) => {
markSecondStarted = resolve;
});
getMCPSetupData.mockResolvedValue({
mcpConfig: {
server1: { endpoint: 'http://server1.com' },
server2: { endpoint: 'http://server2.com' },
},
appConnections: new Map(),
userConnections: new Map(),
oauthServers: new Set(),
});
getServerConnectionStatus.mockImplementation(async (_userId, serverName) => {
if (serverName === 'server1') {
await firstRelease;
} else {
markSecondStarted();
}
return {
connectionState: 'connected',
requiresOAuth: false,
authorizationState: 'not_required',
};
});
const responsePromise = request(app)
.get('/api/mcp/connection/status')
.then((res) => res);
await secondStarted;
releaseFirst();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
});
it('should return 500 when connection status check fails', async () => {
getMCPSetupData.mockRejectedValue(new Error('Database error'));
@ -2458,12 +2687,13 @@ describe('MCP Routes', () => {
mcpConfig: mockMcpConfig,
appConnections: {},
userConnections: {},
oauthServers: [],
oauthServers: new Set(),
});
getServerConnectionStatus.mockResolvedValue({
connectionState: 'requires_auth',
requiresOAuth: true,
authorizationState: 'needs_authorization',
});
const response = await request(app).get('/api/mcp/connection/status/oauth-server');
@ -2474,6 +2704,7 @@ describe('MCP Routes', () => {
serverName: 'oauth-server',
connectionStatus: 'requires_auth',
requiresOAuth: true,
authorizationState: 'needs_authorization',
});
});
@ -2484,7 +2715,7 @@ describe('MCP Routes', () => {
},
appConnections: {},
userConnections: {},
oauthServers: [],
oauthServers: new Set(),
});
const response = await request(app).get('/api/mcp/connection/status/non-existent-server');
@ -2657,7 +2888,7 @@ describe('MCP Routes', () => {
mockRegistryInstance.getServerConfig.mockResolvedValue({});
const mockFlowManager = {
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', createdAt: Date.now() }),
completeFlow: jest.fn(),
deleteFlow: jest.fn().mockResolvedValue(true),
};
@ -3009,6 +3240,8 @@ describe('MCP Routes', () => {
});
describe('POST /servers', () => {
const { MCPConnection } = require('@librechat/api');
it('should create MCP server with valid SSE config', async () => {
const validConfig = {
type: 'sse',
@ -3030,7 +3263,7 @@ describe('MCP Routes', () => {
expect(response.body.url).toBe('https://mcp-server.example.com/sse');
expect(response.body.title).toBe('Test SSE Server');
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
'temp_server_name',
expect.stringMatching(/^temp_server_[0-9a-f-]{36}$/),
expect.objectContaining({
type: 'sse',
url: 'https://mcp-server.example.com/sse',
@ -3039,6 +3272,8 @@ describe('MCP Routes', () => {
'test-user-id',
[],
);
const inspectionServerName = mockRegistryInstance.addServer.mock.calls[0][0];
expect(MCPConnection.clearCooldown).toHaveBeenCalledWith(inspectionServerName);
});
it('should reserve config-managed server names when creating MCP server', async () => {
@ -3058,7 +3293,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(201);
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
'temp_server_name',
expect.stringMatching(/^temp_server_[0-9a-f-]{36}$/),
expect.objectContaining({
type: 'sse',
url: 'https://mcp-server.example.com/sse',
@ -3088,7 +3323,7 @@ describe('MCP Routes', () => {
expect(response.status).toBe(201);
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
'temp_server_name',
expect.stringMatching(/^temp_server_[0-9a-f-]{36}$/),
expect.anything(),
'DB',
'test-user-id',
@ -3223,6 +3458,8 @@ describe('MCP Routes', () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'Database connection failed' });
const inspectionServerName = mockRegistryInstance.addServer.mock.calls[0][0];
expect(MCPConnection.clearCooldown).toHaveBeenCalledWith(inspectionServerName);
});
describe('OBO permission gate', () => {

View file

@ -373,14 +373,19 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
});
return res.redirect(`${basePath}/oauth/success?serverName=${encodeURIComponent(serverName)}`);
}
const isStalePendingFlow =
currentFlowState?.status === 'PENDING' &&
(!currentFlowState.createdAt || Date.now() - currentFlowState.createdAt >= PENDING_STALE_MS);
if (currentFlowState?.status === 'FAILED' || isStalePendingFlow) {
logger.warn('[MCP OAuth] Refusing token exchange for terminal flow', {
flowId,
serverName,
status: currentFlowState.status,
});
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
}
logger.debug('[MCP OAuth] Completing OAuth flow');
if (!flowState.oauthHeaders) {
logger.warn(
'[MCP OAuth] oauthHeaders absent from flow state — config-source server oauth_headers will be empty',
{ serverName, flowId },
);
}
/**
* Restore tenant context for the callback body. The callback is a cross-origin
* redirect from the OAuth provider, so SameSite=Strict cookies (including the
@ -716,6 +721,43 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
}
});
function createMCPStatusRuntimeContext(user, mcpConfig, serverNames) {
const customUserVarServers = serverNames.filter((serverName) => {
const customUserVars = mcpConfig[serverName]?.customUserVars;
return (
customUserVars && typeof customUserVars === 'object' && Object.keys(customUserVars).length > 0
);
});
let userMCPAuthMapPromise;
let mcpAllowlistsPromise;
const loadUserMCPAuthMap = () => {
if (!customUserVarServers.length) {
return Promise.resolve(undefined);
}
userMCPAuthMapPromise ??= getUserMCPAuthMap({
userId: user.id,
servers: customUserVarServers,
findPluginAuthsByKeys: db.findPluginAuthsByKeys,
});
return userMCPAuthMapPromise;
};
const loadMCPAllowlists = () => {
mcpAllowlistsPromise ??= getMCPServersRegistry().resolveAllowlists({
userId: user.id,
role: user.role,
});
return mcpAllowlistsPromise;
};
return { user: createSafeUser(user), loadUserMCPAuthMap, loadMCPAllowlists };
}
function getMCPReinitializeOAuthTimeout(oauthExpiresAt) {
if (typeof oauthExpiresAt !== 'number' || !Number.isFinite(oauthExpiresAt)) {
return mcpSettings.OAUTH_HANDLING_TIMEOUT;
}
return Math.max(0, oauthExpiresAt - Date.now());
}
/**
* Reinitialize MCP server
* This endpoint allows reinitializing a specific MCP server
@ -781,13 +823,15 @@ router.post(
message,
oauthRequired,
oauthUrl,
oauthExpiresAt,
failureReason,
missingUserVars,
connectionDeferred,
} = result;
let flowId;
if (oauthRequired) {
const flowId = getOAuthFlowId(user.id, serverName);
flowId = getOAuthFlowId(user.id, serverName);
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
}
@ -795,6 +839,8 @@ router.post(
success,
message,
oauthUrl,
flowId,
oauthTimeout: oauthRequired ? getMCPReinitializeOAuthTimeout(oauthExpiresAt) : undefined,
serverName,
oauthRequired,
failureReason,
@ -824,28 +870,37 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
user.id,
{ role: user.role, tenantId: getTenantId() },
);
const connectionStatus = {};
for (const [serverName, config] of Object.entries(mcpConfig)) {
try {
connectionStatus[serverName] = await getServerConnectionStatus(
user.id,
serverName,
config,
appConnections,
userConnections,
oauthServers,
);
} catch (error) {
const message = `Failed to get status for server "${serverName}"`;
logger.error(`[MCP Connection Status] ${message},`, error);
connectionStatus[serverName] = {
connectionState: 'error',
requiresOAuth: oauthServers.has(serverName),
error: message,
};
}
}
const runtimeContext = createMCPStatusRuntimeContext(user, mcpConfig, Object.keys(mcpConfig));
const connectionStatus = Object.fromEntries(
await Promise.all(
Object.entries(mcpConfig).map(async ([serverName, config]) => {
try {
const status = await getServerConnectionStatus(
user.id,
serverName,
config,
appConnections,
userConnections,
oauthServers,
runtimeContext,
);
return [serverName, status];
} catch (error) {
const message = `Failed to get status for server "${serverName}"`;
logger.error(`[MCP Connection Status] ${message},`, error);
return [
serverName,
{
connectionState: 'error',
requiresOAuth: oauthServers.has(serverName),
authorizationState: oauthServers.has(serverName) ? 'error' : 'not_required',
error: message,
},
];
}
}),
),
);
res.json({
success: true,
@ -882,6 +937,8 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
.json({ error: `MCP server '${serverName}' not found in configuration` });
}
const runtimeContext = createMCPStatusRuntimeContext(user, mcpConfig, [serverName]);
const serverStatus = await getServerConnectionStatus(
user.id,
serverName,
@ -889,6 +946,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
appConnections,
userConnections,
oauthServers,
runtimeContext,
);
res.json({
@ -896,6 +954,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
serverName,
connectionStatus: serverStatus.connectionState,
requiresOAuth: serverStatus.requiresOAuth,
authorizationState: serverStatus.authorizationState,
});
} catch (error) {
logger.error(

View file

@ -5,6 +5,7 @@ const {
sendEvent,
PENDING_STALE_MS,
MCPOAuthHandler,
MCPTokenStorage,
isMCPDomainAllowed,
splitMCPToolKey,
normalizeServerName,
@ -19,14 +20,19 @@ const {
buildMCPAuthStepId,
buildMCPAuthToolCall,
processMCPEnv,
preProcessGraphTokens,
buildMCPAuthRunStepEvent,
buildMCPAuthRunStepDeltaEvent,
buildMCPAuthRunStepEndDeltaEvent,
isUserSourced,
checkAccessWithRequestCache,
getMissingCustomUserVars,
getServerCustomUserVars,
requiresEphemeralUserConnection,
requiresOAuthMachinery,
hasRuntimeUrlPlaceholders,
containsGraphTokenPlaceholder,
isOAuthServer,
} = require('@librechat/api');
const {
Time,
@ -1119,8 +1125,20 @@ function createToolInstance({
error.message?.includes('OAuth') ||
error.message?.includes('authentication') ||
error.message?.includes('Non-200 status code (401)');
const isOAuthFlowSignal =
error.message === 'OAuth flow initiated - return early' ||
error.message === 'Pending OAuth flow reused - return early';
if (isOAuthError) {
if (
capturedServerConfig &&
!requiresOAuthMachinery(capturedServerConfig) &&
!isOAuthFlowSignal
) {
throw new Error(
`[MCP][${serverName}][${toolName}] upstream authentication failed; MCP OAuth is not configured for this server.`,
);
}
throw new Error(
`[MCP][${serverName}][${toolName}] OAuth authentication required. Please check the server logs for the authentication URL.`,
);
@ -1189,7 +1207,7 @@ async function getMCPSetupData(userId, options = {}) {
const userConnections = mcpManager.getUserConnections(userId) || new Map();
const oauthServers = new Set(
Object.entries(mcpConfig)
.filter(([, config]) => config.requiresOAuth)
.filter(([, config]) => isOAuthServer(config))
.map(([name]) => name),
);
@ -1206,7 +1224,7 @@ async function getMCPSetupData(userId, options = {}) {
* @param {string} userId - The user ID
* @param {string} serverName - The server name
* @param {string} [tenantId] - The tenant ID for the current request.
* @returns {Object} Object containing hasActiveFlow and hasFailedFlow flags
* @returns {Object} Object containing active and failed flow flags
*/
async function checkOAuthFlowStatus(userId, serverName, tenantId = getTenantId()) {
const flowsCache = getLogStores(CacheKeys.FLOWS);
@ -1225,8 +1243,8 @@ async function checkOAuthFlowStatus(userId, serverName, tenantId = getTenantId()
// flow the initiate/callback paths already reject, hiding the connect button.
const flowTTL = flowState.ttl || PENDING_STALE_MS;
if (flowState.status === 'FAILED' || flowAge > flowTTL) {
const wasCancelled = flowState.error && flowState.error.includes('cancelled');
if (flowState.status === 'FAILED' || (flowState.status === 'PENDING' && flowAge > flowTTL)) {
const wasCancelled = /abort|cancel/i.test(flowState.error ?? '');
if (wasCancelled) {
logger.debug(`[MCP Connection Status] Found cancelled OAuth flow for ${serverName}`, {
@ -1264,6 +1282,69 @@ async function checkOAuthFlowStatus(userId, serverName, tenantId = getTenantId()
}
}
async function hasDurableMCPAuthorization(userId, serverName, config, runtimeContext = {}) {
const userMCPAuthMap =
runtimeContext.userMCPAuthMap ?? (await runtimeContext.loadUserMCPAuthMap?.());
const customUserVars = getServerCustomUserVars(userMCPAuthMap, serverName);
if (getMissingCustomUserVars(config, customUserVars).length > 0) {
return false;
}
const dbSourced = isUserSourced(config);
const bindingConfig = {
...config,
args: undefined,
env: undefined,
headers: undefined,
oauth_headers: undefined,
};
const graphProcessedConfig = dbSourced
? bindingConfig
: await preProcessGraphTokens(bindingConfig, {
user: runtimeContext.user,
graphTokenResolver: getGraphApiToken,
scopes: process.env.GRAPH_API_SCOPES,
});
const runtimeConfig = processMCPEnv({
user: runtimeContext.user,
options: graphProcessedConfig,
dbSourced,
customUserVars,
});
const allowlists = await (runtimeContext.loadMCPAllowlists?.() ??
getMCPServersRegistry().resolveAllowlists({
userId,
role: runtimeContext.user?.role,
}));
if (
runtimeConfig.url &&
!(await isMCPDomainAllowed(
runtimeConfig,
allowlists.allowedDomains,
allowlists.allowedAddresses,
))
) {
return false;
}
return MCPTokenStorage.hasStoredAuthorization({
userId,
serverName,
findToken,
validateClientBinding: (clientInfo, storedMetadata) =>
MCPOAuthHandler.assertStoredClientBinding(
serverName,
runtimeConfig.url,
clientInfo,
storedMetadata,
runtimeConfig.oauth,
),
});
}
function canDetectMCPRuntimeOAuth(config) {
return config.requiresOAuth == null && config.apiKey == null && hasRuntimeUrlPlaceholders(config);
}
/**
* Get connection status for a specific MCP server
* @param {string} userId - The user ID
@ -1272,6 +1353,7 @@ async function checkOAuthFlowStatus(userId, serverName, tenantId = getTenantId()
* @param {Map<string, import('@librechat/api').MCPConnection>} appConnections - App-level connections
* @param {Map<string, import('@librechat/api').MCPConnection>} userConnections - User-level connections
* @param {Set} oauthServers - Set of OAuth servers
* @param {{ user?: Partial<IUser>, userMCPAuthMap?: Record<string, Record<string, string>>, loadUserMCPAuthMap?: () => Promise<Record<string, Record<string, string>> | undefined>, loadMCPAllowlists?: () => Promise<{ allowedDomains?: string[] | null, allowedAddresses?: string[] | null }> }} [runtimeContext]
* @returns {Object} Object containing requiresOAuth and connectionState
*/
async function getServerConnectionStatus(
@ -1281,35 +1363,60 @@ async function getServerConnectionStatus(
appConnections,
userConnections,
oauthServers,
runtimeContext = {},
) {
const connection = appConnections.get(serverName) || userConnections.get(serverName);
const isStaleOrDoNotExist = connection ? connection?.isStale(config.updatedAt) : true;
const configuredOAuth = oauthServers.has(serverName);
const liveConnectionOAuth = connection?.usesOAuth?.() === true;
const runtimeOAuthCandidate = canDetectMCPRuntimeOAuth(config);
const effectiveOAuth = configuredOAuth || liveConnectionOAuth;
const baseConnectionState = isStaleOrDoNotExist
? 'disconnected'
: connection?.connectionState || 'disconnected';
let finalConnectionState = baseConnectionState;
let requiresOAuth = effectiveOAuth;
let authorizationState = effectiveOAuth ? 'needs_authorization' : 'not_required';
// connection state overrides specific to OAuth servers
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
if (effectiveOAuth && baseConnectionState === 'connected') {
authorizationState = 'authorized';
} else if (effectiveOAuth && baseConnectionState === 'connecting') {
authorizationState = 'authorizing';
} else if (effectiveOAuth && baseConnectionState === 'error') {
authorizationState = 'error';
} else if (baseConnectionState === 'disconnected' && (effectiveOAuth || runtimeOAuthCandidate)) {
// check if server is actively being reconnected
const oauthReconnectionManager = getOAuthReconnectionManager();
if (oauthReconnectionManager.isReconnecting(userId, serverName)) {
requiresOAuth = true;
finalConnectionState = 'connecting';
authorizationState = 'authorizing';
} else {
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
if (hasFailedFlow) {
requiresOAuth = true;
finalConnectionState = 'error';
authorizationState = 'error';
} else if (hasActiveFlow) {
requiresOAuth = true;
finalConnectionState = 'connecting';
authorizationState = 'authorizing';
} else if (await hasDurableMCPAuthorization(userId, serverName, config, runtimeContext)) {
/** OAuth readiness is durable even when this pod has no live connection. */
requiresOAuth = true;
finalConnectionState = 'connected';
authorizationState = 'authorized';
}
}
}
return {
requiresOAuth: oauthServers.has(serverName),
requiresOAuth,
connectionState: finalConnectionState,
authorizationState,
};
}

View file

@ -9,6 +9,7 @@ jest.mock('@librechat/data-schemas', () => ({
warn: jest.fn(),
},
getTenantId: mockGetTenantId,
decryptV2: jest.fn(async (value) => value.replace(/^enc:/, '')),
}));
// Create mock registry instance
@ -17,6 +18,9 @@ const mockRegistryInstance = {
getAllServerConfigs: jest.fn(() => Promise.resolve({})),
getServerConfig: jest.fn(() => Promise.resolve(null)),
ensureConfigServers: jest.fn(() => Promise.resolve({})),
resolveAllowlists: jest.fn(() =>
Promise.resolve({ allowedDomains: null, allowedAddresses: null, useSSRFProtection: true }),
),
};
// Create isMCPDomainAllowed mock that can be configured per-test
@ -97,6 +101,12 @@ describe('tests for the new helper functions used by the MCP connection status e
jest.clearAllMocks();
jest.spyOn(MCPOAuthHandler, 'generateFlowId');
mockGetTenantId.mockReturnValue(undefined);
mockIsMCPDomainAllowed.mockResolvedValue(true);
mockRegistryInstance.resolveAllowlists.mockResolvedValue({
allowedDomains: null,
allowedAddresses: null,
useSSRFProtection: true,
});
mockGetMCPManager = require('~/config').getMCPManager;
mockGetFlowStateManager = require('~/config').getFlowStateManager;
@ -203,6 +213,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const mockConfigWithOAuth = {
server1: { type: 'stdio' },
server2: { type: 'http', requiresOAuth: true },
server3: { type: 'http', oauth: { client_id: 'configured-client' } },
};
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfigWithOAuth);
@ -229,7 +240,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result.mcpConfig).toEqual(mockConfigWithOAuth);
expect(result.appConnections).toEqual(mockAppConnections);
expect(result.userConnections).toEqual(mockUserConnections);
expect(result.oauthServers).toEqual(new Set(['server2']));
expect(result.oauthServers).toEqual(new Set(['server2', 'server3']));
});
it('should return empty data when no servers are configured', async () => {
@ -309,6 +320,21 @@ describe('tests for the new helper functions used by the MCP connection status e
);
});
it('should treat aborted flow cleanup as neutral connection status', async () => {
const mockFlowState = {
status: 'FAILED',
createdAt: Date.now() - 60000,
ttl: 180000,
error: 'Tool loading aborted',
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
});
it('should detect failed flow when flow has timed out', async () => {
const mockFlowState = {
status: 'PENDING',
@ -385,7 +411,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({ hasActiveFlow: true, hasFailedFlow: false });
});
it('should return false flags for other statuses', async () => {
it('should not treat a completed flow as durable authorization', async () => {
const mockFlowState = {
status: 'COMPLETED',
createdAt: Date.now() - 60000,
@ -399,6 +425,20 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
});
it('should treat an old completed flow as neutral instead of timed out', async () => {
const mockFlowState = {
status: 'COMPLETED',
createdAt: Date.now() - 200000,
ttl: 180000,
};
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
});
it('should handle errors gracefully', async () => {
const mockError = new Error('Flow state error');
const mockFlowManager = {
@ -423,6 +463,10 @@ describe('tests for the new helper functions used by the MCP connection status e
const mockServerName = 'test-server';
const mockConfig = { updatedAt: Date.now() };
beforeEach(() => {
require('~/models').findToken.mockReset();
});
it('should return app connection state when available', async () => {
const appConnections = new Map([
[
@ -448,6 +492,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'connected',
authorizationState: 'not_required',
});
});
@ -476,6 +521,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'connecting',
authorizationState: 'not_required',
});
});
@ -496,6 +542,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'disconnected',
authorizationState: 'not_required',
});
});
@ -532,6 +579,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'connected',
authorizationState: 'not_required',
});
});
@ -593,6 +641,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'error',
authorizationState: 'error',
});
});
@ -631,6 +680,489 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connecting',
authorizationState: 'authorizing',
});
});
it('should require bound token storage after a completed OAuth flow on another pod', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({
getFlowState: jest.fn(() => ({
status: 'COMPLETED',
createdAt: Date.now() - 60000,
result: { access_token: 'encrypted' },
})),
});
mockGetLogStores.mockReturnValue({});
const { findToken } = require('~/models');
findToken.mockResolvedValue(null);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
mockConfig,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'disconnected',
authorizationState: 'needs_authorization',
});
});
it('should derive readiness from bound token storage after the flow record expires', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const { findToken } = require('~/models');
const credentialSetId = 'credential-set-a';
const config = { ...mockConfig, url: 'https://mcp.example.com/' };
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
findToken
.mockResolvedValueOnce({
expiresAt: new Date(Date.now() + 60000),
metadata: { credential_set_id: credentialSetId },
})
.mockResolvedValueOnce({
token: 'enc:{"client_id":"dynamic-client"}',
metadata: {
credential_set_id: credentialSetId,
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
server_url: 'https://mcp.example.com/',
client_source: 'dynamic',
},
});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
authorizationState: 'authorized',
});
});
it('should derive runtime-detected OAuth readiness from bound token storage', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set();
const { findToken } = require('~/models');
const credentialSetId = 'credential-set-a';
const config = {
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/users/{{LIBRECHAT_USER_ID}}/mcp',
};
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
findToken
.mockResolvedValueOnce({
expiresAt: new Date(Date.now() + 60000),
metadata: { credential_set_id: credentialSetId },
})
.mockResolvedValueOnce({
token: 'enc:{"client_id":"dynamic-client"}',
metadata: {
credential_set_id: credentialSetId,
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
server_url: `https://mcp.example.com/users/${mockUserId}/mcp`,
client_source: 'dynamic',
},
});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
{ user: { id: mockUserId } },
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
authorizationState: 'authorized',
});
});
it('should preserve OAuth status from a live runtime-resolved connection', async () => {
const { findToken } = require('~/models');
const appConnections = new Map();
const userConnections = new Map([
[
mockServerName,
{
connectionState: 'connected',
isStale: jest.fn(() => false),
usesOAuth: jest.fn(() => true),
},
],
]);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
{
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/{{LIBRECHAT_USER_ID}}/mcp',
},
appConnections,
userConnections,
new Set(),
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
authorizationState: 'authorized',
});
expect(findToken).not.toHaveBeenCalled();
});
it('should derive runtime-detected OAuth state from an active shared flow', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set();
const config = {
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/{{LIBRECHAT_BODY_TENANT}}/mcp',
};
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({
getFlowState: jest.fn(() => ({
status: 'PENDING',
createdAt: Date.now() - 1000,
ttl: 180000,
})),
});
mockGetLogStores.mockReturnValue({});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
{ user: { id: mockUserId } },
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connecting',
authorizationState: 'authorizing',
});
});
it('should not inspect OAuth state for an explicitly non-OAuth runtime URL', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set();
const { findToken } = require('~/models');
const mockFlowManager = { getFlowState: jest.fn() };
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
{
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/{{LIBRECHAT_USER_ID}}/mcp',
requiresOAuth: false,
},
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'disconnected',
authorizationState: 'not_required',
});
expect(mockFlowManager.getFlowState).not.toHaveBeenCalled();
expect(findToken).not.toHaveBeenCalled();
});
it('should not report durable readiness when the runtime URL violates current policy', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const { findToken } = require('~/models');
const config = { ...mockConfig, url: 'https://blocked.example.com/' };
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
mockRegistryInstance.resolveAllowlists.mockResolvedValue({
allowedDomains: ['allowed.example.com'],
allowedAddresses: null,
useSSRFProtection: false,
});
mockIsMCPDomainAllowed.mockResolvedValue(false);
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
{ user: { id: mockUserId, role: 'user' } },
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'disconnected',
authorizationState: 'needs_authorization',
});
expect(mockRegistryInstance.resolveAllowlists).toHaveBeenCalledWith({
userId: mockUserId,
role: 'user',
});
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
expect.objectContaining({ url: 'https://blocked.example.com/' }),
['allowed.example.com'],
null,
);
expect(findToken).not.toHaveBeenCalled();
});
it('should validate durable readiness against the user-resolved runtime URL', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const { findToken } = require('~/models');
const credentialSetId = 'credential-set-a';
const config = {
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/users/{{LIBRECHAT_USER_ID}}/mcp',
};
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
findToken
.mockResolvedValueOnce({
expiresAt: new Date(Date.now() + 60000),
metadata: { credential_set_id: credentialSetId },
})
.mockResolvedValueOnce({
token: 'enc:{"client_id":"dynamic-client"}',
metadata: {
credential_set_id: credentialSetId,
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
server_url: `https://mcp.example.com/users/${mockUserId}/mcp`,
client_source: 'dynamic',
},
});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
{ user: { id: mockUserId } },
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
authorizationState: 'authorized',
});
});
it('should validate durable readiness against the Graph-resolved runtime URL', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const { findToken } = require('~/models');
const { getGraphApiToken } = require('./GraphTokenService');
const credentialSetId = 'credential-set-a';
const config = {
...mockConfig,
source: 'yaml',
url: 'https://mcp.example.com/{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}/mcp',
};
const user = {
id: mockUserId,
provider: 'openid',
openidId: 'openid-user',
federatedTokens: {
access_token: 'federated-access-token',
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
};
getGraphApiToken.mockResolvedValue({
access_token: 'resolved-graph-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'https://graph.microsoft.com/.default',
});
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
findToken
.mockResolvedValueOnce({
expiresAt: new Date(Date.now() + 60000),
metadata: { credential_set_id: credentialSetId },
})
.mockResolvedValueOnce({
token: 'enc:{"client_id":"dynamic-client"}',
metadata: {
credential_set_id: credentialSetId,
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
server_url: 'https://mcp.example.com/resolved-graph-token/mcp',
client_source: 'dynamic',
},
});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
{ user },
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
authorizationState: 'authorized',
});
expect(getGraphApiToken).toHaveBeenCalledWith(
user,
'federated-access-token',
'https://graph.microsoft.com/.default',
true,
);
});
it('should not report durable readiness while required custom user variables are missing', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const { findToken } = require('~/models');
const credentialSetId = 'credential-set-a';
const config = {
...mockConfig,
url: 'https://mcp.example.com/',
customUserVars: { API_KEY: { title: 'API key' } },
};
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
findToken
.mockResolvedValueOnce({
expiresAt: new Date(Date.now() + 60000),
metadata: { credential_set_id: credentialSetId },
})
.mockResolvedValueOnce({
token: 'enc:{"client_id":"dynamic-client"}',
metadata: {
credential_set_id: credentialSetId,
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
server_url: 'https://mcp.example.com/',
client_source: 'dynamic',
},
});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
{ user: { id: mockUserId } },
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'disconnected',
authorizationState: 'needs_authorization',
});
expect(findToken).not.toHaveBeenCalled();
});
it('should reject stored authorization bound to an older server configuration', async () => {
const appConnections = new Map();
const userConnections = new Map();
const oauthServers = new Set([mockServerName]);
const { findToken } = require('~/models');
const credentialSetId = 'credential-set-a';
const config = {
updatedAt: Date.now(),
url: 'https://new-mcp.example.com/',
};
mockGetOAuthReconnectionManager.mockReturnValue({ isReconnecting: jest.fn(() => false) });
mockGetFlowStateManager.mockReturnValue({ getFlowState: jest.fn(() => null) });
mockGetLogStores.mockReturnValue({});
findToken.mockImplementation(({ type }) => {
if (type === 'mcp_oauth') {
return {
expiresAt: new Date(Date.now() + 60000),
metadata: { credential_set_id: credentialSetId },
};
}
if (type === 'mcp_oauth_client') {
return {
token: 'enc:{"client_id":"dynamic-client"}',
metadata: {
credential_set_id: credentialSetId,
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
server_url: 'https://old-mcp.example.com/',
client_source: 'dynamic',
},
};
}
return null;
});
const result = await getServerConnectionStatus(
mockUserId,
mockServerName,
config,
appConnections,
userConnections,
oauthServers,
);
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'disconnected',
authorizationState: 'needs_authorization',
});
});
@ -665,6 +1197,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'disconnected',
authorizationState: 'needs_authorization',
});
});
@ -691,6 +1224,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connecting',
authorizationState: 'authorizing',
});
expect(mockOAuthReconnectionManager.isReconnecting).toHaveBeenCalledWith(
mockUserId,
@ -729,6 +1263,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: true,
connectionState: 'connected',
authorizationState: 'authorized',
});
// Should not call flow manager since server is connected
@ -758,6 +1293,7 @@ describe('tests for the new helper functions used by the MCP connection status e
expect(result).toEqual({
requiresOAuth: false,
connectionState: 'disconnected',
authorizationState: 'not_required',
});
// Should not call flow manager since server doesn't require OAuth
@ -951,6 +1487,147 @@ describe('User parameter passing tests', () => {
});
describe('createMCPTool', () => {
it.each(['OAuth flow initiated - return early', 'Pending OAuth flow reused - return early'])(
'preserves runtime-detected OAuth for the internal signal: %s',
async (oauthSignal) => {
const mockUser = { id: 'runtime-oauth-user', role: 'USER' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
const { getRoleByName } = require('~/models');
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
},
},
});
mockGetMCPManager.mockReturnValue({
callTool: jest.fn().mockRejectedValue(new Error(oauthSignal)),
});
const mcpTool = await createMCPTool({
res: mockRes,
user: mockUser,
config: { url: 'https://runtime-oauth.example.com/mcp' },
toolKey: `test-tool${D}test-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: {
[`test-tool${D}test-server`]: {
function: {
description: 'Cached tool',
parameters: { type: 'object', properties: {} },
},
},
},
});
await expect(
mcpTool.invoke(
{},
{
configurable: { user: mockUser },
metadata: { provider: 'openai', thread_id: 'thread-1', run_id: 'run-1' },
toolCall: {},
},
),
).rejects.toThrow(
'[MCP][test-server][test-tool] OAuth authentication required. Please check the server logs for the authentication URL.',
);
},
);
it('does not label forwarded-token failures as MCP OAuth', async () => {
const mockUser = { id: 'forwarded-token-user', role: 'USER' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
const { getRoleByName } = require('~/models');
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
},
},
});
mockGetMCPManager.mockReturnValue({
callTool: jest.fn().mockRejectedValue(new Error('Non-200 status code (401)')),
});
const mcpTool = await createMCPTool({
res: mockRes,
user: mockUser,
config: { requiresOAuth: false },
toolKey: `test-tool${D}test-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: {
[`test-tool${D}test-server`]: {
function: {
description: 'Cached tool',
parameters: { type: 'object', properties: {} },
},
},
},
});
await expect(
mcpTool.invoke(
{},
{
configurable: { user: mockUser },
metadata: { provider: 'openai', thread_id: 'thread-1', run_id: 'run-1' },
toolCall: {},
},
),
).rejects.toThrow(
'[MCP][test-server][test-tool] upstream authentication failed; MCP OAuth is not configured for this server.',
);
});
it('does not label OBO authentication failures as unconfigured MCP OAuth', async () => {
const mockUser = { id: 'obo-user', role: 'USER' };
const mockRes = { write: jest.fn(), flush: jest.fn() };
const { getRoleByName } = require('~/models');
getRoleByName.mockResolvedValue({
permissions: {
[PermissionTypes.MCP_SERVERS]: {
[Permissions.USE]: true,
},
},
});
mockGetMCPManager.mockReturnValue({
callTool: jest.fn().mockRejectedValue(new Error('Non-200 status code (401)')),
});
const mcpTool = await createMCPTool({
res: mockRes,
user: mockUser,
config: { requiresOAuth: false, obo: {} },
toolKey: `test-tool${D}test-server`,
provider: 'openai',
userMCPAuthMap: {},
availableTools: {
[`test-tool${D}test-server`]: {
function: {
description: 'Cached tool',
parameters: { type: 'object', properties: {} },
},
},
},
});
await expect(
mcpTool.invoke(
{},
{
configurable: { user: mockUser },
metadata: { provider: 'openai', thread_id: 'thread-1', run_id: 'run-1' },
toolCall: {},
},
),
).rejects.toThrow(
'[MCP][test-server][test-tool] OAuth authentication required. Please check the server logs for the authentication URL.',
);
});
it('should pass user parameter to reinitMCPServer when tool not in cache', async () => {
const mockUser = { id: 'test-user-456', email: 'test@example.com' };
const mockRes = { write: jest.fn(), flush: jest.fn() };

View file

@ -63,6 +63,7 @@ async function reinitMCPServer({
let tools = null;
let oauthRequired = false;
let oauthUrl = null;
let oauthExpiresAt;
let ephemeralServer = false;
try {
@ -168,9 +169,15 @@ async function reinitMCPServer({
const oauthStart =
_oauthStart ??
(async (authURL) => {
(async (authURL, options) => {
logger.info(`[MCP Reinitialize] OAuth URL received for ${serverName}`);
if (authURL !== oauthUrl) {
oauthExpiresAt = undefined;
}
oauthUrl = authURL;
if (typeof options?.expiresAt === 'number' && Number.isFinite(options.expiresAt)) {
oauthExpiresAt = options.expiresAt;
}
oauthRequired = true;
});
@ -298,6 +305,7 @@ async function reinitMCPServer({
oauthRequired,
serverName,
oauthUrl,
oauthExpiresAt,
tools,
};

View file

@ -310,3 +310,40 @@ describe('reinitMCPServer — runtime BODY placeholder pre-check (issue #14074)'
expect(result.message).toBe(`Failed to reinitialize MCP server '${serverName}'`);
});
});
describe('reinitMCPServer — OAuth attempt lifetime', () => {
const user = { id: 'user-123' };
const serverName = 'Thingy';
const serverConfig = {
type: 'streamable-http',
url: 'https://thingy.example.com/mcp',
};
beforeEach(() => {
jest.clearAllMocks();
mockUpdateMCPServerTools.mockResolvedValue({});
});
it('returns the expiry supplied when a pending OAuth URL is replayed', async () => {
const expiresAt = Date.now() + 45_000;
mockGetConnection.mockImplementation(async ({ oauthStart }) => {
await oauthStart('https://oauth.example.com/authorize', { expiresAt });
await oauthStart('https://oauth.example.com/authorize');
throw new Error('OAuth flow initiated - return early');
});
mockDiscoverServerTools.mockResolvedValue({ tools: [], oauthRequired: true, oauthUrl: null });
const result = await reinitMCPServer({
user,
serverName,
serverConfig,
});
expect(result).toMatchObject({
success: true,
oauthRequired: true,
oauthUrl: 'https://oauth.example.com/authorize',
oauthExpiresAt: expiresAt,
});
});
});