import { create } from 'zustand'; import { devtools, persist, createJSONStorage } from 'zustand/middleware'; import { getSafeStorage } from './utils/safeStorage'; import { startConfigUpdate, finishConfigUpdate, } from '@/lib/configUpdate'; import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; export type McpScope = 'user' | 'project'; const getConfigDirectory = (): string | null => { try { const projectsStore = useProjectsStore.getState(); const activeProject = projectsStore.getActiveProject?.(); if (activeProject?.path?.trim()) { return activeProject.path.trim(); } const clientDir = opencodeClient.getDirectory(); if (clientDir?.trim()) { return clientDir.trim(); } } catch (err) { console.warn('[McpConfigStore] Error resolving config directory:', err); } return null; }; // ============== TYPES ============== export interface McpLocalConfig { type: 'local'; command: string[]; environment?: Record; enabled: boolean; } export interface McpRemoteConfig { type: 'remote'; url: string; environment?: Record; enabled: boolean; } export type McpServerConfig = (McpLocalConfig | McpRemoteConfig) & { name: string }; export type McpServerWithScope = McpServerConfig & { scope?: McpScope | null }; export interface McpDraft { name: string; scope: McpScope; type: 'local' | 'remote'; command: string[]; url: string; environment: Array<{ key: string; value: string }>; enabled: boolean; } // ============== HELPERS ============== export const envRecordToArray = (env?: Record): Array<{ key: string; value: string }> => { if (!env) return []; return Object.entries(env).map(([key, value]) => ({ key, value })); }; export const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record | undefined => { const filtered = arr.filter((e) => e.key.trim()); if (filtered.length === 0) return undefined; return Object.fromEntries(filtered.map((e) => [e.key.trim(), e.value])); }; const CLIENT_RELOAD_DELAY_MS = 800; const MCP_LOAD_CACHE_TTL_MS = 5000; const DEFAULT_MCP_CACHE_KEY = '__default__'; const mcpLastLoadedAt = new Map(); const mcpLoadInFlight = new Map>(); const getMcpCacheKey = (directory: string | null): string => { return directory?.trim() || DEFAULT_MCP_CACHE_KEY; }; // ============== STORE ============== interface McpConfigStore { mcpServers: McpServerWithScope[]; selectedMcpName: string | null; isLoading: boolean; mcpDraft: McpDraft | null; setSelectedMcp: (name: string | null) => void; setMcpDraft: (draft: McpDraft | null) => void; loadMcpConfigs: () => Promise; createMcp: (config: McpDraft) => Promise; updateMcp: (name: string, config: Partial) => Promise; deleteMcp: (name: string) => Promise; getMcpByName: (name: string) => McpServerWithScope | undefined; } export const useMcpConfigStore = create()( devtools( persist( (set, get) => ({ mcpServers: [], selectedMcpName: null, isLoading: false, mcpDraft: null, setSelectedMcp: (name) => set({ selectedMcpName: name }), setMcpDraft: (draft) => set({ mcpDraft: draft }), loadMcpConfigs: async () => { const configDirectory = getConfigDirectory(); const cacheKey = getMcpCacheKey(configDirectory); const now = Date.now(); const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0; const hasCachedConfigs = get().mcpServers.length > 0; if (hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) { return true; } const inFlight = mcpLoadInFlight.get(cacheKey); if (inFlight) { return inFlight; } const request = (async () => { set({ isLoading: true }); try { const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/mcp${queryParams}`, { headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined, }); if (!response.ok) { throw new Error('Failed to load MCP configs'); } const data: McpServerWithScope[] = await response.json(); set({ mcpServers: data, isLoading: false }); mcpLastLoadedAt.set(cacheKey, Date.now()); return true; } catch (error) { console.error('[McpConfigStore] Failed to load MCP configs:', error); set({ isLoading: false }); return false; } })(); mcpLoadInFlight.set(cacheKey, request); try { return await request; } finally { mcpLoadInFlight.delete(cacheKey); } }, createMcp: async (config: McpDraft) => { startConfigUpdate('Creating MCP server configuration…'); let requiresReload = false; try { const body = buildMcpBody(config); const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/mcp/${encodeURIComponent(config.name)}${queryParams}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}), }, body: JSON.stringify(body), }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.error || 'Failed to create MCP server'); } if (payload?.requiresReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, scopes: ['all'], }); return true; } await get().loadMcpConfigs(); return true; } catch (error) { console.error('[McpConfigStore] Failed to create MCP:', error); return false; } finally { if (!requiresReload) finishConfigUpdate(); } }, updateMcp: async (name: string, config: Partial) => { startConfigUpdate('Updating MCP server configuration…'); let requiresReload = false; try { const body = buildMcpBody(config); const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(configDirectory ? { 'x-opencode-directory': configDirectory } : {}), }, body: JSON.stringify(body), }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.error || 'Failed to update MCP server'); } if (payload?.requiresReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, scopes: ['all'], }); return true; } await get().loadMcpConfigs(); return true; } catch (error) { console.error('[McpConfigStore] Failed to update MCP:', error); throw error; } finally { if (!requiresReload) finishConfigUpdate(); } }, deleteMcp: async (name: string) => { startConfigUpdate('Deleting MCP server configuration…'); let requiresReload = false; try { const configDirectory = getConfigDirectory(); const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : ''; const response = await fetch(`/api/config/mcp/${encodeURIComponent(name)}${queryParams}`, { method: 'DELETE', headers: configDirectory ? { 'x-opencode-directory': configDirectory } : undefined, }); const payload = await response.json().catch(() => null); if (!response.ok) { throw new Error(payload?.error || 'Failed to delete MCP server'); } if (payload?.requiresReload) { requiresReload = true; await refreshAfterOpenCodeRestart({ message: payload.message, delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS, scopes: ['all'], }); return true; } if (get().selectedMcpName === name) { set({ selectedMcpName: null }); } await get().loadMcpConfigs(); return true; } catch (error) { console.error('[McpConfigStore] Failed to delete MCP:', error); return false; } finally { if (!requiresReload) finishConfigUpdate(); } }, getMcpByName: (name: string) => { return get().mcpServers.find((s) => s.name === name); }, }), { name: 'mcp-config-store', storage: createJSONStorage(() => getSafeStorage()), partialize: (state) => ({ selectedMcpName: state.selectedMcpName }), }, ), { name: 'mcp-config-store' }, ), ); // ============== HELPERS ============== function buildMcpBody(config: Partial): Record { const body: Record = {}; if (config.scope !== undefined) body.scope = config.scope; if (config.type !== undefined) body.type = config.type; if (config.type === 'local' || config.command !== undefined) { body.command = (config.command ?? []).filter((s) => s.trim()); } if (config.type === 'remote' || config.url !== undefined) { body.url = config.url?.trim() ?? ''; } if (config.environment !== undefined) { body.environment = envArrayToRecord(config.environment) ?? {}; } if (config.enabled !== undefined) { body.enabled = config.enabled; } return body; }