mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-26 17:31:27 +00:00
* 🤫 fix: Silent MCP OAuth Refresh on Mid-Session 401 Avoids the hourly interactive re-auth prompt when an MCP server (e.g. Azure Entra ID) returns 401 mid-session by attempting a refresh token exchange first, and only falling back to the interactive OAuth flow when no refresh token is stored or the refresh server rejects it. Resolves #13364. * fix: Use distinct flow type for silent token refresh to avoid cache hit Addresses the Codex review on PR #13369: `attemptSilentTokenRefresh` was reusing the `'mcp_get_tokens'` flow type, so `FlowStateManager.createFlowWithHandler` would short-circuit and return the same tokens cached by an earlier `getOAuthTokens` call — the very tokens the server just rejected — without executing the forced-refresh handler. Switch silent refresh to the distinct `'mcp_force_refresh_tokens'` flow type so coalescing still works but stale `mcp_get_tokens` cache entries are not reused. After a successful refresh, invalidate the `mcp_get_tokens` flow cache so the next `getOAuthTokens` call reads the freshly persisted tokens from storage rather than the stale cached value. Add a regression test that simulates the real `FlowStateManager.createFlowWithHandler` cache-hit behavior for `mcp_get_tokens` and verifies the silent refresh handler still runs and returns the freshly refreshed tokens. * fix: Address Codex round-2 review on silent MCP OAuth refresh Three follow-up findings from Codex on PR #13369: 1. The new `mcp_force_refresh_tokens` flow type was itself cached by `FlowStateManager.createFlowWithHandler`, so a subsequent 401 within the refreshed token's `expires_at` could re-serve the just-rejected token without ever re-running the refresh handler. 2. The factory's `oauthRequired` listener was removed immediately after the initial `attemptToConnect` succeeded, so a real mid-session 401 emitted by `MCPConnection.connectClient` during transport recovery had no listener — the OAuth handled-promise would simply time out instead of triggering the silent refresh. 3. Routing the silent refresh through a distinct flow type broke coalescing with the `mcp_get_tokens` lock used by `getOAuthTokens`, letting two paths concurrently redeem the same stored refresh token. For providers that rotate refresh tokens (e.g. Azure Entra) the second redemption is rejected, kicking the user back into interactive OAuth despite a successful refresh elsewhere. Resolution: - Drop `FlowStateManager` from the silent-refresh path entirely. Replace with a process-local `inflightSilentRefreshes` Map keyed by `userId:serverName` that holds only the in-flight Promise (no cached result), so every fresh 401 after settlement triggers a fresh redemption while concurrent 401s for the same user/server still share one redemption. - Stop calling `cleanupOAuthHandlers()` on successful initial connect, keeping the OAuth handler attached for the connection's lifetime so mid-session 401s actually reach `attemptSilentTokenRefresh`. - Add a regression test reproducing the stale-cache scenario by faking the `mcp_get_tokens` cache hit and asserting silent refresh still runs against storage and returns the fresh tokens. - Add a coalescing test asserting two concurrent oauthRequired events for the same user/server result in a single `forceRefreshTokens` call. - Clear `inflightSilentRefreshes` in `beforeEach` to prevent cross-test leakage; switch the silent-refresh test mocks to `mockResolvedValueOnce` / `mockImplementationOnce` so leftover mock state cannot leak into later test cases. Acknowledged remaining gap: the silent refresh still races `getOAuthTokens`'s `mcp_get_tokens` flow when both run concurrently (narrow window when an existing connection's local `expires_at` is still valid but the server invalidated the token, and a new connection is being created in parallel). The race is self-healing on the next 401 and documented inline. * fix: Address Codex round-3 review on silent MCP OAuth refresh Three more findings from Codex on PR #13369: 1. The in-flight silent-refresh promise was unbounded. If `forceRefreshTokens()` ever hung (slow provider, dropped TCP), the `inflightSilentRefreshes` lock stayed occupied forever and every later 401 for the same user/server joined the stuck promise instead of starting a fresh attempt or falling back to interactive OAuth. 2. The interactive-OAuth fallback didn't invalidate the `mcp_get_tokens` flow cache after persisting fresh tokens. For providers that don't issue refresh tokens (so silent refresh returns null), the old cache could still feed stale access tokens to the next `getOAuthTokens` call until its TTL expired — causing an immediate reconnect with the same just-rejected token. 3. When silent refresh failed, the handler fell through to `handleOAuthRequired()` whose recent-completion fast path can reuse a COMPLETED `mcp_oauth` flow within `PENDING_STALE_MS`. Those cached tokens are exactly the ones the server just rejected, so the connection would keep adopting them and looping on 401s until the cache aged out. Resolution: - Wrap `runSilentRefresh()` with a 60-second `withTimeout` (well under `connectClient`'s 120s OAuth timeout). On timeout the `.catch` resolves to null and the `finally` clears the in-flight entry, so the next 401 starts fresh and falls through to interactive OAuth. - Extract two helpers — `invalidateGetTokensFlow` and `invalidateCompletedOAuthFlow` — and call them from the right branches: clear `mcp_get_tokens` after silent-refresh success AND after interactive-OAuth `storeTokens`; clear the COMPLETED `mcp_oauth` state (plus its CSRF mapping) before falling through to interactive OAuth so the fast-reuse path can't re-serve the rejected tokens. - Add three regression tests: hung refresh release-the-lock under fake timers, completed-OAuth cache invalidation pre-fallback, and `mcp_get_tokens` invalidation after interactive token store. * fix: Address Codex round-4 review on silent MCP OAuth refresh Three more findings from Codex on PR #13369: 1. (P1) The silent-refresh in-flight lock keyed only by `userId:serverName`. In multi-tenant setups where two tenants share a userId (e.g. username-based IDs) and the same MCP server name, a concurrent mid-session 401 from tenant B would join tenant A's in-flight refresh and adopt tenant A's freshly minted tokens onto a tenant-B connection — a cross-tenant credential leak. 2. (P2) `invalidateGetTokensFlow` deleted the `mcp_get_tokens` flow state regardless of its status. When another connection was currently in `getOAuthTokens()` (PENDING flow) and joiners were monitoring it, the unconditional delete made those waiters see "Flow state not found" and unnecessarily fall back to interactive OAuth — even though fresh tokens were already being written. 3. (P2) The 60s `withTimeout` wrapping `runSilentRefresh()` only races the promise; it does not cancel the underlying `forceRefreshTokens` / refresh-token HTTP request. If the request returned after a subsequent interactive OAuth had stored newer tokens, the late completion would `storeTokens` over the newer state. This requires a provider that doesn't rotate refresh tokens AND a refresh slower than 60s AND a successful interactive OAuth in that window — narrow but real. Resolution: - Capture `getTenantId()` into a new `factory.tenantId` field at factory construction time (before the OAuth handler closes over it outside the original request's async context) and include it in the silent-refresh lock key as `tenantId:userId:serverName`. - `invalidateGetTokensFlow` now calls `getFlowState` first and only deletes when `status === 'COMPLETED'`. PENDING lookups are left alone so concurrent `getOAuthTokens` waiters via `monitorFlow` can still settle. - For (3), document the race as a known limitation inline. Fully closing it requires threading an `AbortSignal` through `MCPTokenStorage.forceRefreshTokens` and the OAuth refresh handler to skip the late `storeTokens` after timeout — out of scope for this PR's surgical change. - Add `getTenantId` to the `MCPOAuthConnectionEvents` test's `@librechat/data-schemas` mock so the factory constructor doesn't blow up under that suite. - Add three regression tests: per-tenant lock isolation, PENDING-state preservation under `invalidateGetTokensFlow`, and (reused) the existing interactive-store invalidation test now driven through `getFlowState` returning the COMPLETED state. * fix: Address silent MCP OAuth refresh review Restore captured tenant context around token storage and OAuth fallback paths so mid-session callbacks do not lose tenant scope. Thread AbortSignal through forced refresh and OAuth token requests, cap silent refresh by the connection OAuth timeout, and prevent timed-out refreshes from writing stale credentials after fallback. Complete pending mcp_get_tokens flows with fresh tokens, add missing FlowState createdAt test fixtures, and cover the new tenant/abort/cache behaviors. * fix: Tighten tenant-scoped MCP token refresh Cap silent refresh by both the factory connect timeout and the connection OAuth wait timeout so fallback OAuth wins before the outer connect attempt expires. Tenant-scope mcp_get_tokens flow ids for both token lookup and refresh invalidation, preventing cross-tenant flow completion or cache deletion when tenants share user ids and server names. Add regression tests for the omitted initTimeout budget and tenant-prefixed token flow locks. * fix: Reserve MCP OAuth fallback budget * fix: Harden MCP OAuth refresh races * fix: Keep MCP OAuth fallback route-compatible * test: Add SDK MCP OAuth refresh repro * fix: Address MCP OAuth refresh review findings * fix: Address MCP OAuth tenant review findings * fix: Close MCP OAuth route tenant gaps * fix: Preserve MCP OAuth refresh flow guards * fix: Avoid reprocessing MCP OAuth reauth config * fix: Release timed-out MCP refresh locks * fix: Release MCP OAuth request callbacks * fix: Tenant-scope remaining MCP OAuth flow lookups * ci: Sort imports in MCP OAuth test suites
1895 lines
60 KiB
JavaScript
1895 lines
60 KiB
JavaScript
// Mock all dependencies - define mocks before imports
|
|
const mockGetTenantId = jest.fn();
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
error: jest.fn(),
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
},
|
|
getTenantId: mockGetTenantId,
|
|
}));
|
|
|
|
// Create mock registry instance
|
|
const mockRegistryInstance = {
|
|
getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
|
|
getAllServerConfigs: jest.fn(() => Promise.resolve({})),
|
|
getServerConfig: jest.fn(() => Promise.resolve(null)),
|
|
ensureConfigServers: jest.fn(() => Promise.resolve({})),
|
|
};
|
|
|
|
// 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', () => {
|
|
const actual = jest.requireActual('@librechat/api');
|
|
return {
|
|
...actual,
|
|
sendEvent: jest.fn(),
|
|
get isMCPDomainAllowed() {
|
|
return mockIsMCPDomainAllowed;
|
|
},
|
|
GenerationJobManager: {
|
|
emitChunk: jest.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { MCPOAuthHandler } = require('@librechat/api');
|
|
const { CacheKeys, Constants, Permissions, PermissionTypes } = require('librechat-data-provider');
|
|
const D = Constants.mcp_delimiter;
|
|
const {
|
|
createMCPTool,
|
|
createMCPTools,
|
|
createMCPPermissionContext,
|
|
getMCPSetupData,
|
|
createOAuthStart,
|
|
checkOAuthFlowStatus,
|
|
getServerConnectionStatus,
|
|
createUnavailableToolStub,
|
|
} = require('./MCP');
|
|
|
|
jest.mock('./Config', () => ({
|
|
loadCustomConfig: jest.fn(),
|
|
get getAppConfig() {
|
|
return mockGetAppConfig;
|
|
},
|
|
}));
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: jest.fn(),
|
|
getFlowStateManager: jest.fn(),
|
|
getOAuthReconnectionManager: jest.fn(),
|
|
getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
findToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
getRoleByName: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('./Tools/mcp', () => ({
|
|
reinitMCPServer: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('./GraphTokenService', () => ({
|
|
getGraphApiToken: jest.fn(),
|
|
}));
|
|
|
|
describe('tests for the new helper functions used by the MCP connection status endpoints', () => {
|
|
let mockGetMCPManager;
|
|
let mockGetFlowStateManager;
|
|
let mockGetLogStores;
|
|
let mockGetOAuthReconnectionManager;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
jest.spyOn(MCPOAuthHandler, 'generateFlowId');
|
|
mockGetTenantId.mockReturnValue(undefined);
|
|
|
|
mockGetMCPManager = require('~/config').getMCPManager;
|
|
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
|
mockGetLogStores = require('~/cache').getLogStores;
|
|
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
|
|
});
|
|
|
|
describe('createOAuthStart', () => {
|
|
const flowId = 'test-server:oauth_login:thread-1:run-1';
|
|
const authUrl = 'https://auth.example.com/oauth?state=test';
|
|
|
|
it('should create a login flow and emit the OAuth URL for the first request', async () => {
|
|
const callback = jest.fn();
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue(null),
|
|
createFlowWithHandler: jest.fn(async (_flowId, _type, handler) => handler()),
|
|
};
|
|
|
|
const oauthStart = createOAuthStart({
|
|
flowId,
|
|
flowManager: mockFlowManager,
|
|
callback,
|
|
});
|
|
|
|
await expect(oauthStart(authUrl)).resolves.toBe(true);
|
|
|
|
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login');
|
|
expect(mockFlowManager.createFlowWithHandler).toHaveBeenCalledWith(
|
|
flowId,
|
|
'oauth_login',
|
|
expect.any(Function),
|
|
);
|
|
expect(callback).toHaveBeenCalledWith(authUrl);
|
|
expect(logger.debug).toHaveBeenCalledWith('Sent OAuth login request to client');
|
|
});
|
|
|
|
it('should replay the OAuth URL when the login flow already exists', async () => {
|
|
const callback = jest.fn();
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'COMPLETED',
|
|
result: true,
|
|
}),
|
|
createFlowWithHandler: jest.fn(),
|
|
};
|
|
|
|
const oauthStart = createOAuthStart({
|
|
flowId,
|
|
flowManager: mockFlowManager,
|
|
callback,
|
|
});
|
|
|
|
await expect(oauthStart(authUrl)).resolves.toBe(true);
|
|
|
|
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login');
|
|
expect(mockFlowManager.createFlowWithHandler).not.toHaveBeenCalled();
|
|
expect(callback).toHaveBeenCalledWith(authUrl);
|
|
expect(logger.debug).toHaveBeenCalledWith('Re-sent OAuth login request to client');
|
|
});
|
|
|
|
it('should replay the OAuth URL when flow creation is deduped internally', async () => {
|
|
const callback = jest.fn();
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue(null),
|
|
createFlowWithHandler: jest.fn().mockResolvedValue(true),
|
|
};
|
|
|
|
const oauthStart = createOAuthStart({
|
|
flowId,
|
|
flowManager: mockFlowManager,
|
|
callback,
|
|
});
|
|
|
|
await expect(oauthStart(authUrl)).resolves.toBe(true);
|
|
|
|
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login');
|
|
expect(mockFlowManager.createFlowWithHandler).toHaveBeenCalledWith(
|
|
flowId,
|
|
'oauth_login',
|
|
expect.any(Function),
|
|
);
|
|
expect(callback).toHaveBeenCalledWith(authUrl);
|
|
expect(logger.debug).toHaveBeenCalledWith('Re-sent OAuth login request to client');
|
|
});
|
|
});
|
|
|
|
describe('getMCPSetupData', () => {
|
|
const mockUserId = 'user-123';
|
|
const mockConfig = {
|
|
server1: { type: 'stdio' },
|
|
server2: { type: 'http' },
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockGetMCPManager.mockReturnValue({
|
|
appConnections: { getLoaded: jest.fn(() => new Map()) },
|
|
getUserConnections: jest.fn(() => new Map()),
|
|
});
|
|
mockRegistryInstance.getOAuthServers.mockResolvedValue(new Set());
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
|
|
});
|
|
|
|
it('should successfully return MCP setup data', async () => {
|
|
const mockConfigWithOAuth = {
|
|
server1: { type: 'stdio' },
|
|
server2: { type: 'http', requiresOAuth: true },
|
|
};
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfigWithOAuth);
|
|
|
|
const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
|
|
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
|
|
|
|
const mockMCPManager = {
|
|
appConnections: { getLoaded: jest.fn(() => Promise.resolve(mockAppConnections)) },
|
|
getUserConnections: jest.fn(() => mockUserConnections),
|
|
};
|
|
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
|
|
|
const result = await getMCPSetupData(mockUserId);
|
|
|
|
expect(mockRegistryInstance.ensureConfigServers).toHaveBeenCalled();
|
|
expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith(
|
|
mockUserId,
|
|
expect.any(Object),
|
|
);
|
|
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
|
|
expect(mockMCPManager.appConnections.getLoaded).toHaveBeenCalled();
|
|
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
|
|
|
|
expect(result.mcpConfig).toEqual(mockConfigWithOAuth);
|
|
expect(result.appConnections).toEqual(mockAppConnections);
|
|
expect(result.userConnections).toEqual(mockUserConnections);
|
|
expect(result.oauthServers).toEqual(new Set(['server2']));
|
|
});
|
|
|
|
it('should return empty data when no servers are configured', async () => {
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue({});
|
|
const result = await getMCPSetupData(mockUserId);
|
|
expect(result.mcpConfig).toEqual({});
|
|
expect(result.oauthServers).toEqual(new Set());
|
|
});
|
|
|
|
it('should handle null values from MCP manager gracefully', async () => {
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockConfig);
|
|
|
|
const mockMCPManager = {
|
|
appConnections: { getLoaded: jest.fn(() => Promise.resolve(null)) },
|
|
getUserConnections: jest.fn(() => null),
|
|
};
|
|
mockGetMCPManager.mockReturnValue(mockMCPManager);
|
|
mockRegistryInstance.getOAuthServers.mockResolvedValue(new Set());
|
|
|
|
const result = await getMCPSetupData(mockUserId);
|
|
|
|
expect(result).toEqual({
|
|
mcpConfig: mockConfig,
|
|
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() - 16 * 60 * 1000, // 16 minutes ago (past the PENDING_STALE_MS window)
|
|
// ttl not specified, should fall back to the PENDING_STALE_MS 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 check the tenant-scoped OAuth flow when tenant context exists', async () => {
|
|
mockGetTenantId.mockReturnValue('tenant/a');
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('tenant-flow-id');
|
|
const mockFlowState = {
|
|
status: 'PENDING',
|
|
createdAt: Date.now() - 60000,
|
|
ttl: 180000,
|
|
};
|
|
const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) };
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const result = await checkOAuthFlowStatus(mockUserId, mockServerName);
|
|
|
|
expect(MCPOAuthHandler.generateFlowId).toHaveBeenCalledWith(
|
|
mockUserId,
|
|
mockServerName,
|
|
'tenant/a',
|
|
);
|
|
expect(mockFlowManager.getFlowState).toHaveBeenCalledWith('tenant-flow-id', 'mcp_oauth');
|
|
expect(result).toEqual({ hasActiveFlow: true, hasFailedFlow: false });
|
|
});
|
|
|
|
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';
|
|
const mockConfig = { updatedAt: Date.now() };
|
|
|
|
it('should return app connection state when available', async () => {
|
|
const appConnections = new Map([
|
|
[
|
|
mockServerName,
|
|
{
|
|
connectionState: 'connected',
|
|
isStale: jest.fn(() => false),
|
|
},
|
|
],
|
|
]);
|
|
const userConnections = new Map();
|
|
const oauthServers = new Set();
|
|
|
|
const result = await getServerConnectionStatus(
|
|
mockUserId,
|
|
mockServerName,
|
|
mockConfig,
|
|
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();
|
|
const userConnections = new Map([
|
|
[
|
|
mockServerName,
|
|
{
|
|
connectionState: 'connecting',
|
|
isStale: jest.fn(() => false),
|
|
},
|
|
],
|
|
]);
|
|
const oauthServers = new Set();
|
|
|
|
const result = await getServerConnectionStatus(
|
|
mockUserId,
|
|
mockServerName,
|
|
mockConfig,
|
|
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,
|
|
mockConfig,
|
|
appConnections,
|
|
userConnections,
|
|
oauthServers,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
requiresOAuth: false,
|
|
connectionState: 'disconnected',
|
|
});
|
|
});
|
|
|
|
it('should prioritize app connection over user connection', async () => {
|
|
const appConnections = new Map([
|
|
[
|
|
mockServerName,
|
|
{
|
|
connectionState: 'connected',
|
|
isStale: jest.fn(() => false),
|
|
},
|
|
],
|
|
]);
|
|
const userConnections = new Map([
|
|
[
|
|
mockServerName,
|
|
{
|
|
connectionState: 'disconnected',
|
|
isStale: jest.fn(() => false),
|
|
},
|
|
],
|
|
]);
|
|
const oauthServers = new Set();
|
|
|
|
const result = await getServerConnectionStatus(
|
|
mockUserId,
|
|
mockServerName,
|
|
mockConfig,
|
|
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]);
|
|
|
|
// Mock OAuthReconnectionManager
|
|
const mockOAuthReconnectionManager = {
|
|
isReconnecting: jest.fn(() => false),
|
|
};
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
const result = await getServerConnectionStatus(
|
|
mockUserId,
|
|
mockServerName,
|
|
mockConfig,
|
|
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]);
|
|
|
|
// Mock OAuthReconnectionManager
|
|
const mockOAuthReconnectionManager = {
|
|
isReconnecting: jest.fn(() => false),
|
|
};
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
// 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,
|
|
mockConfig,
|
|
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]);
|
|
|
|
// Mock OAuthReconnectionManager
|
|
const mockOAuthReconnectionManager = {
|
|
isReconnecting: jest.fn(() => false),
|
|
};
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
// 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,
|
|
mockConfig,
|
|
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]);
|
|
|
|
// Mock OAuthReconnectionManager
|
|
const mockOAuthReconnectionManager = {
|
|
isReconnecting: jest.fn(() => false),
|
|
};
|
|
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
|
|
|
// 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,
|
|
mockConfig,
|
|
appConnections,
|
|
userConnections,
|
|
oauthServers,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
requiresOAuth: true,
|
|
connectionState: 'disconnected',
|
|
});
|
|
});
|
|
|
|
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,
|
|
mockConfig,
|
|
appConnections,
|
|
userConnections,
|
|
oauthServers,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
requiresOAuth: true,
|
|
connectionState: 'connecting',
|
|
});
|
|
expect(mockOAuthReconnectionManager.isReconnecting).toHaveBeenCalledWith(
|
|
mockUserId,
|
|
mockServerName,
|
|
);
|
|
});
|
|
|
|
it('should not check OAuth flow status when server is connected', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn(),
|
|
};
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
mockGetLogStores.mockReturnValue({});
|
|
|
|
const appConnections = new Map([
|
|
[
|
|
mockServerName,
|
|
{
|
|
connectionState: 'connected',
|
|
isStale: jest.fn(() => false),
|
|
},
|
|
],
|
|
]);
|
|
const userConnections = new Map();
|
|
const oauthServers = new Set([mockServerName]);
|
|
|
|
const result = await getServerConnectionStatus(
|
|
mockUserId,
|
|
mockServerName,
|
|
mockConfig,
|
|
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,
|
|
mockConfig,
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('User parameter passing tests', () => {
|
|
let mockReinitMCPServer;
|
|
let mockGetMCPManager;
|
|
let mockGetFlowStateManager;
|
|
let mockGetLogStores;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockGetTenantId.mockReturnValue(undefined);
|
|
mockReinitMCPServer = require('./Tools/mcp').reinitMCPServer;
|
|
mockGetMCPManager = require('~/config').getMCPManager;
|
|
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
|
mockGetLogStores = require('~/cache').getLogStores;
|
|
|
|
// Setup default mocks
|
|
mockGetLogStores.mockReturnValue({});
|
|
mockGetFlowStateManager.mockReturnValue({
|
|
createFlowWithHandler: jest.fn(),
|
|
failFlow: jest.fn(),
|
|
});
|
|
|
|
// 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({});
|
|
});
|
|
|
|
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: {
|
|
[`test-tool${D}test-server`]: {
|
|
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 fail tenant-scoped OAuth flows when tool loading is aborted', async () => {
|
|
const mockUser = { id: 'tenant-user', name: 'Tenant User' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const abortController = new AbortController();
|
|
const mockFlowManager = {
|
|
createFlowWithHandler: jest.fn(),
|
|
failFlow: jest.fn(),
|
|
};
|
|
mockGetTenantId.mockReturnValue('tenant/a');
|
|
mockGetFlowStateManager.mockReturnValue(mockFlowManager);
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('tenant-flow-id');
|
|
|
|
let resolveReinit;
|
|
mockReinitMCPServer.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveReinit = resolve;
|
|
}),
|
|
);
|
|
|
|
const createToolsPromise = createMCPTools({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
serverName: 'tenant-abort-server',
|
|
provider: 'openai',
|
|
signal: abortController.signal,
|
|
userMCPAuthMap: {},
|
|
config: { type: 'stdio' },
|
|
});
|
|
|
|
abortController.abort();
|
|
resolveReinit({ tools: [], availableTools: {} });
|
|
await createToolsPromise;
|
|
|
|
expect(MCPOAuthHandler.generateFlowId).toHaveBeenCalledWith(
|
|
mockUser.id,
|
|
'tenant-abort-server',
|
|
'tenant/a',
|
|
);
|
|
expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
|
|
'tenant-flow-id',
|
|
'mcp_oauth',
|
|
expect.any(Error),
|
|
);
|
|
expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
|
|
'tenant-flow-id',
|
|
'mcp_get_tokens',
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
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: {
|
|
[`test-tool${D}test-server`]: {
|
|
function: {
|
|
description: 'Test tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Call without availableTools to trigger reinit
|
|
await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `test-tool${D}test-server`,
|
|
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 report available tools discovered during single tool reinit', async () => {
|
|
const mockUser = { id: 'user-discovery-callback', role: 'USER' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const onAvailableTools = jest.fn();
|
|
const discoveredTools = {
|
|
[`my-tool${D}my-server`]: {
|
|
function: { description: 'My Tool', parameters: {} },
|
|
},
|
|
[`other-tool${D}my-server`]: {
|
|
function: { description: 'Other Tool', parameters: {} },
|
|
},
|
|
};
|
|
|
|
mockReinitMCPServer.mockResolvedValue({
|
|
availableTools: discoveredTools,
|
|
});
|
|
|
|
const result = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `my-tool${D}my-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools: undefined,
|
|
onAvailableTools,
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(onAvailableTools).toHaveBeenCalledWith(discoveredTools);
|
|
});
|
|
|
|
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 = {
|
|
[`test-tool${D}test-server`]: {
|
|
function: {
|
|
description: 'Cached tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
|
|
await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `test-tool${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools: availableTools,
|
|
});
|
|
|
|
// Verify reinitMCPServer was NOT called since tool was in cache
|
|
expect(mockReinitMCPServer).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reject tool execution when user lacks MCP server use permission', async () => {
|
|
const mockUser = { id: 'mcp-denied-user', role: 'USER' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const { getRoleByName } = require('~/models');
|
|
getRoleByName.mockResolvedValue({
|
|
permissions: {
|
|
[PermissionTypes.MCP_SERVERS]: {
|
|
[Permissions.USE]: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
const mcpTool = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
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',
|
|
},
|
|
toolCall: {},
|
|
},
|
|
),
|
|
).rejects.toThrow(
|
|
'[MCP][test-server][test-tool] tool call failed: Forbidden: Insufficient MCP server permissions',
|
|
);
|
|
expect(mockGetMCPManager).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should reuse request-scoped MCP permission checks across tool executions', async () => {
|
|
const mockUser = { id: 'mcp-allowed-user', role: 'USER' };
|
|
const mockReq = { user: mockUser };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const { getRoleByName } = require('~/models');
|
|
getRoleByName.mockResolvedValue({
|
|
permissions: {
|
|
[PermissionTypes.MCP_SERVERS]: {
|
|
[Permissions.USE]: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockCallTool = jest.fn().mockResolvedValue(['ok', null]);
|
|
mockGetMCPManager.mockReturnValue({
|
|
callTool: mockCallTool,
|
|
});
|
|
|
|
const availableTools = {
|
|
[`search${D}test-server`]: {
|
|
function: {
|
|
description: 'Search tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
[`fetch${D}test-server`]: {
|
|
function: {
|
|
description: 'Fetch tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
const mcpPermissionContext = createMCPPermissionContext(mockReq);
|
|
|
|
const searchTool = await createMCPTool({
|
|
mcpPermissionContext,
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `search${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools,
|
|
});
|
|
const fetchTool = await createMCPTool({
|
|
mcpPermissionContext,
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `fetch${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools,
|
|
});
|
|
|
|
const invocationConfig = {
|
|
configurable: {
|
|
user: mockUser,
|
|
},
|
|
metadata: {
|
|
provider: 'openai',
|
|
thread_id: 'thread-1',
|
|
run_id: 'run-1',
|
|
},
|
|
toolCall: {},
|
|
};
|
|
|
|
await expect(searchTool.invoke({}, invocationConfig)).resolves.toBe('ok');
|
|
await expect(fetchTool.invoke({}, invocationConfig)).resolves.toBe('ok');
|
|
|
|
expect(getRoleByName).toHaveBeenCalledTimes(1);
|
|
expect(mockCallTool).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should pass the captured user to MCPManager.callTool when invocation config omits configurable.user', async () => {
|
|
const mockUser = { id: 'captured-user', email: 'captured@example.com', role: 'USER' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const { getRoleByName } = require('~/models');
|
|
getRoleByName.mockResolvedValue({
|
|
permissions: {
|
|
[PermissionTypes.MCP_SERVERS]: {
|
|
[Permissions.USE]: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockCallTool = jest.fn().mockResolvedValue(['ok', null]);
|
|
mockGetMCPManager.mockReturnValue({
|
|
callTool: mockCallTool,
|
|
});
|
|
|
|
const mcpTool = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
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_id: mockUser.id,
|
|
},
|
|
metadata: {
|
|
provider: 'openai',
|
|
thread_id: 'thread-1',
|
|
run_id: 'run-1',
|
|
},
|
|
toolCall: {},
|
|
},
|
|
),
|
|
).resolves.toBe('ok');
|
|
|
|
expect(mockCallTool).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName: 'test-server',
|
|
toolName: 'test-tool',
|
|
user: mockUser,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should pass captured request body when invocation config omits requestBody', async () => {
|
|
const mockUser = { id: 'captured-body-user', email: 'captured@example.com', role: 'USER' };
|
|
const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const { getRoleByName } = require('~/models');
|
|
getRoleByName.mockResolvedValue({
|
|
permissions: {
|
|
[PermissionTypes.MCP_SERVERS]: {
|
|
[Permissions.USE]: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const mockCallTool = jest.fn().mockResolvedValue(['ok', null]);
|
|
mockGetMCPManager.mockReturnValue({
|
|
callTool: mockCallTool,
|
|
});
|
|
|
|
const mcpTool = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
requestBody,
|
|
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: {},
|
|
},
|
|
),
|
|
).resolves.toBe('ok');
|
|
|
|
expect(mockCallTool).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
serverName: 'test-server',
|
|
toolName: 'test-tool',
|
|
requestBody,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
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: {
|
|
[`tool1${D}server1`]: { function: { description: 'Tool 1', parameters: {} } },
|
|
[`tool2${D}server1`]: { function: { description: 'Tool 2', parameters: {} } },
|
|
},
|
|
});
|
|
});
|
|
|
|
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: {
|
|
[`my-tool${D}my-server`]: {
|
|
function: { description: 'My Tool', parameters: {} },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `my-tool${D}my-server`,
|
|
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');
|
|
});
|
|
});
|
|
|
|
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,
|
|
toolKey: `test-tool${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools: {
|
|
[`test-tool${D}test-server`]: {
|
|
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 the user scope
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({
|
|
role: 'user',
|
|
tenantId: undefined,
|
|
userId: 'domain-test-user',
|
|
});
|
|
|
|
// Verify domain validation was called with correct parameters
|
|
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
|
|
{ url: 'https://disallowed-domain.com/sse' },
|
|
['allowed-domain.com'],
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
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 = {
|
|
[`test-tool${D}test-server`]: {
|
|
function: {
|
|
description: 'Test tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `test-tool${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools,
|
|
});
|
|
|
|
// Should create tool successfully
|
|
expect(result).toBeDefined();
|
|
|
|
// Verify getAppConfig was called with the user scope
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({
|
|
role: 'admin',
|
|
tenantId: undefined,
|
|
userId: 'domain-test-user',
|
|
});
|
|
});
|
|
|
|
it('should validate the resolved runtime URL for tool creation', async () => {
|
|
const mockUser = { id: 'runtime-domain-user', role: 'user' };
|
|
const requestBody = { conversationId: 'tenant-a' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://{{LIBRECHAT_BODY_CONVERSATIONID}}.example.com/sse',
|
|
source: 'yaml',
|
|
});
|
|
|
|
mockGetAppConfig.mockResolvedValue({
|
|
mcpSettings: { allowedDomains: ['*.example.com'] },
|
|
});
|
|
|
|
mockIsMCPDomainAllowed.mockResolvedValueOnce(true);
|
|
|
|
const result = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
requestBody,
|
|
toolKey: `test-tool${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools: {
|
|
[`test-tool${D}test-server`]: {
|
|
function: {
|
|
description: 'Test tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
url: 'https://tenant-a.example.com/sse',
|
|
}),
|
|
['*.example.com'],
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
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 = {
|
|
[`test-tool${D}test-server`]: {
|
|
function: {
|
|
description: 'Test tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await createMCPTool({
|
|
res: mockRes,
|
|
user: mockUser,
|
|
toolKey: `test-tool${D}test-server`,
|
|
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 the user scope
|
|
expect(mockGetAppConfig).toHaveBeenCalledWith({
|
|
role: 'user',
|
|
tenantId: undefined,
|
|
userId: 'domain-test-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 = {
|
|
[`test-tool${D}test-server`]: {
|
|
function: {
|
|
description: 'Test tool',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
};
|
|
|
|
// Call with admin user
|
|
await createMCPTool({
|
|
res: mockRes,
|
|
user: adminUser,
|
|
toolKey: `test-tool${D}test-server`,
|
|
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,
|
|
toolKey: `test-tool${D}test-server`,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools,
|
|
});
|
|
|
|
// Verify getAppConfig was called with the correct user scopes
|
|
expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, {
|
|
role: 'admin',
|
|
tenantId: undefined,
|
|
userId: 'admin-user',
|
|
});
|
|
expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, {
|
|
role: 'user',
|
|
tenantId: undefined,
|
|
userId: 'regular-user',
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('should bypass the negative cache for request-scoped tools', async () => {
|
|
const userA = { id: 'request-scoped-user-A' };
|
|
const userB = { id: 'request-scoped-user-B' };
|
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
|
const serverName = 'request-scoped-server';
|
|
const toolKey = `tenant-tool${D}${serverName}`;
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
type: 'streamable-http',
|
|
url: 'https://api.example.com/{{LIBRECHAT_BODY_MESSAGEID}}/mcp',
|
|
source: 'yaml',
|
|
});
|
|
|
|
mockReinitMCPServer
|
|
.mockResolvedValueOnce({
|
|
availableTools: {},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
availableTools: {
|
|
[toolKey]: {
|
|
function: { description: 'Tenant tool', parameters: {} },
|
|
},
|
|
},
|
|
});
|
|
|
|
await createMCPTool({
|
|
res: mockRes,
|
|
user: userA,
|
|
requestBody: { messageId: 'message-a' },
|
|
toolKey,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools: undefined,
|
|
});
|
|
|
|
const result = await createMCPTool({
|
|
res: mockRes,
|
|
user: userB,
|
|
requestBody: { messageId: 'message-b' },
|
|
toolKey,
|
|
provider: 'openai',
|
|
userMCPAuthMap: {},
|
|
availableTools: undefined,
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.name).toContain('tenant-tool');
|
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|
|
|
|
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: {
|
|
[`test${D}server`]: { function: { description: 'Test', parameters: {} } },
|
|
},
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|