mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🎟️ 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:
parent
9b7a30743b
commit
56175af0b5
24 changed files with 2352 additions and 111 deletions
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue