Files
openchamber/packages/ui/src/stores/useMcpConfigStore.ts
T
Bohdan Triapitsyn 0079347edc refactor(settings): scope the settings project selector to settings
Picking a project in Settings called setActiveProject, which relocates
the chat, the session list, the file tree and the Git surface. Reading
another project's MCP servers or agents moved the user's whole app.

It had to, because the configuration stores resolved the directory
themselves from the active project and held one flat list. Each of them
now takes an explicit directory — omitted still means the active project,
so every caller outside Settings is unchanged — and keys loaded data by
directory next to a flat mirror of the active project. Chat, autocompletes
and pickers keep reading that mirror; a load for another directory writes
only the map. A failed load restores that directory's previous list.

Settings resolves its own directory through useSettingsDirectory, backed
by a session-local settingsProjectPath that follows the active project
until the user picks something else.
2026-08-22 20:31:31 +03:00

527 lines
18 KiB
TypeScript

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
import { startConfigUpdate } from '@/lib/configUpdate';
import { refreshAfterOpenCodeRestart } from '@/stores/useAgentsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { noteDeferredRestartFromPayload } from '@/lib/opencode/deferredRestart';
export type McpScope = 'user' | 'project';
type McpMutationResult = {
ok: boolean;
reloadFailed?: boolean;
message?: string;
warning?: string;
requiresManualRestart?: boolean;
restartDeferred?: boolean;
};
/**
* Directory a call operates on. Settings can browse another project without
* moving the app, so every entry point takes one; omitting it means the
* project the app is currently on.
*/
const resolveDirectory = (directory?: string | null): string | null => {
if (directory !== undefined) {
const trimmed = directory?.trim();
return trimmed ? trimmed : null;
}
return getConfigDirectory();
};
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 ==============
interface McpLocalConfig {
type: 'local';
command: string[];
environment?: Record<string, string>;
enabled: boolean;
}
interface McpOAuthConfig {
clientId?: string;
clientSecret?: string;
scope?: string;
redirectUri?: string;
}
interface McpRemoteConfig {
type: 'remote';
url: string;
environment?: Record<string, string>;
headers?: Record<string, string>;
oauth?: McpOAuthConfig | false;
timeout?: number;
enabled: boolean;
}
export type McpServerConfig = (McpLocalConfig | McpRemoteConfig) & { name: string };
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 }>;
headers: Array<{ key: string; value: string }>;
oauthEnabled: boolean;
oauthClientId: string;
oauthClientSecret: string;
oauthScope: string;
oauthRedirectUri: string;
timeout: string;
enabled: boolean;
}
// ============== HELPERS ==============
export const envRecordToArray = (env?: Record<string, string>): Array<{ key: string; value: string }> => {
if (!env) return [];
return Object.entries(env).map(([key, value]) => ({ key, value }));
};
const envArrayToRecord = (arr: Array<{ key: string; value: string }>): Record<string, string> | 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 trimOptionalString = (value: string | undefined): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
};
const CLIENT_RELOAD_DELAY_MS = 800;
const MCP_LOAD_CACHE_TTL_MS = 5000;
const DEFAULT_MCP_CACHE_KEY = '__default__';
const mcpLastLoadedAt = new Map<string, number>();
const mcpLoadInFlight = new Map<string, Promise<boolean>>();
const getMcpCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_MCP_CACHE_KEY;
};
// ============== STORE ==============
interface McpConfigStore {
/** Servers of the project the app is on. Chat and mobile read this one. */
mcpServers: McpServerWithScope[];
/** Every directory loaded so far, including the ambient one. */
serversByDirectory: Record<string, McpServerWithScope[]>;
selectedMcpName: string | null;
isLoading: boolean;
mcpDraft: McpDraft | null;
setSelectedMcp: (name: string | null) => void;
setMcpDraft: (draft: McpDraft | null) => void;
loadMcpConfigs: (options?: { force?: boolean; directory?: string | null }) => Promise<boolean>;
createMcp: (config: McpDraft, directory?: string | null) => Promise<McpMutationResult>;
updateMcp: (name: string, config: Partial<McpDraft>, directory?: string | null) => Promise<McpMutationResult>;
deleteMcp: (name: string, directory?: string | null) => Promise<McpMutationResult>;
getMcpByName: (name: string, directory?: string | null) => McpServerWithScope | undefined;
getMcpServersForDirectory: (directory?: string | null) => McpServerWithScope[];
}
const invalidateMcpCache = (directory: string | null) => {
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
};
const EMPTY_MCP_SERVERS: McpServerWithScope[] = [];
/**
* Servers of one project. Returns a stored array so components can select it
* directly; an omitted directory means the project the app is on.
*/
export const selectMcpServersForDirectory = (
state: Pick<McpConfigStore, 'serversByDirectory'>,
directory?: string | null,
): McpServerWithScope[] => {
const cacheKey = getMcpCacheKey(resolveDirectory(directory));
return state.serversByDirectory[cacheKey] ?? EMPTY_MCP_SERVERS;
};
export const useMcpConfigStore = create<McpConfigStore>()(
devtools(
persist(
(set, get) => ({
mcpServers: [],
serversByDirectory: {},
selectedMcpName: null,
isLoading: false,
mcpDraft: null,
setSelectedMcp: (name) => set({ selectedMcpName: name }),
setMcpDraft: (draft) => set({ mcpDraft: draft }),
loadMcpConfigs: async (options) => {
const configDirectory = resolveDirectory(options?.directory);
const cacheKey = getMcpCacheKey(configDirectory);
const isAmbient = cacheKey === getMcpCacheKey(getConfigDirectory());
const now = Date.now();
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedConfigs = (get().serversByDirectory[cacheKey] ?? (isAmbient ? get().mcpServers : [])).length > 0;
if (!options?.force && hasCachedConfigs && now - loadedAt < MCP_LOAD_CACHE_TTL_MS) {
return true;
}
const inFlight = mcpLoadInFlight.get(cacheKey);
if (!options?.force && inFlight) {
return inFlight;
}
const request = (async () => {
set({ isLoading: true });
try {
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/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((state) => {
const next: Partial<McpConfigStore> = {
serversByDirectory: { ...state.serversByDirectory, [cacheKey]: data },
isLoading: false,
};
if (isAmbient) next.mcpServers = data;
return next;
});
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, directory?: string | null) => {
try {
const body = buildMcpBody(config);
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/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');
}
invalidateMcpCache(configDirectory);
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: config.name })) {
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (payload?.requiresReload) {
startConfigUpdate('Creating MCP server configuration…');
await refreshAfterOpenCodeRestart({
message: payload.message,
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[McpConfigStore] Failed to create MCP:', error);
return { ok: false };
}
},
updateMcp: async (name: string, config: Partial<McpDraft>, directory?: string | null) => {
try {
const body = buildMcpBody(config);
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/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');
}
invalidateMcpCache(configDirectory);
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (payload?.requiresReload) {
startConfigUpdate('Updating MCP server configuration…');
await refreshAfterOpenCodeRestart({
message: payload.message,
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[McpConfigStore] Failed to update MCP:', error);
throw error;
}
},
deleteMcp: async (name: string, directory?: string | null) => {
try {
const configDirectory = resolveDirectory(directory);
const queryParams = configDirectory ? `?directory=${encodeURIComponent(configDirectory)}` : '';
const response = await runtimeFetch(`/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');
}
invalidateMcpCache(configDirectory);
if (get().selectedMcpName === name) {
set({ selectedMcpName: null });
}
if (payload?.requiresManualRestart) {
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
requiresManualRestart: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (noteDeferredRestartFromPayload(payload, 'mcp', { id: name })) {
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
restartDeferred: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
}
if (payload?.requiresReload) {
startConfigUpdate('Deleting MCP server configuration…');
await refreshAfterOpenCodeRestart({
message: payload.message,
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
scopes: ['all'],
});
}
await get().loadMcpConfigs({ force: true, directory: configDirectory });
return {
ok: true,
reloadFailed: payload?.reloadFailed === true,
message: payload?.message,
warning: payload?.warning,
};
} catch (error) {
console.error('[McpConfigStore] Failed to delete MCP:', error);
return { ok: false };
}
},
getMcpByName: (name: string, directory?: string | null) => {
return get().getMcpServersForDirectory(directory).find((s) => s.name === name);
},
getMcpServersForDirectory: (directory?: string | null) => {
return selectMcpServersForDirectory(get(), directory);
},
}),
{
name: 'mcp-config-store',
storage: createDeferredSafeJSONStorage(),
partialize: (state) => ({ selectedMcpName: state.selectedMcpName }),
},
),
{ name: 'mcp-config-store' },
),
);
// ============== HELPERS ==============
function buildMcpBody(config: Partial<McpDraft>): Record<string, unknown> {
const body: Record<string, unknown> = {};
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.headers !== undefined) {
body.headers = envArrayToRecord(config.headers) ?? {};
}
if (
config.oauthEnabled !== undefined ||
config.oauthClientId !== undefined ||
config.oauthClientSecret !== undefined ||
config.oauthScope !== undefined ||
config.oauthRedirectUri !== undefined
) {
if (config.oauthEnabled === false) {
body.oauth = false;
} else {
const oauth = {
clientId: trimOptionalString(config.oauthClientId),
clientSecret: trimOptionalString(config.oauthClientSecret),
scope: trimOptionalString(config.oauthScope),
redirectUri: trimOptionalString(config.oauthRedirectUri),
};
if (oauth.clientId || oauth.clientSecret || oauth.scope || oauth.redirectUri) {
body.oauth = oauth;
} else if (config.oauthEnabled) {
body.oauth = {};
} else {
body.oauth = false;
}
}
}
if (config.timeout !== undefined) {
const timeout = Number(config.timeout);
if (Number.isFinite(timeout) && timeout > 0) {
body.timeout = timeout;
} else {
body.timeout = null;
}
}
if (config.enabled !== undefined) {
body.enabled = config.enabled;
}
return body;
}