2025-12-18 19:57:49 +01:00
|
|
|
// Mock all dependencies - define mocks before imports
|
2025-07-28 09:25:34 -07:00
|
|
|
// Mock all dependencies
|
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
|
|
|
logger: {
|
|
|
|
|
debug: jest.fn(),
|
|
|
|
|
error: jest.fn(),
|
2025-09-24 22:48:38 -04:00
|
|
|
info: jest.fn(),
|
|
|
|
|
warn: jest.fn(),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
2025-12-18 19:57:49 +01:00
|
|
|
// Create mock registry instance
|
2025-12-01 00:57:46 +01:00
|
|
|
const mockRegistryInstance = {
|
|
|
|
|
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
|
|
|
|
|
getAllServerConfigs: jest.fn(() => Promise.resolve({})),
|
2025-12-18 19:57:49 +01:00
|
|
|
getServerConfig: jest.fn(() => Promise.resolve(null)),
|
2025-12-01 00:57:46 +01:00
|
|
|
};
|
|
|
|
|
|
2025-12-18 19:57:49 +01:00
|
|
|
// Create isMCPDomainAllowed mock that can be configured per-test
|
|
|
|
|
const mockIsMCPDomainAllowed = jest.fn(() => Promise.resolve(true));
|
|
|
|
|
|
|
|
|
|
const mockGetAppConfig = jest.fn(() => Promise.resolve({}));
|
|
|
|
|
|
|
|
|
|
jest.mock('@librechat/api', () => {
|
2026-02-13 13:33:25 -05:00
|
|
|
const actual = jest.requireActual('@librechat/api');
|
2025-12-18 19:57:49 +01:00
|
|
|
return {
|
2026-02-13 13:33:25 -05:00
|
|
|
...actual,
|
2025-12-18 19:57:49 +01:00
|
|
|
sendEvent: jest.fn(),
|
|
|
|
|
get isMCPDomainAllowed() {
|
|
|
|
|
return mockIsMCPDomainAllowed;
|
|
|
|
|
},
|
2026-02-13 13:33:25 -05:00
|
|
|
GenerationJobManager: {
|
|
|
|
|
emitChunk: jest.fn(),
|
2025-12-18 19:57:49 +01:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { logger } = require('@librechat/data-schemas');
|
|
|
|
|
const { MCPOAuthHandler } = require('@librechat/api');
|
2026-02-13 13:33:25 -05:00
|
|
|
const { CacheKeys, Constants } = require('librechat-data-provider');
|
|
|
|
|
const D = Constants.mcp_delimiter;
|
2025-12-18 19:57:49 +01:00
|
|
|
const {
|
|
|
|
|
createMCPTool,
|
|
|
|
|
createMCPTools,
|
|
|
|
|
getMCPSetupData,
|
|
|
|
|
checkOAuthFlowStatus,
|
|
|
|
|
getServerConnectionStatus,
|
🧪 chore: MCP Reconnect Storm Follow-Up Fixes and Integration Tests (#12172)
* 🧪 test: Add reconnection storm regression tests for MCPConnection
Introduced a comprehensive test suite for reconnection storm scenarios, validating circuit breaker, throttling, cooldown, and timeout fixes. The tests utilize real MCP SDK transports and a StreamableHTTP server to ensure accurate behavior under rapid connect/disconnect cycles and error handling for SSE 400/405 responses. This enhances the reliability of the MCPConnection by ensuring proper handling of reconnection logic and circuit breaker functionality.
* 🔧 fix: Update createUnavailableToolStub to return structured response
Modified the `createUnavailableToolStub` function to return an array containing the unavailable message and a null value, enhancing the response structure. Additionally, added a debug log to skip tool creation when the result is null, improving the handling of reconnection scenarios in the MCP service.
* 🧪 test: Enhance MCP tool creation tests for cache and throttle interactions
Added new test cases for the `createMCPTool` function to validate the caching behavior when tools are unavailable or throttled. The tests ensure that tools are correctly cached as missing and prevent unnecessary reconnects across different users, improving the reliability of the MCP service under concurrent usage scenarios. Additionally, introduced a test for the `createMCPTools` function to verify that it returns an empty array when reconnect is throttled, ensuring proper handling of throttling logic.
* 📝 docs: Update AGENTS.md with testing philosophy and guidelines
Expanded the testing section in AGENTS.md to emphasize the importance of using real logic over mocks, advocating for the use of spies and real dependencies in tests. Added specific recommendations for testing with MongoDB and MCP SDK, highlighting the need to mock only uncontrollable external services. This update aims to improve testing practices and encourage more robust test implementations.
* 🧪 test: Enhance reconnection storm tests with socket tracking and SSE handling
Updated the reconnection storm test suite to include a new socket tracking mechanism for better resource management during tests. Improved the handling of SSE 400/405 responses by ensuring they are processed in the same branch as 404 errors, preventing unhandled cases. This enhances the reliability of the MCPConnection under rapid reconnect scenarios and ensures proper error handling.
* 🔧 fix: Implement cache eviction for stale reconnect attempts and missing tools
Added an `evictStale` function to manage the size of the `lastReconnectAttempts` and `missingToolCache` maps, ensuring they do not exceed a maximum cache size. This enhancement improves resource management by removing outdated entries based on a specified time-to-live (TTL), thereby optimizing the MCP service's performance during reconnection scenarios.
2026-03-10 17:44:13 -04:00
|
|
|
createUnavailableToolStub,
|
2025-12-18 19:57:49 +01:00
|
|
|
} = require('./MCP');
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
jest.mock('./Config', () => ({
|
|
|
|
|
loadCustomConfig: jest.fn(),
|
2025-12-18 19:57:49 +01:00
|
|
|
get getAppConfig() {
|
|
|
|
|
return mockGetAppConfig;
|
|
|
|
|
},
|
2025-07-28 09:25:34 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/config', () => ({
|
|
|
|
|
getMCPManager: jest.fn(),
|
|
|
|
|
getFlowStateManager: jest.fn(),
|
2025-09-17 22:49:36 +02:00
|
|
|
getOAuthReconnectionManager: jest.fn(),
|
2025-12-01 00:57:46 +01:00
|
|
|
getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
|
2025-07-28 09:25:34 -07:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/cache', () => ({
|
|
|
|
|
getLogStores: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/models', () => ({
|
|
|
|
|
findToken: jest.fn(),
|
|
|
|
|
createToken: jest.fn(),
|
|
|
|
|
updateToken: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-09-24 22:48:38 -04:00
|
|
|
jest.mock('./Tools/mcp', () => ({
|
|
|
|
|
reinitMCPServer: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-01-19 22:35:15 +01:00
|
|
|
jest.mock('./GraphTokenService', () => ({
|
|
|
|
|
getGraphApiToken: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2025-07-28 09:25:34 -07:00
|
|
|
describe('tests for the new helper functions used by the MCP connection status endpoints', () => {
|
|
|
|
|
let mockGetMCPManager;
|
|
|
|
|
let mockGetFlowStateManager;
|
|
|
|
|
let mockGetLogStores;
|
2025-09-17 22:49:36 +02:00
|
|
|
let mockGetOAuthReconnectionManager;
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
2026-02-13 13:33:25 -05:00
|
|
|
jest.spyOn(MCPOAuthHandler, 'generateFlowId');
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
mockGetMCPManager = require('~/config').getMCPManager;
|
|
|
|
|
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
|
|
|
|
mockGetLogStores = require('~/cache').getLogStores;
|
2025-09-17 22:49:36 +02:00
|
|
|
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
|
2025-07-28 09:25:34 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getMCPSetupData', () => {
|
|
|
|
|
const mockUserId = 'user-123';
|
|
|
|
|
const mockConfig = {
|
2025-11-26 21:26:40 +01:00
|
|
|
server1: { type: 'stdio' },
|
|
|
|
|
server2: { type: 'http' },
|
2025-07-28 09:25:34 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockGetMCPManager.mockReturnValue({
|
2025-12-15 16:46:56 -05:00
|
|
|
appConnections: { getLoaded: jest.fn(() => new Map()) },
|
2025-07-28 09:25:34 -07:00
|
|
|
getUserConnections: jest.fn(() => new Map()),
|
|
|
|
|
});
|
2025-12-01 00:57:46 +01:00
|
|
|
mockRegistryInstance.getOAuthServers.mockResolvedValue(new Set());
|
|
|
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
|
2025-07-28 09:25:34 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should successfully return MCP setup data', async () => {
|
2025-12-01 00:57:46 +01:00
|
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
|
|
|
|
|
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
|
|
|
|
|
const mockOAuthServers = new Set(['server2']);
|
|
|
|
|
|
|
|
|
|
const mockMCPManager = {
|
2025-12-15 16:46:56 -05:00
|
|
|
appConnections: { getLoaded: jest.fn(() => Promise.resolve(mockAppConnections)) },
|
2025-07-28 09:25:34 -07:00
|
|
|
getUserConnections: jest.fn(() => mockUserConnections),
|
|
|
|
|
};
|
|
|
|
|
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
2025-12-01 00:57:46 +01:00
|
|
|
mockRegistryInstance.getOAuthServers.mockResolvedValue(mockOAuthServers);
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
const result = await getMCPSetupData(mockUserId);
|
|
|
|
|
|
2025-12-01 00:57:46 +01:00
|
|
|
expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith(mockUserId);
|
2025-07-28 09:25:34 -07:00
|
|
|
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
|
2025-12-15 16:46:56 -05:00
|
|
|
expect(mockMCPManager.appConnections.getLoaded).toHaveBeenCalled();
|
2025-07-28 09:25:34 -07:00
|
|
|
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
|
2025-12-01 00:57:46 +01:00
|
|
|
expect(mockRegistryInstance.getOAuthServers).toHaveBeenCalledWith(mockUserId);
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
2025-11-26 21:26:40 +01:00
|
|
|
mcpConfig: mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections: mockAppConnections,
|
|
|
|
|
userConnections: mockUserConnections,
|
|
|
|
|
oauthServers: mockOAuthServers,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should throw error when MCP config not found', async () => {
|
2025-12-01 00:57:46 +01:00
|
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(null);
|
2025-07-28 09:25:34 -07:00
|
|
|
await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle null values from MCP manager gracefully', async () => {
|
2025-12-01 00:57:46 +01:00
|
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
const mockMCPManager = {
|
2025-12-15 16:46:56 -05:00
|
|
|
appConnections: { getLoaded: jest.fn(() => Promise.resolve(null)) },
|
2025-07-28 09:25:34 -07:00
|
|
|
getUserConnections: jest.fn(() => null),
|
|
|
|
|
};
|
|
|
|
|
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
2025-12-01 00:57:46 +01:00
|
|
|
mockRegistryInstance.getOAuthServers.mockResolvedValue(new Set());
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
const result = await getMCPSetupData(mockUserId);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
2025-11-26 21:26:40 +01:00
|
|
|
mcpConfig: mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections: new Map(),
|
|
|
|
|
userConnections: new Map(),
|
|
|
|
|
oauthServers: new Set(),
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('checkOAuthFlowStatus', () => {
|
|
|
|
|
const mockUserId = 'user-123';
|
|
|
|
|
const mockServerName = 'test-server';
|
|
|
|
|
const mockFlowId = 'flow-123';
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
const mockFlowsCache = {};
|
|
|
|
|
const mockFlowManager = {
|
|
|
|
|
getFlowState: jest.fn(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockGetLogStores.mockReturnValue(mockFlowsCache);
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue(mockFlowId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return false flags when no flow state exists', async () => {
|
|
|
|
|
const mockFlowManager = { getFlowState: jest.fn(() => null) };
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
|
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
|
|
|
|
|
|
expect(mockGetLogStores).toHaveBeenCalledWith(CacheKeys.FLOWS);
|
|
|
|
|
expect(MCPOAuthHandler.generateFlowId).toHaveBeenCalledWith(mockUserId, mockServerName);
|
|
|
|
|
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(mockFlowId, 'mcp_oauth');
|
|
|
|
|
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should detect failed flow when status is FAILED', async () => {
|
|
|
|
|
const mockFlowState = {
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
createdAt: Date.now() - 60000, // 1 minute ago
|
|
|
|
|
ttl: 180000,
|
|
|
|
|
};
|
|
|
|
|
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
|
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: true });
|
|
|
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining('Found failed OAuth flow'),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
flowId: mockFlowId,
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should detect failed flow when flow has timed out', async () => {
|
|
|
|
|
const mockFlowState = {
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
createdAt: Date.now() - 200000, // 200 seconds ago (> 180s TTL)
|
|
|
|
|
ttl: 180000,
|
|
|
|
|
};
|
|
|
|
|
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
|
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: true });
|
|
|
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining('Found failed OAuth flow'),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
timedOut: true,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should detect failed flow when TTL not specified and flow exceeds default TTL', async () => {
|
|
|
|
|
const mockFlowState = {
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
createdAt: Date.now() - 200000, // 200 seconds ago (> 180s default TTL)
|
|
|
|
|
// ttl not specified, should use 180000 default
|
|
|
|
|
};
|
|
|
|
|
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
|
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: true });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should detect active flow when status is PENDING and within TTL', async () => {
|
|
|
|
|
const mockFlowState = {
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
createdAt: Date.now() - 60000, // 1 minute ago (< 180s TTL)
|
|
|
|
|
ttl: 180000,
|
|
|
|
|
};
|
|
|
|
|
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
|
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({ hasActiveFlow: true, hasFailedFlow: false });
|
|
|
|
|
expect(logger.debug).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining('Found active OAuth flow'),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
flowId: mockFlowId,
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return false flags for other statuses', async () => {
|
|
|
|
|
const mockFlowState = {
|
|
|
|
|
status: 'COMPLETED',
|
|
|
|
|
createdAt: Date.now() - 60000,
|
|
|
|
|
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 = {
|
|
|
|
|
getFlowState: jest.fn(() => {
|
|
|
|
|
throw mockError;
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
|
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({ hasActiveFlow: false, hasFailedFlow: false });
|
|
|
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining('Error checking OAuth flows'),
|
|
|
|
|
mockError,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('getServerConnectionStatus', () => {
|
|
|
|
|
const mockUserId = 'user-123';
|
|
|
|
|
const mockServerName = 'test-server';
|
2025-12-04 21:37:23 +01:00
|
|
|
const mockConfig = { updatedAt: Date.now() };
|
2025-07-28 09:25:34 -07:00
|
|
|
|
|
|
|
|
it('should return app connection state when available', async () => {
|
2025-11-26 21:26:40 +01:00
|
|
|
const appConnections = new Map([
|
|
|
|
|
[
|
|
|
|
|
mockServerName,
|
|
|
|
|
{
|
|
|
|
|
connectionState: 'connected',
|
|
|
|
|
isStale: jest.fn(() => false),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
2025-07-28 09:25:34 -07:00
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set();
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: false,
|
|
|
|
|
connectionState: 'connected',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should fallback to user connection state when app connection not available', async () => {
|
|
|
|
|
const appConnections = new Map();
|
2025-11-26 21:26:40 +01:00
|
|
|
const userConnections = new Map([
|
|
|
|
|
[
|
|
|
|
|
mockServerName,
|
|
|
|
|
{
|
|
|
|
|
connectionState: 'connecting',
|
|
|
|
|
isStale: jest.fn(() => false),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
2025-07-28 09:25:34 -07:00
|
|
|
const oauthServers = new Set();
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: false,
|
|
|
|
|
connectionState: 'connecting',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should default to disconnected when no connections exist', async () => {
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set();
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: false,
|
|
|
|
|
connectionState: 'disconnected',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should prioritize app connection over user connection', async () => {
|
2025-11-26 21:26:40 +01:00
|
|
|
const appConnections = new Map([
|
|
|
|
|
[
|
|
|
|
|
mockServerName,
|
|
|
|
|
{
|
|
|
|
|
connectionState: 'connected',
|
|
|
|
|
isStale: jest.fn(() => false),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
const userConnections = new Map([
|
|
|
|
|
[
|
|
|
|
|
mockServerName,
|
|
|
|
|
{
|
|
|
|
|
connectionState: 'disconnected',
|
|
|
|
|
isStale: jest.fn(() => false),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
2025-07-28 09:25:34 -07:00
|
|
|
const oauthServers = new Set();
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: false,
|
|
|
|
|
connectionState: 'connected',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should indicate OAuth requirement when server is in OAuth servers set', async () => {
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
// Mock OAuthReconnectionManager
|
|
|
|
|
const mockOAuthReconnectionManager = {
|
|
|
|
|
isReconnecting: jest.fn(() => false),
|
|
|
|
|
};
|
|
|
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
|
|
2025-07-28 09:25:34 -07:00
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result.requiresOAuth).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle OAuth flow status when disconnected and requires OAuth with failed flow', async () => {
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
// Mock OAuthReconnectionManager
|
|
|
|
|
const mockOAuthReconnectionManager = {
|
|
|
|
|
isReconnecting: jest.fn(() => false),
|
|
|
|
|
};
|
|
|
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
|
|
2025-07-28 09:25:34 -07:00
|
|
|
// Mock flow state to return failed flow
|
|
|
|
|
const mockFlowManager = {
|
|
|
|
|
getFlowState: jest.fn(() => ({
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
createdAt: Date.now() - 60000,
|
|
|
|
|
ttl: 180000,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('test-flow-id');
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: true,
|
|
|
|
|
connectionState: 'error',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle OAuth flow status when disconnected and requires OAuth with active flow', async () => {
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
// Mock OAuthReconnectionManager
|
|
|
|
|
const mockOAuthReconnectionManager = {
|
|
|
|
|
isReconnecting: jest.fn(() => false),
|
|
|
|
|
};
|
|
|
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
|
|
2025-07-28 09:25:34 -07:00
|
|
|
// Mock flow state to return active flow
|
|
|
|
|
const mockFlowManager = {
|
|
|
|
|
getFlowState: jest.fn(() => ({
|
|
|
|
|
status: 'PENDING',
|
|
|
|
|
createdAt: Date.now() - 60000, // 1 minute ago
|
|
|
|
|
ttl: 180000, // 3 minutes TTL
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('test-flow-id');
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: true,
|
|
|
|
|
connectionState: 'connecting',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should handle OAuth flow status when disconnected and requires OAuth with no flow', async () => {
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
// Mock OAuthReconnectionManager
|
|
|
|
|
const mockOAuthReconnectionManager = {
|
|
|
|
|
isReconnecting: jest.fn(() => false),
|
|
|
|
|
};
|
|
|
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
|
|
2025-07-28 09:25:34 -07:00
|
|
|
// Mock flow state to return no flow
|
|
|
|
|
const mockFlowManager = {
|
|
|
|
|
getFlowState: jest.fn(() => null),
|
|
|
|
|
};
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('test-flow-id');
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: true,
|
|
|
|
|
connectionState: 'disconnected',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-17 22:49:36 +02:00
|
|
|
it('should return connecting state when OAuth server is reconnecting', async () => {
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
|
|
|
|
|
|
// Mock OAuthReconnectionManager to return true for isReconnecting
|
|
|
|
|
const mockOAuthReconnectionManager = {
|
|
|
|
|
isReconnecting: jest.fn(() => true),
|
|
|
|
|
};
|
|
|
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-09-17 22:49:36 +02:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: true,
|
|
|
|
|
connectionState: 'connecting',
|
|
|
|
|
});
|
|
|
|
|
expect(mockOAuthReconnectionManager.isReconnecting).toHaveBeenCalledWith(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2025-07-28 09:25:34 -07:00
|
|
|
it('should not check OAuth flow status when server is connected', async () => {
|
|
|
|
|
const mockFlowManager = {
|
|
|
|
|
getFlowState: jest.fn(),
|
|
|
|
|
};
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
|
|
2025-11-26 21:26:40 +01:00
|
|
|
const appConnections = new Map([
|
|
|
|
|
[
|
|
|
|
|
mockServerName,
|
|
|
|
|
{
|
|
|
|
|
connectionState: 'connected',
|
|
|
|
|
isStale: jest.fn(() => false),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
]);
|
2025-07-28 09:25:34 -07:00
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: true,
|
|
|
|
|
connectionState: 'connected',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should not call flow manager since server is connected
|
|
|
|
|
expect(mockFlowManager.getFlowState).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should not check OAuth flow status when server does not require OAuth', async () => {
|
|
|
|
|
const mockFlowManager = {
|
|
|
|
|
getFlowState: jest.fn(),
|
|
|
|
|
};
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
|
|
|
|
|
|
const appConnections = new Map();
|
|
|
|
|
const userConnections = new Map();
|
|
|
|
|
const oauthServers = new Set(); // Server not in OAuth servers
|
|
|
|
|
|
|
|
|
|
const result = await getServerConnectionStatus(
|
|
|
|
|
mockUserId,
|
|
|
|
|
mockServerName,
|
2025-11-26 21:26:40 +01:00
|
|
|
mockConfig,
|
2025-07-28 09:25:34 -07:00
|
|
|
appConnections,
|
|
|
|
|
userConnections,
|
|
|
|
|
oauthServers,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual({
|
|
|
|
|
requiresOAuth: false,
|
|
|
|
|
connectionState: 'disconnected',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should not call flow manager since server doesn't require OAuth
|
|
|
|
|
expect(mockFlowManager.getFlowState).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2025-09-24 22:48:38 -04:00
|
|
|
|
|
|
|
|
describe('User parameter passing tests', () => {
|
|
|
|
|
let mockReinitMCPServer;
|
|
|
|
|
let mockGetFlowStateManager;
|
|
|
|
|
let mockGetLogStores;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
mockReinitMCPServer = require('./Tools/mcp').reinitMCPServer;
|
|
|
|
|
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
|
|
|
|
mockGetLogStores = require('~/cache').getLogStores;
|
|
|
|
|
|
|
|
|
|
// Setup default mocks
|
|
|
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
|
mockGetFlowStateManager.mockReturnValue({
|
|
|
|
|
createFlowWithHandler: jest.fn(),
|
|
|
|
|
failFlow: jest.fn(),
|
|
|
|
|
});
|
2025-12-18 19:57:49 +01:00
|
|
|
|
|
|
|
|
// Reset domain validation mock to default (allow all)
|
|
|
|
|
mockIsMCPDomainAllowed.mockReset();
|
|
|
|
|
mockIsMCPDomainAllowed.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
// Reset registry mocks
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockReset();
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
// Reset getAppConfig mock to default (no restrictions)
|
|
|
|
|
mockGetAppConfig.mockReset();
|
|
|
|
|
mockGetAppConfig.mockResolvedValue({});
|
2025-09-24 22:48:38 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createMCPTools', () => {
|
|
|
|
|
it('should pass user parameter to reinitMCPServer when calling reconnectServer internally', async () => {
|
|
|
|
|
const mockUser = { id: 'test-user-123', name: 'Test User' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
const mockSignal = new AbortController().signal;
|
|
|
|
|
|
|
|
|
|
mockReinitMCPServer.mockResolvedValue({
|
|
|
|
|
tools: [{ name: 'test-tool' }],
|
|
|
|
|
availableTools: {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-09-24 22:48:38 -04:00
|
|
|
function: {
|
|
|
|
|
description: 'Test tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'test-server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
signal: mockSignal,
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify reinitMCPServer was called with the user
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'test-server',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(mockReinitMCPServer.mock.calls[0][0].user).toBe(mockUser);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should throw error if user is not provided', async () => {
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
mockReinitMCPServer.mockResolvedValue({
|
|
|
|
|
tools: [],
|
|
|
|
|
availableTools: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Call without user should throw error
|
|
|
|
|
await expect(
|
|
|
|
|
createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: undefined,
|
|
|
|
|
serverName: 'test-server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("Cannot read properties of undefined (reading 'id')");
|
|
|
|
|
|
|
|
|
|
// Verify reinitMCPServer was not called due to early error
|
|
|
|
|
expect(mockReinitMCPServer).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createMCPTool', () => {
|
|
|
|
|
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() };
|
|
|
|
|
const mockSignal = new AbortController().signal;
|
|
|
|
|
|
|
|
|
|
mockReinitMCPServer.mockResolvedValue({
|
|
|
|
|
availableTools: {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-09-24 22:48:38 -04:00
|
|
|
function: {
|
|
|
|
|
description: 'Test tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Call without availableTools to trigger reinit
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-09-24 22:48:38 -04:00
|
|
|
provider: 'openai',
|
|
|
|
|
signal: mockSignal,
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined, // Force reinit
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify reinitMCPServer was called with the user
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'test-server',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(mockReinitMCPServer.mock.calls[0][0].user).toBe(mockUser);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should not call reinitMCPServer when tool is in cache', async () => {
|
|
|
|
|
const mockUser = { id: 'test-user-789' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
const availableTools = {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-09-24 22:48:38 -04:00
|
|
|
function: {
|
|
|
|
|
description: 'Cached tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-09-24 22:48:38 -04:00
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: availableTools,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify reinitMCPServer was NOT called since tool was in cache
|
|
|
|
|
expect(mockReinitMCPServer).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('reinitMCPServer (via reconnectServer)', () => {
|
|
|
|
|
it('should always receive user parameter when called from createMCPTools', async () => {
|
|
|
|
|
const mockUser = { id: 'user-001', role: 'admin' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// Track all calls to reinitMCPServer
|
|
|
|
|
const reinitCalls = [];
|
|
|
|
|
mockReinitMCPServer.mockImplementation((params) => {
|
|
|
|
|
reinitCalls.push(params);
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
tools: [{ name: 'tool1' }, { name: 'tool2' }],
|
|
|
|
|
availableTools: {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`tool1${D}server1`]: { function: { description: 'Tool 1', parameters: {} } },
|
|
|
|
|
[`tool2${D}server1`]: { function: { description: 'Tool 2', parameters: {} } },
|
2025-09-24 22:48:38 -04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'server1',
|
|
|
|
|
provider: 'anthropic',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify all calls to reinitMCPServer had the user
|
|
|
|
|
expect(reinitCalls.length).toBeGreaterThan(0);
|
|
|
|
|
reinitCalls.forEach((call) => {
|
|
|
|
|
expect(call.user).toBe(mockUser);
|
|
|
|
|
expect(call.user.id).toBe('user-001');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should always receive user parameter when called from createMCPTool', async () => {
|
|
|
|
|
const mockUser = { id: 'user-002', permissions: ['read', 'write'] };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// Track all calls to reinitMCPServer
|
|
|
|
|
const reinitCalls = [];
|
|
|
|
|
mockReinitMCPServer.mockImplementation((params) => {
|
|
|
|
|
reinitCalls.push(params);
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
availableTools: {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`my-tool${D}my-server`]: {
|
2025-09-24 22:48:38 -04:00
|
|
|
function: { description: 'My Tool', parameters: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `my-tool${D}my-server`,
|
2025-09-24 22:48:38 -04:00
|
|
|
provider: 'google',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined, // Force reinit
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify the call to reinitMCPServer had the user
|
|
|
|
|
expect(reinitCalls.length).toBe(1);
|
|
|
|
|
expect(reinitCalls[0].user).toBe(mockUser);
|
|
|
|
|
expect(reinitCalls[0].user.id).toBe('user-002');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-18 19:57:49 +01:00
|
|
|
describe('Runtime domain validation', () => {
|
|
|
|
|
it('should skip tool creation when domain is not allowed', async () => {
|
|
|
|
|
const mockUser = { id: 'domain-test-user', role: 'user' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// Mock server config with URL (remote server)
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
|
|
|
url: 'https://disallowed-domain.com/sse',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock getAppConfig to return domain restrictions
|
|
|
|
|
mockGetAppConfig.mockResolvedValue({
|
|
|
|
|
mcpSettings: { allowedDomains: ['allowed-domain.com'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock domain validation to return false (domain not allowed)
|
|
|
|
|
mockIsMCPDomainAllowed.mockResolvedValueOnce(false);
|
|
|
|
|
|
|
|
|
|
const result = await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-12-18 19:57:49 +01:00
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-12-18 19:57:49 +01:00
|
|
|
function: {
|
|
|
|
|
description: 'Test tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should return undefined for disallowed domain
|
|
|
|
|
expect(result).toBeUndefined();
|
|
|
|
|
|
|
|
|
|
// Should not call reinitMCPServer since domain check failed
|
|
|
|
|
expect(mockReinitMCPServer).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Verify getAppConfig was called with user role
|
|
|
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' });
|
|
|
|
|
|
|
|
|
|
// Verify domain validation was called with correct parameters
|
|
|
|
|
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
|
|
|
|
|
{ url: 'https://disallowed-domain.com/sse' },
|
|
|
|
|
['allowed-domain.com'],
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should allow tool creation when domain is allowed', async () => {
|
|
|
|
|
const mockUser = { id: 'domain-test-user', role: 'admin' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// Mock server config with URL (remote server)
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
|
|
|
url: 'https://allowed-domain.com/sse',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock getAppConfig to return domain restrictions
|
|
|
|
|
mockGetAppConfig.mockResolvedValue({
|
|
|
|
|
mcpSettings: { allowedDomains: ['allowed-domain.com'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock domain validation to return true (domain allowed)
|
|
|
|
|
mockIsMCPDomainAllowed.mockResolvedValueOnce(true);
|
|
|
|
|
|
|
|
|
|
const availableTools = {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-12-18 19:57:49 +01:00
|
|
|
function: {
|
|
|
|
|
description: 'Test tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-12-18 19:57:49 +01:00
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should create tool successfully
|
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
|
|
|
|
|
|
// Verify getAppConfig was called with user role
|
|
|
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'admin' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should skip domain validation for stdio transports (no URL)', async () => {
|
|
|
|
|
const mockUser = { id: 'stdio-test-user' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// Mock server config without URL (stdio transport)
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
|
|
|
command: 'npx',
|
|
|
|
|
args: ['@modelcontextprotocol/server'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock getAppConfig (should not be called for stdio)
|
|
|
|
|
mockGetAppConfig.mockResolvedValue({
|
|
|
|
|
mcpSettings: { allowedDomains: ['restricted-domain.com'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const availableTools = {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-12-18 19:57:49 +01:00
|
|
|
function: {
|
|
|
|
|
description: 'Test tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const result = await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-12-18 19:57:49 +01:00
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should create tool successfully without domain check
|
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
|
|
|
|
|
|
// Should not call getAppConfig or isMCPDomainAllowed for stdio transport (no URL)
|
|
|
|
|
expect(mockGetAppConfig).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockIsMCPDomainAllowed).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should return empty array from createMCPTools when domain is not allowed', async () => {
|
|
|
|
|
const mockUser = { id: 'domain-test-user', role: 'user' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// Mock server config with URL (remote server)
|
|
|
|
|
const serverConfig = { url: 'https://disallowed-domain.com/sse' };
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue(serverConfig);
|
|
|
|
|
|
|
|
|
|
// Mock getAppConfig to return domain restrictions
|
|
|
|
|
mockGetAppConfig.mockResolvedValue({
|
|
|
|
|
mcpSettings: { allowedDomains: ['allowed-domain.com'] },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock domain validation to return false (domain not allowed)
|
|
|
|
|
mockIsMCPDomainAllowed.mockResolvedValueOnce(false);
|
|
|
|
|
|
|
|
|
|
const result = await createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'test-server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
config: serverConfig,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Should return empty array for disallowed domain
|
|
|
|
|
expect(result).toEqual([]);
|
|
|
|
|
|
|
|
|
|
// Should not call reinitMCPServer since domain check failed early
|
|
|
|
|
expect(mockReinitMCPServer).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
// Verify getAppConfig was called with user role
|
|
|
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should use user role when fetching domain restrictions', async () => {
|
|
|
|
|
const adminUser = { id: 'admin-user', role: 'admin' };
|
|
|
|
|
const regularUser = { id: 'regular-user', role: 'user' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
|
|
|
url: 'https://some-domain.com/sse',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mock different responses based on role
|
|
|
|
|
mockGetAppConfig
|
|
|
|
|
.mockResolvedValueOnce({ mcpSettings: { allowedDomains: ['admin-allowed.com'] } })
|
|
|
|
|
.mockResolvedValueOnce({ mcpSettings: { allowedDomains: ['user-allowed.com'] } });
|
|
|
|
|
|
|
|
|
|
mockIsMCPDomainAllowed.mockResolvedValue(true);
|
|
|
|
|
|
|
|
|
|
const availableTools = {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test-tool${D}test-server`]: {
|
2025-12-18 19:57:49 +01:00
|
|
|
function: {
|
|
|
|
|
description: 'Test tool',
|
|
|
|
|
parameters: { type: 'object', properties: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Call with admin user
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: adminUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-12-18 19:57:49 +01:00
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Reset and call with regular user
|
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
|
|
|
url: 'https://some-domain.com/sse',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: regularUser,
|
2026-02-13 13:33:25 -05:00
|
|
|
toolKey: `test-tool${D}test-server`,
|
2025-12-18 19:57:49 +01:00
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify getAppConfig was called with correct roles
|
|
|
|
|
expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, { role: 'admin' });
|
|
|
|
|
expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, { role: 'user' });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
🧪 chore: MCP Reconnect Storm Follow-Up Fixes and Integration Tests (#12172)
* 🧪 test: Add reconnection storm regression tests for MCPConnection
Introduced a comprehensive test suite for reconnection storm scenarios, validating circuit breaker, throttling, cooldown, and timeout fixes. The tests utilize real MCP SDK transports and a StreamableHTTP server to ensure accurate behavior under rapid connect/disconnect cycles and error handling for SSE 400/405 responses. This enhances the reliability of the MCPConnection by ensuring proper handling of reconnection logic and circuit breaker functionality.
* 🔧 fix: Update createUnavailableToolStub to return structured response
Modified the `createUnavailableToolStub` function to return an array containing the unavailable message and a null value, enhancing the response structure. Additionally, added a debug log to skip tool creation when the result is null, improving the handling of reconnection scenarios in the MCP service.
* 🧪 test: Enhance MCP tool creation tests for cache and throttle interactions
Added new test cases for the `createMCPTool` function to validate the caching behavior when tools are unavailable or throttled. The tests ensure that tools are correctly cached as missing and prevent unnecessary reconnects across different users, improving the reliability of the MCP service under concurrent usage scenarios. Additionally, introduced a test for the `createMCPTools` function to verify that it returns an empty array when reconnect is throttled, ensuring proper handling of throttling logic.
* 📝 docs: Update AGENTS.md with testing philosophy and guidelines
Expanded the testing section in AGENTS.md to emphasize the importance of using real logic over mocks, advocating for the use of spies and real dependencies in tests. Added specific recommendations for testing with MongoDB and MCP SDK, highlighting the need to mock only uncontrollable external services. This update aims to improve testing practices and encourage more robust test implementations.
* 🧪 test: Enhance reconnection storm tests with socket tracking and SSE handling
Updated the reconnection storm test suite to include a new socket tracking mechanism for better resource management during tests. Improved the handling of SSE 400/405 responses by ensuring they are processed in the same branch as 404 errors, preventing unhandled cases. This enhances the reliability of the MCPConnection under rapid reconnect scenarios and ensures proper error handling.
* 🔧 fix: Implement cache eviction for stale reconnect attempts and missing tools
Added an `evictStale` function to manage the size of the `lastReconnectAttempts` and `missingToolCache` maps, ensuring they do not exceed a maximum cache size. This enhancement improves resource management by removing outdated entries based on a specified time-to-live (TTL), thereby optimizing the MCP service's performance during reconnection scenarios.
2026-03-10 17:44:13 -04:00
|
|
|
describe('createUnavailableToolStub', () => {
|
|
|
|
|
it('should return a tool whose _call returns a valid CONTENT_AND_ARTIFACT two-tuple', async () => {
|
|
|
|
|
const stub = createUnavailableToolStub('myTool', 'myServer');
|
|
|
|
|
// invoke() goes through langchain's base tool, which checks responseFormat.
|
|
|
|
|
// CONTENT_AND_ARTIFACT requires [content, artifact] — a bare string would throw:
|
|
|
|
|
// "Tool response format is "content_and_artifact" but the output was not a two-tuple"
|
|
|
|
|
const result = await stub.invoke({});
|
|
|
|
|
// If we reach here without throwing, the two-tuple format is correct.
|
|
|
|
|
// invoke() returns the content portion of [content, artifact] as a string.
|
|
|
|
|
expect(result).toContain('temporarily unavailable');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('negative tool cache and throttle interaction', () => {
|
|
|
|
|
it('should cache tool as missing even when throttled (cross-user dedup)', async () => {
|
|
|
|
|
const mockUser = { id: 'throttle-test-user' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// First call: reconnect succeeds but tool not found
|
|
|
|
|
mockReinitMCPServer.mockResolvedValueOnce({
|
|
|
|
|
availableTools: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
toolKey: `missing-tool${D}cache-dedup-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Second call within 10s for DIFFERENT tool on same server:
|
|
|
|
|
// reconnect is throttled (returns null), tool is still cached as missing.
|
|
|
|
|
// This is intentional: the cache acts as cross-user dedup since the
|
|
|
|
|
// throttle is per-user-per-server and can't prevent N different users
|
|
|
|
|
// from each triggering their own reconnect.
|
|
|
|
|
const result2 = await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
toolKey: `other-tool${D}cache-dedup-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result2).toBeDefined();
|
|
|
|
|
expect(result2.name).toContain('other-tool');
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should prevent user B from triggering reconnect when user A already cached the tool', async () => {
|
|
|
|
|
const userA = { id: 'cache-user-A' };
|
|
|
|
|
const userB = { id: 'cache-user-B' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// User A: real reconnect, tool not found → cached
|
|
|
|
|
mockReinitMCPServer.mockResolvedValueOnce({
|
|
|
|
|
availableTools: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: userA,
|
|
|
|
|
toolKey: `shared-tool${D}cross-user-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
// User B requests the SAME tool within 10s.
|
|
|
|
|
// The negative cache is keyed by toolKey (no user prefix), so user B
|
|
|
|
|
// gets a cache hit and no reconnect fires. This is the cross-user
|
|
|
|
|
// storm protection: without this, user B's unthrottled first request
|
|
|
|
|
// would trigger a second reconnect to the same server.
|
|
|
|
|
const result = await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: userB,
|
|
|
|
|
toolKey: `shared-tool${D}cross-user-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
|
expect(result.name).toContain('shared-tool');
|
|
|
|
|
// reinitMCPServer still called only once — user B hit the cache
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should prevent user B from triggering reconnect for throttle-cached tools', async () => {
|
|
|
|
|
const userA = { id: 'storm-user-A' };
|
|
|
|
|
const userB = { id: 'storm-user-B' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// User A: real reconnect for tool-1, tool not found → cached
|
|
|
|
|
mockReinitMCPServer.mockResolvedValueOnce({
|
|
|
|
|
availableTools: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: userA,
|
|
|
|
|
toolKey: `tool-1${D}storm-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// User A: tool-2 on same server within 10s → throttled → cached from throttle
|
|
|
|
|
await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: userA,
|
|
|
|
|
toolKey: `tool-2${D}storm-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
|
|
|
|
|
|
|
|
|
// User B requests tool-2 — gets cache hit from the throttle-cached entry.
|
|
|
|
|
// Without this caching, user B would trigger a real reconnect since
|
|
|
|
|
// user B has their own throttle key and hasn't reconnected yet.
|
|
|
|
|
const result = await createMCPTool({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: userB,
|
|
|
|
|
toolKey: `tool-2${D}storm-server`,
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
availableTools: undefined,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
|
expect(result.name).toContain('tool-2');
|
|
|
|
|
// Still only 1 real reconnect — user B was protected by the cache
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('createMCPTools throttle handling', () => {
|
|
|
|
|
it('should return empty array with debug log when reconnect is throttled', async () => {
|
|
|
|
|
const mockUser = { id: 'throttle-tools-user' };
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
// First call: real reconnect
|
|
|
|
|
mockReinitMCPServer.mockResolvedValueOnce({
|
|
|
|
|
tools: [{ name: 'tool1' }],
|
|
|
|
|
availableTools: {
|
|
|
|
|
[`tool1${D}throttle-tools-server`]: {
|
|
|
|
|
function: { description: 'Tool 1', parameters: {} },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'throttle-tools-server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Second call within 10s — throttled
|
|
|
|
|
const result = await createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: mockUser,
|
|
|
|
|
serverName: 'throttle-tools-server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result).toEqual([]);
|
|
|
|
|
// reinitMCPServer called only once — second was throttled
|
|
|
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
|
|
|
|
// Should log at debug level (not warn) for throttled case
|
|
|
|
|
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Reconnect throttled'));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-24 22:48:38 -04:00
|
|
|
describe('User parameter integrity', () => {
|
|
|
|
|
it('should preserve user object properties through the call chain', async () => {
|
|
|
|
|
const complexUser = {
|
|
|
|
|
id: 'complex-user',
|
|
|
|
|
name: 'John Doe',
|
|
|
|
|
email: 'john@example.com',
|
|
|
|
|
metadata: { subscription: 'premium', settings: { theme: 'dark' } },
|
|
|
|
|
};
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
let capturedUser = null;
|
|
|
|
|
mockReinitMCPServer.mockImplementation((params) => {
|
|
|
|
|
capturedUser = params.user;
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
tools: [{ name: 'test' }],
|
|
|
|
|
availableTools: {
|
2026-02-13 13:33:25 -05:00
|
|
|
[`test${D}server`]: { function: { description: 'Test', parameters: {} } },
|
2025-09-24 22:48:38 -04:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: complexUser,
|
|
|
|
|
serverName: 'server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Verify the complete user object was passed
|
|
|
|
|
expect(capturedUser).toEqual(complexUser);
|
|
|
|
|
expect(capturedUser.id).toBe('complex-user');
|
|
|
|
|
expect(capturedUser.metadata.subscription).toBe('premium');
|
|
|
|
|
expect(capturedUser.metadata.settings.theme).toBe('dark');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('should throw error when user is null', async () => {
|
|
|
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
|
|
|
|
|
|
mockReinitMCPServer.mockResolvedValue({
|
|
|
|
|
tools: [],
|
|
|
|
|
availableTools: {},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
createMCPTools({
|
|
|
|
|
res: mockRes,
|
|
|
|
|
user: null,
|
|
|
|
|
serverName: 'test-server',
|
|
|
|
|
provider: 'openai',
|
|
|
|
|
userMCPAuthMap: {},
|
|
|
|
|
}),
|
|
|
|
|
).rejects.toThrow("Cannot read properties of null (reading 'id')");
|
|
|
|
|
|
|
|
|
|
// Verify reinitMCPServer was not called due to early error
|
|
|
|
|
expect(mockReinitMCPServer).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|