mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-25 19:56:13 +01:00
🧩 refactor: Decouple MCP Config from Startup Config (#10689)
* Decouple mcp config from start up config * Chore: Work on AI Review and Copilot Comments - setRawConfig is not needed since the private raw config is not needed any more - !!serversLoading bug fixed - added unit tests for route /api/mcp/servers - copilot comments addressed * chore: remove comments * chore: rename data-provider dir for MCP * chore: reorganize mcp specific query hooks * fix: consolidate imports for MCP server manager * chore: add dev-staging branch to frontend review workflow triggers * feat: add GitHub Actions workflow for building and pushing Docker images to GitHub Container Registry and Docker Hub * fix: update label for tag input in BookmarkForm tests to improve clarity --------- Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
98b188f26c
commit
ef1b7f0157
36 changed files with 548 additions and 301 deletions
|
|
@ -74,7 +74,6 @@ export function useApplyAgentTemplate() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Merge model spec fields into ephemeral agent
|
||||
const mergedAgent = {
|
||||
...ephemeralAgent,
|
||||
mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])],
|
||||
|
|
@ -83,7 +82,6 @@ export function useApplyAgentTemplate() {
|
|||
execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false,
|
||||
};
|
||||
|
||||
// Deduplicate MCP servers
|
||||
mergedAgent.mcp = [...new Set(mergedAgent.mcp)];
|
||||
|
||||
applyAgentTemplate(targetId, sourceId, mergedAgent);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
|
|||
import type { TStartupConfig, TUser } from 'librechat-data-provider';
|
||||
import { cleanupTimestampedStorage } from '~/utils/timestamps';
|
||||
import useSpeechSettingsInit from './useSpeechSettingsInit';
|
||||
import { useMCPToolsQuery } from '~/data-provider';
|
||||
import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
export default function useAppStartup({
|
||||
|
|
@ -18,9 +18,10 @@ export default function useAppStartup({
|
|||
const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset);
|
||||
|
||||
useSpeechSettingsInit(!!user);
|
||||
const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery();
|
||||
|
||||
useMCPToolsQuery({
|
||||
enabled: !!startupConfig?.mcpServers && !!user,
|
||||
enabled: !serversLoading && !!loadedServers && Object.keys(loadedServers).length > 0 && !!user,
|
||||
});
|
||||
|
||||
/** Clean up old localStorage entries on startup */
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
|||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { setTimestamp } from '~/utils/timestamps';
|
||||
import { useMCPSelect } from '../useMCPSelect';
|
||||
import * as dataProvider from '~/data-provider';
|
||||
import { MCPServerDefinition } from '../useMCPServerManager';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('~/utils/timestamps', () => ({
|
||||
|
|
@ -15,27 +15,28 @@ jest.mock('~/utils/timestamps', () => ({
|
|||
|
||||
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
...jest.requireActual('~/data-provider'),
|
||||
useGetStartupConfig: jest.fn(),
|
||||
}));
|
||||
// Helper to create MCPServerDefinition objects
|
||||
const createMCPServers = (serverNames: string[]): MCPServerDefinition[] => {
|
||||
return serverNames.map((serverName) => ({
|
||||
serverName,
|
||||
config: {
|
||||
url: 'http://mcp',
|
||||
},
|
||||
effectivePermissions: 15, // All permissions (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
|
||||
}));
|
||||
};
|
||||
|
||||
const createWrapper = (mcpServers: string[] = []) => {
|
||||
// Create a new Jotai store for each test to ensure clean state
|
||||
const store = createStore();
|
||||
|
||||
// Mock the startup config
|
||||
(dataProvider.useGetStartupConfig as jest.Mock).mockReturnValue({
|
||||
data: { mcpServers: Object.fromEntries(mcpServers.map((v) => [v, {}])) },
|
||||
isLoading: false,
|
||||
});
|
||||
const servers = createMCPServers(mcpServers);
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<RecoilRoot>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
return Wrapper;
|
||||
return { Wrapper, servers };
|
||||
};
|
||||
|
||||
describe('useMCPSelect', () => {
|
||||
|
|
@ -46,8 +47,9 @@ describe('useMCPSelect', () => {
|
|||
|
||||
describe('Basic Functionality', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
|
|
@ -58,16 +60,18 @@ describe('useMCPSelect', () => {
|
|||
|
||||
it('should use conversationId when provided', () => {
|
||||
const conversationId = 'test-convo-123';
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use NEW_CONVO constant when conversationId is null', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: null }), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: null, servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
|
|
@ -76,8 +80,9 @@ describe('useMCPSelect', () => {
|
|||
|
||||
describe('State Updates', () => {
|
||||
it('should update mcpValues when setMCPValues is called', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(['value1', 'value2']),
|
||||
const { Wrapper, servers } = createWrapper(['value1', 'value2']);
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const newValues = ['value1', 'value2'];
|
||||
|
|
@ -92,8 +97,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should not update mcpValues if non-array is passed', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -105,8 +111,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should update isPinned state', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
// Default is true
|
||||
|
|
@ -131,8 +138,9 @@ describe('useMCPSelect', () => {
|
|||
describe('Timestamp Management', () => {
|
||||
it('should set timestamp when mcpValues is updated with values', async () => {
|
||||
const conversationId = 'test-convo';
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const newValues = ['value1', 'value2'];
|
||||
|
|
@ -148,8 +156,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should not set timestamp when mcpValues is empty', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -164,8 +173,9 @@ describe('useMCPSelect', () => {
|
|||
|
||||
describe('Race Conditions and Infinite Loops Prevention', () => {
|
||||
it('should not create infinite loop when syncing between Jotai and Recoil states', async () => {
|
||||
const { result, rerender } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result, rerender } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
let renderCount = 0;
|
||||
|
|
@ -196,8 +206,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should handle rapid consecutive updates without race conditions', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const updates = [
|
||||
|
|
@ -222,8 +233,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should maintain stable setter function reference', () => {
|
||||
const { result, rerender } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result, rerender } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const firstSetMCPValues = result.current.setMCPValues;
|
||||
|
|
@ -238,10 +250,11 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should handle switching conversation IDs without issues', async () => {
|
||||
const { Wrapper, servers } = createWrapper(['convo1-value', 'convo2-value']);
|
||||
const { result, rerender } = renderHook(
|
||||
({ conversationId }) => useMCPSelect({ conversationId }),
|
||||
({ conversationId }) => useMCPSelect({ conversationId, servers }),
|
||||
{
|
||||
wrapper: createWrapper(['convo1-value', 'convo2-value']),
|
||||
wrapper: Wrapper,
|
||||
initialProps: { conversationId: 'convo1' },
|
||||
},
|
||||
);
|
||||
|
|
@ -283,18 +296,18 @@ describe('useMCPSelect', () => {
|
|||
describe('Ephemeral Agent Synchronization', () => {
|
||||
it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
|
||||
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context
|
||||
const wrapper = createWrapper(['external-value1', 'external-value2']);
|
||||
const { Wrapper, servers } = createWrapper(['external-value1', 'external-value2']);
|
||||
|
||||
// Create a component that uses both hooks to ensure they share state
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
|
||||
ephemeralAgentByConvoId(Constants.NEW_CONVO),
|
||||
);
|
||||
return { mcpHook, ephemeralAgent, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
// Simulate external update to ephemeralAgent (e.g., from another component)
|
||||
const externalMcpValues = ['external-value1', 'external-value2'];
|
||||
|
|
@ -311,15 +324,15 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should filter out MCPs not in configured servers', async () => {
|
||||
const wrapper = createWrapper(['server1', 'server2']);
|
||||
const { Wrapper, servers } = createWrapper(['server1', 'server2']);
|
||||
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
|
|
@ -333,15 +346,15 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should clear all MCPs when none are in configured servers', async () => {
|
||||
const wrapper = createWrapper(['server1', 'server2']);
|
||||
const { Wrapper, servers } = createWrapper(['server1', 'server2']);
|
||||
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
|
|
@ -355,15 +368,15 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should keep all MCPs when all are in configured servers', async () => {
|
||||
const wrapper = createWrapper(['server1', 'server2', 'server3']);
|
||||
const { Wrapper, servers } = createWrapper(['server1', 'server2', 'server3']);
|
||||
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
|
|
@ -378,16 +391,16 @@ describe('useMCPSelect', () => {
|
|||
|
||||
it('should update ephemeralAgent when mcpValues changes through hook', async () => {
|
||||
// Create a shared wrapper for both hooks
|
||||
const wrapper = createWrapper(['hook-value1', 'hook-value2']);
|
||||
const { Wrapper, servers } = createWrapper(['hook-value1', 'hook-value2']);
|
||||
|
||||
// Create a component that uses both the hook and accesses Recoil state
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, ephemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
const newValues = ['hook-value1', 'hook-value2'];
|
||||
|
||||
|
|
@ -404,16 +417,16 @@ describe('useMCPSelect', () => {
|
|||
|
||||
it('should handle empty ephemeralAgent.mcp array correctly', async () => {
|
||||
// Create a shared wrapper
|
||||
const wrapper = createWrapper(['initial-value']);
|
||||
const { Wrapper, servers } = createWrapper(['initial-value']);
|
||||
|
||||
// Create a component that uses both hooks
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
// Set initial values
|
||||
act(() => {
|
||||
|
|
@ -438,16 +451,16 @@ describe('useMCPSelect', () => {
|
|||
|
||||
it('should handle ephemeralAgent with clear mcp value', async () => {
|
||||
// Create a shared wrapper
|
||||
const wrapper = createWrapper(['server1', 'server2']);
|
||||
const { Wrapper, servers } = createWrapper(['server1', 'server2']);
|
||||
|
||||
// Create a component that uses both hooks
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
// Set initial values
|
||||
act(() => {
|
||||
|
|
@ -473,15 +486,21 @@ describe('useMCPSelect', () => {
|
|||
|
||||
it('should properly sync non-empty arrays from ephemeralAgent', async () => {
|
||||
// Additional test to ensure non-empty arrays DO sync
|
||||
const wrapper = createWrapper(['value1', 'value2', 'value3', 'value4', 'value5']);
|
||||
const { Wrapper, servers } = createWrapper([
|
||||
'value1',
|
||||
'value2',
|
||||
'value3',
|
||||
'value4',
|
||||
'value5',
|
||||
]);
|
||||
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const mcpHook = useMCPSelect({ servers });
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
|
||||
|
||||
// Set initial values through ephemeralAgent with non-empty array
|
||||
const initialValues = ['value1', 'value2'];
|
||||
|
|
@ -513,8 +532,9 @@ describe('useMCPSelect', () => {
|
|||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined conversationId', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), {
|
||||
wrapper: createWrapper(['test']),
|
||||
const { Wrapper, servers } = createWrapper(['test']);
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined, servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
|
|
@ -527,8 +547,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should handle empty string conversationId', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: '' }), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: '', servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
|
|
@ -536,8 +557,9 @@ describe('useMCPSelect', () => {
|
|||
|
||||
it('should handle very large arrays without performance issues', async () => {
|
||||
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(largeArray),
|
||||
const { Wrapper, servers } = createWrapper(largeArray);
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
const startTime = performance.now();
|
||||
|
|
@ -558,8 +580,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should cleanup properly on unmount', () => {
|
||||
const { unmount } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { unmount } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
// Should unmount without errors
|
||||
|
|
@ -570,8 +593,9 @@ describe('useMCPSelect', () => {
|
|||
describe('Memory Leak Prevention', () => {
|
||||
it('should not leak memory on repeated updates', async () => {
|
||||
const values = Array.from({ length: 100 }, (_, i) => `value-${i}`);
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(values),
|
||||
const { Wrapper, servers } = createWrapper(values);
|
||||
const { result } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
// Perform many updates to test for memory leaks
|
||||
|
|
@ -586,8 +610,9 @@ describe('useMCPSelect', () => {
|
|||
});
|
||||
|
||||
it('should handle component remounting', () => {
|
||||
const { result, unmount } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper, servers } = createWrapper();
|
||||
const { result, unmount } = renderHook(() => useMCPSelect({ servers }), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -597,8 +622,9 @@ describe('useMCPSelect', () => {
|
|||
unmount();
|
||||
|
||||
// Remount
|
||||
const { result: newResult } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
const { Wrapper: Wrapper2, servers: servers2 } = createWrapper();
|
||||
const { result: newResult } = renderHook(() => useMCPSelect({ servers: servers2 }), {
|
||||
wrapper: Wrapper2,
|
||||
});
|
||||
|
||||
// Should handle remounting gracefully
|
||||
|
|
|
|||
|
|
@ -4,15 +4,20 @@ import isEqual from 'lodash/isEqual';
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { setTimestamp } from '~/utils/timestamps';
|
||||
import { MCPServerDefinition } from './useMCPServerManager';
|
||||
|
||||
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
export function useMCPSelect({
|
||||
conversationId,
|
||||
servers,
|
||||
}: {
|
||||
conversationId?: string | null;
|
||||
servers: MCPServerDefinition[];
|
||||
}) {
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const configuredServers = useMemo(() => {
|
||||
return new Set(Object.keys(startupConfig?.mcpServers ?? {}));
|
||||
}, [startupConfig?.mcpServers]);
|
||||
return new Set(servers?.map((s) => s.serverName));
|
||||
}, [servers]);
|
||||
|
||||
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
|
||||
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import { Constants, QueryKeys, MCPOptions } from 'librechat-data-provider';
|
||||
import {
|
||||
useCancelMCPOAuthMutation,
|
||||
useUpdateUserPluginsMutation,
|
||||
|
|
@ -10,7 +10,15 @@ import {
|
|||
import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
|
||||
|
||||
export interface MCPServerDefinition {
|
||||
serverName: string;
|
||||
config: MCPOptions;
|
||||
mcp_id?: string;
|
||||
_id?: string; // MongoDB ObjectId for database servers (used for permissions)
|
||||
effectivePermissions: number; // Permission bits (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
isInitializing: boolean;
|
||||
|
|
@ -24,12 +32,40 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
|
||||
const { data: startupConfig } = useGetStartupConfig(); // Keep for UI config only
|
||||
|
||||
const { data: loadedServers, isLoading } = useMCPServersQuery();
|
||||
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const configuredServers = useMemo(() => {
|
||||
if (!loadedServers) return [];
|
||||
return Object.keys(loadedServers).filter((name) => loadedServers[name]?.chatMenu !== false);
|
||||
}, [loadedServers]);
|
||||
|
||||
const availableMCPServers: MCPServerDefinition[] = useMemo<MCPServerDefinition[]>(() => {
|
||||
const definitions: MCPServerDefinition[] = [];
|
||||
if (loadedServers) {
|
||||
for (const [serverName, metadata] of Object.entries(loadedServers)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _id, mcp_id, effectivePermissions, author, updatedAt, createdAt, ...config } =
|
||||
metadata;
|
||||
definitions.push({
|
||||
serverName,
|
||||
mcp_id,
|
||||
effectivePermissions: effectivePermissions || 1,
|
||||
config,
|
||||
});
|
||||
}
|
||||
}
|
||||
return definitions;
|
||||
}, [loadedServers]);
|
||||
|
||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({
|
||||
conversationId,
|
||||
servers: availableMCPServers,
|
||||
});
|
||||
const mcpValuesRef = useRef(mcpValues);
|
||||
|
||||
// fixes the issue where OAuth flows would deselect all the servers except the one that is being authenticated on success
|
||||
|
|
@ -37,13 +73,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
mcpValuesRef.current = mcpValues;
|
||||
}, [mcpValues]);
|
||||
|
||||
const configuredServers = useMemo(() => {
|
||||
if (!startupConfig?.mcpServers) return [];
|
||||
return Object.entries(startupConfig.mcpServers)
|
||||
.filter(([, config]) => config.chatMenu !== false)
|
||||
.map(([serverName]) => serverName);
|
||||
}, [startupConfig?.mcpServers]);
|
||||
|
||||
const reinitializeMutation = useReinitializeMCPServerMutation();
|
||||
const cancelOAuthMutation = useCancelMCPOAuthMutation();
|
||||
|
||||
|
|
@ -52,6 +81,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries([QueryKeys.mcpServers]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpTools]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
|
||||
|
|
@ -81,7 +111,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
});
|
||||
|
||||
const { connectionStatus } = useMCPConnectionStatus({
|
||||
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
|
||||
enabled: !isLoading && configuredServers.length > 0,
|
||||
});
|
||||
|
||||
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
|
||||
|
|
@ -289,7 +319,12 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
|
||||
startServerPolling(serverName);
|
||||
} else {
|
||||
await queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries([QueryKeys.mcpServers]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpTools]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
|
||||
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
|
||||
]);
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
|
||||
|
|
@ -494,7 +529,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
]);
|
||||
const serverData = mcpData?.servers?.[serverName];
|
||||
const serverStatus = connectionStatus?.[serverName];
|
||||
const serverConfig = startupConfig?.mcpServers?.[serverName];
|
||||
const serverConfig = loadedServers?.[serverName];
|
||||
|
||||
const handleConfigClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -548,14 +583,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
hasCustomUserVars,
|
||||
};
|
||||
},
|
||||
[
|
||||
queryClient,
|
||||
isCancellable,
|
||||
isInitializing,
|
||||
cancelOAuthFlow,
|
||||
connectionStatus,
|
||||
startupConfig?.mcpServers,
|
||||
],
|
||||
[queryClient, isCancellable, isInitializing, cancelOAuthFlow, connectionStatus, loadedServers],
|
||||
);
|
||||
|
||||
const getConfigDialogProps = useCallback(() => {
|
||||
|
|
@ -600,7 +628,10 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
|||
]);
|
||||
|
||||
return {
|
||||
configuredServers,
|
||||
availableMCPServers,
|
||||
availableMCPServersMap: loadedServers,
|
||||
isLoading,
|
||||
connectionStatus,
|
||||
initializeServer,
|
||||
cancelOAuthFlow,
|
||||
isInitializing,
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
|||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { useHasAccess, useMCPServerManager } from '~/hooks';
|
||||
|
||||
export default function useSideNavLinks({
|
||||
hidePanel,
|
||||
|
|
@ -61,7 +60,8 @@ export default function useSideNavLinks({
|
|||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { availableMCPServers } = useMCPServerManager();
|
||||
|
||||
const Links = useMemo(() => {
|
||||
const links: NavLink[] = [];
|
||||
|
|
@ -153,12 +153,12 @@ export default function useSideNavLinks({
|
|||
}
|
||||
|
||||
if (
|
||||
startupConfig?.mcpServers &&
|
||||
Object.values(startupConfig.mcpServers).some(
|
||||
availableMCPServers &&
|
||||
availableMCPServers.some(
|
||||
(server: any) =>
|
||||
(server.customUserVars && Object.keys(server.customUserVars).length > 0) ||
|
||||
server.isOAuth ||
|
||||
server.startup === false,
|
||||
(server.config.customUserVars && Object.keys(server.config.customUserVars).length > 0) ||
|
||||
server.config.isOAuth ||
|
||||
server.config.startup === false,
|
||||
)
|
||||
) {
|
||||
links.push({
|
||||
|
|
@ -192,7 +192,7 @@ export default function useSideNavLinks({
|
|||
hasAccessToBookmarks,
|
||||
hasAccessToCreateAgents,
|
||||
hidePanel,
|
||||
startupConfig,
|
||||
availableMCPServers,
|
||||
]);
|
||||
|
||||
return Links;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue