diff --git a/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx b/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx new file mode 100644 index 0000000000..eef2795a76 --- /dev/null +++ b/client/src/hooks/Config/__tests__/useAppStartup.spec.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { renderHook } from '@testing-library/react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import type { TUser } from 'librechat-data-provider'; + +const mockUseHasAccess = jest.fn(); +const mockUseMCPServersQuery = jest.fn(); +const mockUseMCPToolsQuery = jest.fn(); + +jest.mock('~/hooks', () => ({ + useHasAccess: (args: unknown) => mockUseHasAccess(args), +})); + +jest.mock('~/data-provider', () => ({ + useMCPServersQuery: (config: unknown) => mockUseMCPServersQuery(config), + useMCPToolsQuery: (config: unknown) => mockUseMCPToolsQuery(config), +})); + +jest.mock('../useSpeechSettingsInit', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('~/utils/timestamps', () => ({ + cleanupTimestampedStorage: jest.fn(), +})); + +jest.mock('react-gtm-module', () => ({ + __esModule: true, + default: { initialize: jest.fn() }, +})); + +import useAppStartup from '../useAppStartup'; + +const mockUser = { + id: 'user-123', + username: 'testuser', + email: 'test@example.com', + name: 'Test User', + avatar: '', + role: 'USER', + provider: 'local', + emailVerified: true, + createdAt: '2023-01-01T00:00:00.000Z', + updatedAt: '2023-01-01T00:00:00.000Z', +} as TUser; + +const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +describe('useAppStartup — MCP permission gating', () => { + beforeEach(() => { + mockUseMCPServersQuery.mockReturnValue({ data: undefined, isLoading: false }); + mockUseMCPToolsQuery.mockReturnValue({ data: undefined, isLoading: false }); + }); + + it('checks the MCP_SERVERS.USE permission via useHasAccess', () => { + mockUseHasAccess.mockReturnValue(false); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper }); + + expect(mockUseHasAccess).toHaveBeenCalledWith({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.USE, + }); + }); + + it('suppresses all MCP queries when user lacks MCP_SERVERS.USE', () => { + mockUseHasAccess.mockReturnValue(false); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper }); + + expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: false }); + expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false }); + }); + + it('enables servers query and tools query when permission granted, servers loaded, and user present', () => { + mockUseHasAccess.mockReturnValue(true); + mockUseMCPServersQuery.mockReturnValue({ + data: { 'test-server': { url: 'http://test' } }, + isLoading: false, + }); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper }); + + expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: true }); + }); + + it('suppresses tools query when permission granted but user prop is undefined', () => { + mockUseHasAccess.mockReturnValue(true); + mockUseMCPServersQuery.mockReturnValue({ + data: { 'test-server': { url: 'http://test' } }, + isLoading: false, + }); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: undefined }), { wrapper }); + + expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false }); + }); + + it('suppresses tools query when permission granted but no servers loaded', () => { + mockUseHasAccess.mockReturnValue(true); + mockUseMCPServersQuery.mockReturnValue({ data: {}, isLoading: false }); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper }); + + expect(mockUseMCPServersQuery).toHaveBeenCalledWith({ enabled: true }); + expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false }); + }); + + it('suppresses tools query while servers are still loading', () => { + mockUseHasAccess.mockReturnValue(true); + mockUseMCPServersQuery.mockReturnValue({ data: undefined, isLoading: true }); + + renderHook(() => useAppStartup({ startupConfig: undefined, user: mockUser }), { wrapper }); + + expect(mockUseMCPToolsQuery).toHaveBeenCalledWith({ enabled: false }); + }); +}); diff --git a/client/src/hooks/Config/useAppStartup.ts b/client/src/hooks/Config/useAppStartup.ts index 52b4325eea..f40b283ee2 100644 --- a/client/src/hooks/Config/useAppStartup.ts +++ b/client/src/hooks/Config/useAppStartup.ts @@ -1,11 +1,12 @@ import { useEffect } from 'react'; import { useRecoilState } from 'recoil'; import TagManager from 'react-gtm-module'; -import { LocalStorageKeys } from 'librechat-data-provider'; +import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider'; import type { TStartupConfig, TUser } from 'librechat-data-provider'; +import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider'; import { cleanupTimestampedStorage } from '~/utils/timestamps'; import useSpeechSettingsInit from './useSpeechSettingsInit'; -import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider'; +import { useHasAccess } from '~/hooks'; import store from '~/store'; export default function useAppStartup({ @@ -16,12 +17,23 @@ export default function useAppStartup({ user?: TUser; }) { const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset); + const canUseMcp = useHasAccess({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.USE, + }); useSpeechSettingsInit(!!user); - const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery(); + const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery({ + enabled: canUseMcp, + }); useMCPToolsQuery({ - enabled: !serversLoading && !!loadedServers && Object.keys(loadedServers).length > 0 && !!user, + enabled: + canUseMcp && + !serversLoading && + !!loadedServers && + Object.keys(loadedServers).length > 0 && + !!user, }); /** Clean up old localStorage entries on startup */ diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index af65ba4507..4ba1ff6278 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -2,7 +2,14 @@ import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; import { useAtom } from 'jotai'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; -import { Constants, QueryKeys, MCPOptions, ResourceType } from 'librechat-data-provider'; +import { + Constants, + QueryKeys, + MCPOptions, + Permissions, + ResourceType, + PermissionTypes, +} from 'librechat-data-provider'; import { useCancelMCPOAuthMutation, useUpdateUserPluginsMutation, @@ -11,7 +18,7 @@ import { } from 'librechat-data-provider/react-query'; import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider'; import type { ConfigFieldDetail } from '~/common'; -import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks'; +import { useLocalize, useHasAccess, useMCPSelect, useMCPConnectionStatus } from '~/hooks'; import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider'; import { mcpServerInitStatesAtom, getServerInitState } from '~/store/mcp'; import type { MCPServerInitState } from '~/store/mcp'; @@ -35,12 +42,19 @@ export function useMCPServerManager({ const localize = useLocalize(); const queryClient = useQueryClient(); const { showToast } = useToastContext(); - const { data: startupConfig } = useGetStartupConfig(); // Keep for UI config only + /** Retained for `interface.mcpServers.placeholder` used by `placeholderText` below */ + const { data: startupConfig } = useGetStartupConfig(); + const canUseMcp = useHasAccess({ + permissionType: PermissionTypes.MCP_SERVERS, + permission: Permissions.USE, + }); - const { data: loadedServers, isLoading } = useMCPServersQuery(); + const { data: loadedServers, isLoading } = useMCPServersQuery({ enabled: canUseMcp }); // Fetch effective permissions for all MCP servers - const { data: permissionsMap } = useGetAllEffectivePermissionsQuery(ResourceType.MCPSERVER); + const { data: permissionsMap } = useGetAllEffectivePermissionsQuery(ResourceType.MCPSERVER, { + enabled: canUseMcp, + }); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [selectedToolForConfig, setSelectedToolForConfig] = useState(null);