import { useCallback, useEffect, useState } from 'react'; import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN, STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_EXTERNAL_MCP_MODE, STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS, STORAGE_KEY_AI_PERMISSION_MODE, STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, } from '../../infrastructure/config/storageKeys'; import type { AIPermissionMode } from '../../infrastructure/ai/types'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents'; export type ExternalMcpMode = 'temporary' | 'persistent'; const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10; const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1; const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60; const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30; /** Keep top-bar / settings switch state aligned with runtime auto-disable. */ export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000; // Always-mounted top-bar consumers can poll before App finishes startup reconcile. // Gate runtime auto-clear until that reconcile has run on the main window. let externalMcpStartupReady = false; let externalMcpStartupReadyWaitPromise: Promise | null = null; let externalMcpStartupReadyWaitResolve: (() => void) | null = null; /** Bumps on every intentional enable/disable so stale startup applies lose. */ let externalMcpEnableGeneration = 0; function getWindowHash(hash?: string): string { if (typeof hash === 'string') return hash; return typeof window !== 'undefined' ? window.location.hash : ''; } function isPeerSessionWindowLocation(hash?: string): boolean { return getWindowHash(hash).startsWith('#/session-window'); } /** Only the main App shell runs startup reconcile and owns the ready gate. */ export function shouldWaitForExternalMcpStartupReady(hash?: string): boolean { const current = getWindowHash(hash); if (!current || current === '#' || current === '#/') return true; // Peer windows never own External MCP lifecycle. if (current.startsWith('#/session-window')) return false; // Settings / tray / popup windows mount the shared hook but never run App reconcile. if (current.startsWith('#/settings')) return false; if (current.startsWith('#/tray')) return false; if (current.startsWith('#/terminal-popup')) return false; return true; } export function markExternalMcpStartupReady(): void { if (externalMcpStartupReady) return; externalMcpStartupReady = true; const resolve = externalMcpStartupReadyWaitResolve; externalMcpStartupReadyWaitResolve = null; externalMcpStartupReadyWaitPromise = null; resolve?.(); } export function resetExternalMcpStartupReadyForTests(): void { externalMcpStartupReady = false; externalMcpStartupReadyWaitPromise = null; externalMcpStartupReadyWaitResolve = null; externalMcpEnableGeneration = 0; externalMcpStartupSyncPromise = null; } export function getExternalMcpEnableGenerationForTests(): number { return externalMcpEnableGeneration; } export function bumpExternalMcpEnableGenerationForTests(): number { externalMcpEnableGeneration += 1; return externalMcpEnableGeneration; } export function isExternalMcpStartupReady(): boolean { return externalMcpStartupReady; } /** Exposed for tests: single-flight waiter count is 0 or 1. */ export function getExternalMcpStartupReadyWaiterCountForTests(): number { return externalMcpStartupReadyWaitResolve ? 1 : 0; } export function waitForExternalMcpStartupReady(hash?: string): Promise { // Non-main routes intentionally skip App reconcile; do not block forever there. if (!shouldWaitForExternalMcpStartupReady(hash) || externalMcpStartupReady) { return Promise.resolve(); } if (!externalMcpStartupReadyWaitPromise) { externalMcpStartupReadyWaitPromise = new Promise((resolve) => { externalMcpStartupReadyWaitResolve = resolve; }); } return externalMcpStartupReadyWaitPromise; } type ExternalMcpConfig = { mode: ExternalMcpMode; idleTimeoutMinutes: number; sessionIdleTimeoutMinutes: number; }; type ExternalMcpBridge = { aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise | unknown; externalMcpSetConfig?: (config: ExternalMcpConfig) => Promise | unknown; externalMcpSetEnabled?: (enabled: boolean) => Promise | unknown; externalMcpGetStatus?: () => Promise<{ ok?: boolean; enabled?: boolean; state?: string; error?: string | null; } | undefined>; }; export type ExternalMcpStartupSyncPlan = { config: ExternalMcpConfig; runtimeEnabled: boolean; storedEnabled: boolean; shouldPersistStoredEnabled: boolean; }; export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode { return value === 'persistent' ? 'persistent' : 'temporary'; } function readAIPermissionMode(): AIPermissionMode { const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE); return stored === 'observer' || stored === 'auto' ? stored : 'confirm'; } export function normalizeExternalMcpIdleTimeoutMinutes(value: number | null): number { if (!Number.isFinite(value)) return DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES; return Math.min( MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, Math.max( MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, Math.round(value ?? DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES), ), ); } export function readExternalMcpStoredEnabled(): boolean { return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) ?? false; } export function readExternalMcpMode(): ExternalMcpMode { return normalizeExternalMcpMode(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE)); } export function writeExternalMcpMode(mode: ExternalMcpMode): ExternalMcpMode { const normalized = normalizeExternalMcpMode(mode); localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, normalized); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_MODE); return normalized; } export function readExternalMcpIdleTimeoutMinutes(): number { return normalizeExternalMcpIdleTimeoutMinutes( localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES), ); } export function writeExternalMcpIdleTimeoutMinutes(minutes: number): number { const normalized = normalizeExternalMcpIdleTimeoutMinutes(minutes); localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, normalized); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES); return normalized; } /** Whether host_open should surface/focus the main window. Defaults to true (existing behavior). */ export function readExternalMcpFocusOnHostOpen(): boolean { return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN) ?? true; } export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void { localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN, focusOnHostOpen); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN); } /** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */ export function readExternalMcpSilentSessions(): boolean { return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false; } export function writeExternalMcpSilentSessions(silentSessions: boolean): void { localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS, silentSessions); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS); } export function normalizeSessionIdleTimeoutMinutes(value: number | null): number { if (!Number.isFinite(value)) return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES; return Math.min( MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, Math.max( MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES, Math.round(value ?? DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES), ), ); } export function readSessionIdleTimeoutMinutes(): number { return normalizeSessionIdleTimeoutMinutes( localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES), ); } export function writeSessionIdleTimeoutMinutes(minutes: number): number { const normalized = normalizeSessionIdleTimeoutMinutes(minutes); localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, normalized); emitAIStateChanged(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES); return normalized; } export function shouldStartExternalMcpOnStartup({ enabled, mode, }: { enabled: boolean; mode: ExternalMcpMode; }): boolean { return mode === 'persistent' && enabled; } export function readExternalMcpStartupEnabled(): boolean { return shouldStartExternalMcpOnStartup({ enabled: readExternalMcpStoredEnabled(), mode: readExternalMcpMode(), }); } export function createExternalMcpStartupSyncPlan({ enabled, mode, idleTimeoutMinutes, sessionIdleTimeoutMinutes, }: { enabled: boolean; mode: ExternalMcpMode; idleTimeoutMinutes: number; sessionIdleTimeoutMinutes: number; }): ExternalMcpStartupSyncPlan { const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode }); const storedEnabled = runtimeEnabled; return { config: { mode, idleTimeoutMinutes, sessionIdleTimeoutMinutes, }, runtimeEnabled, storedEnabled, shouldPersistStoredEnabled: storedEnabled !== enabled, }; } export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan { return createExternalMcpStartupSyncPlan({ enabled: readExternalMcpStoredEnabled(), mode: readExternalMcpMode(), idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(), sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(), }); } export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void { void bridge?.externalMcpSetConfig?.({ mode: readExternalMcpMode(), idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(), sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(), }); } /** * App-startup reconcile: only persistent+enabled starts the runtime. * Temporary mode never auto-starts, and we clear a stale stored enabled flag * so Settings remounts cannot accidentally re-enable temporary mode. */ export async function syncExternalMcpStartupState( bridge: ExternalMcpBridge | undefined = netcattyBridge.get(), ): Promise { // Snapshot once for config push; re-read after awaits so a concurrent top-bar // toggle during boot wins over a stale enable/disable decision. const startupGeneration = externalMcpEnableGeneration; const initialPlan = readExternalMcpStartupSyncPlan(); try { // External MCP discovery is written while enabling, so restore the saved // permission first instead of exposing the main-process default (confirm). await Promise.resolve(bridge?.aiMcpSetPermissionMode?.(readAIPermissionMode())); } catch { // Permission sync is best-effort; the main process keeps its safe default. } try { await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config)); } catch { // Config sync is best-effort; continue with enable/disable reconcile. } // A user toggle during config await invalidates this startup apply. if (externalMcpEnableGeneration !== startupGeneration) { return readExternalMcpStartupSyncPlan(); } const plan = readExternalMcpStartupSyncPlan(); if (plan.shouldPersistStoredEnabled) { localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED); } // Capture generation again immediately before applying runtime enable/disable. const applyGeneration = externalMcpEnableGeneration; if (applyGeneration !== startupGeneration) { return plan; } try { await Promise.resolve(bridge?.externalMcpSetEnabled?.(plan.runtimeEnabled)); } catch { // Keep stored preference on transient enable failure; runtime status + error // surface can recover without wiping always-on intent. } return plan; } let externalMcpStartupSyncPromise: Promise | null = null; /** * Startup reconcile runs exactly once per renderer load. A remounted App must * await the same settled promise instead of pushing a second enable/disable to * the main process, which would fight the user's live toggle. */ export function syncExternalMcpStartupStateOnce( bridge: ExternalMcpBridge | undefined = netcattyBridge.get(), ): Promise { if (!externalMcpStartupSyncPromise) { externalMcpStartupSyncPromise = syncExternalMcpStartupState(bridge); } return externalMcpStartupSyncPromise; } export function resetExternalMcpStartupSyncOnceForTests(): void { externalMcpStartupSyncPromise = null; } export function useExternalMcpToggleState() { // UI mirrors the stored switch. Startup reconcile (App mount, main window only) // decides whether temporary mode should clear/persist and start the runtime. const isPeerSessionWindow = isPeerSessionWindowLocation(); const [enabled, setEnabledRaw] = useState(() => readExternalMcpStoredEnabled()); const persistEnabled = useCallback((nextEnabled: boolean) => { setEnabledRaw(nextEnabled); localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, nextEnabled); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED); }, []); const setEnabled = useCallback((nextEnabled: boolean) => { persistEnabled(nextEnabled); // Peer session windows can mirror the stored switch, but they must not own // the main-process External MCP lifecycle. if (isPeerSessionWindow) return; externalMcpEnableGeneration += 1; // Re-emit after the lifecycle IPC settles so consumers that refetch runtime // status (e.g. Tool Access guidance) do not act on the pre-IPC storage // event, which can still observe the previous enabled state. void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => { emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED); }); }, [isPeerSessionWindow, persistEnabled]); useEffect(() => { const syncFromStorage = () => { const nextEnabled = readExternalMcpStoredEnabled(); setEnabledRaw(nextEnabled); }; const handleStorage = (event: StorageEvent) => { if (event.key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return; syncFromStorage(); }; const handleLocalStateChanged = (event: Event) => { const key = (event as CustomEvent<{ key?: string }>).detail?.key; if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return; syncFromStorage(); }; window.addEventListener('storage', handleStorage); window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged); return () => { window.removeEventListener('storage', handleStorage); window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged); }; }, []); useEffect(() => { // Peer windows must never auto-clear the shared stored switch from runtime status. if (isPeerSessionWindow || !enabled) return; let cancelled = false; const syncRuntimeStatus = async () => { try { // Wait until App has finished startup reconcile so a still-disabled runtime // is not mistaken for "user turned it off / idle timeout". await waitForExternalMcpStartupReady(); if (cancelled) return; const status = await netcattyBridge.get()?.externalMcpGetStatus?.(); if (cancelled) return; // Only clear the shared switch for an intentional runtime-off (idle / // explicit disable). Start failures set error and should keep preference. if (status?.ok && !status.enabled && !status.error) { persistEnabled(false); } } catch { // Keep the user's stored switch state during transient bridge errors. } }; const intervalId = window.setInterval(() => { void syncRuntimeStatus(); }, EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS); void syncRuntimeStatus(); return () => { cancelled = true; window.clearInterval(intervalId); }; }, [enabled, isPeerSessionWindow, persistEnabled]); return { enabled, setEnabled }; }