Add a packaged-client runtime boundary so the shared UI can talk to local, desktop, remote, and VS Code runtimes through the right transport instead of assuming one same-origin web server. Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and runtime URL helpers, while keeping official OpenCode traffic on the SDK path. Support runtime switching, remote host selection, desktop client credentials, and headless connection links for pairing packaged clients with remote OpenChamber servers. Harden the new auth model by moving long-lived client tokens out of browser URLs, introducing short-lived scoped URL tokens for browser-owned transports, restricting URL-token access to explicit readable/realtime routes, and making client-token management session-scoped or self-scoped as appropriate. Update browser-owned assets and preview proxy flows to work with the split runtime model, including authenticated project icons, preview token propagation, CSP-safe preview bridge injection, and preview proxy auth that survives short-lived URL-token expiry. Tighten Electron security boundaries for packaged clients by gating privileged preload state to trusted origins and requiring explicit confirmation before connect deep-links import or switch remote runtimes. Also refresh agent guidance and project skills so future runtime/API, auth, preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new architecture.
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
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';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
|
|
export type McpScope = 'user' | 'project';
|
|
|
|
type McpMutationResult = {
|
|
ok: boolean;
|
|
reloadFailed?: boolean;
|
|
message?: string;
|
|
warning?: string;
|
|
};
|
|
|
|
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<string, string>;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface McpOAuthConfig {
|
|
clientId?: string;
|
|
clientSecret?: string;
|
|
scope?: string;
|
|
redirectUri?: string;
|
|
}
|
|
|
|
export 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 };
|
|
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 }>;
|
|
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 }));
|
|
};
|
|
|
|
export 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 {
|
|
mcpServers: McpServerWithScope[];
|
|
selectedMcpName: string | null;
|
|
isLoading: boolean;
|
|
mcpDraft: McpDraft | null;
|
|
|
|
setSelectedMcp: (name: string | null) => void;
|
|
setMcpDraft: (draft: McpDraft | null) => void;
|
|
loadMcpConfigs: (options?: { force?: boolean }) => Promise<boolean>;
|
|
createMcp: (config: McpDraft) => Promise<McpMutationResult>;
|
|
updateMcp: (name: string, config: Partial<McpDraft>) => Promise<McpMutationResult>;
|
|
deleteMcp: (name: string) => Promise<McpMutationResult>;
|
|
getMcpByName: (name: string) => McpServerWithScope | undefined;
|
|
}
|
|
|
|
const invalidateMcpCache = (directory: string | null) => {
|
|
mcpLastLoadedAt.delete(getMcpCacheKey(directory));
|
|
};
|
|
|
|
export const useMcpConfigStore = create<McpConfigStore>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
mcpServers: [],
|
|
selectedMcpName: null,
|
|
isLoading: false,
|
|
mcpDraft: null,
|
|
|
|
setSelectedMcp: (name) => set({ selectedMcpName: name }),
|
|
|
|
setMcpDraft: (draft) => set({ mcpDraft: draft }),
|
|
|
|
loadMcpConfigs: async (options) => {
|
|
const configDirectory = getConfigDirectory();
|
|
const cacheKey = getMcpCacheKey(configDirectory);
|
|
const now = Date.now();
|
|
const loadedAt = mcpLastLoadedAt.get(cacheKey) ?? 0;
|
|
const hasCachedConfigs = 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({ 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 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?.requiresReload) {
|
|
requiresReload = true;
|
|
await refreshAfterOpenCodeRestart({
|
|
message: payload.message,
|
|
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
|
scopes: ['all'],
|
|
});
|
|
await get().loadMcpConfigs({ force: true });
|
|
return {
|
|
ok: true,
|
|
reloadFailed: payload?.reloadFailed === true,
|
|
message: payload?.message,
|
|
warning: payload?.warning,
|
|
};
|
|
}
|
|
|
|
await get().loadMcpConfigs({ force: true });
|
|
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 };
|
|
} finally {
|
|
if (!requiresReload) finishConfigUpdate();
|
|
}
|
|
},
|
|
|
|
updateMcp: async (name: string, config: Partial<McpDraft>) => {
|
|
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 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?.requiresReload) {
|
|
requiresReload = true;
|
|
await refreshAfterOpenCodeRestart({
|
|
message: payload.message,
|
|
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
|
scopes: ['all'],
|
|
});
|
|
await get().loadMcpConfigs({ force: true });
|
|
return {
|
|
ok: true,
|
|
reloadFailed: payload?.reloadFailed === true,
|
|
message: payload?.message,
|
|
warning: payload?.warning,
|
|
};
|
|
}
|
|
|
|
await get().loadMcpConfigs({ force: true });
|
|
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;
|
|
} 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 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 (payload?.requiresReload) {
|
|
requiresReload = true;
|
|
await refreshAfterOpenCodeRestart({
|
|
message: payload.message,
|
|
delayMs: payload.reloadDelayMs ?? CLIENT_RELOAD_DELAY_MS,
|
|
scopes: ['all'],
|
|
});
|
|
}
|
|
|
|
if (get().selectedMcpName === name) {
|
|
set({ selectedMcpName: null });
|
|
}
|
|
await get().loadMcpConfigs({ force: true });
|
|
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 };
|
|
} 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<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;
|
|
}
|