diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96f31d1..8e98f9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,7 @@ import { bootstrapSecureConfig, finalizeSecurityUpdateStatus, mergeSecurityUpdateStatusWithLegacySource, + prepareSecureConfigForExternalMCP, startSecurityUpdateFromBootstrap, } from './utils/secureConfigBootstrap'; import { bootstrapSavedQueries } from './utils/savedQueryPersistence'; @@ -562,6 +563,7 @@ function App() { try { const result = await bootstrapSecureConfig({ backend: (window as any).go?.app?.App, + autoStartLegacySecurityUpdate: true, replaceConnections, replaceGlobalProxy, t, @@ -1298,6 +1300,51 @@ function App() { const handleStartSecurityUpdate = useCallback(() => { void runSecurityUpdateRound('start'); }, [runSecurityUpdateRound]); + const handlePrepareExternalMCPUse = useCallback(async () => { + const backendApp = (window as any).go?.app?.App; + const result = await prepareSecureConfigForExternalMCP({ + backend: backendApp, + replaceConnections, + replaceGlobalProxy, + t, + }); + if (result.error) { + throw result.error; + } + if (!result.status) { + return; + } + + const nextStatus = normalizeSecurityUpdateStatus(result.status); + const shouldOpenSettings = nextStatus.overallStatus === 'needs_attention' || nextStatus.overallStatus === 'rolled_back'; + applySecurityUpdateStatus(nextStatus, { + openSettings: shouldOpenSettings, + refreshFocus: shouldOpenSettings, + }); + + if (nextStatus.overallStatus === 'completed') { + setSecurityUpdateHasLegacySensitiveItems(false); + setSecurityUpdateRawPayload(null); + setIsSecurityUpdateSettingsOpen(false); + return; + } + + const hasConnectionIssue = nextStatus.issues.some((issue) => + issue.scope === 'connection' && issue.status !== 'updated', + ); + if (nextStatus.overallStatus === 'rolled_back' || hasConnectionIssue) { + throw new Error(t('app.security_update.message.needs_attention')); + } + if (nextStatus.overallStatus === 'needs_attention') { + void message.warning(t('app.security_update.message.needs_attention')); + } + }, [ + applySecurityUpdateStatus, + normalizeSecurityUpdateStatus, + replaceConnections, + replaceGlobalProxy, + t, + ]); const handleRetrySecurityUpdate = useCallback(() => { void runSecurityUpdateRound('retry'); }, [runSecurityUpdateRound]); @@ -4051,6 +4098,7 @@ function App() { darkMode={darkMode} overlayTheme={overlayTheme} focusProviderId={focusedAIProviderId} + onBeforeExternalMCPUse={handlePrepareExternalMCPUse} /> )} Promise; } const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = { @@ -79,7 +80,7 @@ const normalizeMCPHTTPAuthorizationToken = (value: string): string => { return withoutHeaderName.replace(/^Bearer\s+/i, '').trim(); }; -const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { +const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId, onBeforeExternalMCPUse }) => { const { t } = useI18n(); const defaultMCPHTTPServerStatus = useMemo(() => ({ ...DEFAULT_MCP_HTTP_SERVER_STATUS, @@ -181,7 +182,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo resolveAIService, messageApi, copyTextToClipboard, - onBeforeInstall: () => setLoading(true), + onBeforeInstall: async () => { + setLoading(true); + await onBeforeExternalMCPUse?.(); + }, onAfterInstall: () => setLoading(false), onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')), translate: t, @@ -532,6 +536,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') { throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version')); } + if (checked) { + await onBeforeExternalMCPUse?.(); + } const nextStatus = checked ? await Service.AIStartMCPHTTPServer({ addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr, diff --git a/frontend/src/components/ai/useAIMCPClientInstaller.ts b/frontend/src/components/ai/useAIMCPClientInstaller.ts index c4cd6eb..68fe6f6 100644 --- a/frontend/src/components/ai/useAIMCPClientInstaller.ts +++ b/frontend/src/components/ai/useAIMCPClientInstaller.ts @@ -40,7 +40,7 @@ interface UseAIMCPClientInstallerOptions { copyTextToClipboard: (text: string, successMessage: string) => Promise; messageApi: MCPClientMessageApi; onAfterInstall?: () => void; - onBeforeInstall?: () => void; + onBeforeInstall?: () => void | Promise; onConfigChanged?: () => void; resolveAIService: () => Promise; translate?: MCPClientInstallTranslator; @@ -124,7 +124,7 @@ export const useAIMCPClientInstaller = ({ const targetLabel = selectedMCPClientStatus?.displayName || (targetClient === 'codex' ? 'Codex' : 'Claude Code'); if (remoteClient) { try { - onBeforeInstall?.(); + await onBeforeInstall?.(); setMCPClientSelectionTouched(true); await copyTextToClipboard( buildRemoteMCPClientGuide(selectedMCPClientStatus), @@ -138,11 +138,18 @@ export const useAIMCPClientInstaller = ({ return; } if (selectedMCPClientStatus?.matchesCurrent) { - void messageApi.success(copy('ai_chat.mcp_client.install.message.already_connected', '{{label}} is already connected to current GoNavi MCP. No repeated write is needed.', { label: targetLabel })); + try { + await onBeforeInstall?.(); + void messageApi.success(copy('ai_chat.mcp_client.install.message.already_connected', '{{label}} is already connected to current GoNavi MCP. No repeated write is needed.', { label: targetLabel })); + } catch (error: any) { + void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.install_failed', 'Failed to install {{label}} MCP', { label: targetLabel })); + } finally { + onAfterInstall?.(); + } return; } try { - onBeforeInstall?.(); + await onBeforeInstall?.(); setMCPClientSelectionTouched(true); const service = await resolveAIService(); if (targetClient === 'codex') { diff --git a/frontend/src/utils/secureConfigBootstrap.test.ts b/frontend/src/utils/secureConfigBootstrap.test.ts index 1c6d6a0..5c22f4d 100644 --- a/frontend/src/utils/secureConfigBootstrap.test.ts +++ b/frontend/src/utils/secureConfigBootstrap.test.ts @@ -7,6 +7,7 @@ import { bootstrapSecureConfig, finalizeSecurityUpdateStatus, mergeSecurityUpdateStatusWithLegacySource, + prepareSecureConfigForExternalMCP, startSecurityUpdateFromBootstrap, } from './secureConfigBootstrap'; import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage'; @@ -165,6 +166,53 @@ describe('secureConfigBootstrap', () => { ); }); + it('auto-starts legacy security update on bootstrap when requested so MCP sees backend connections', async () => { + const args = createBaseArgs(); + const StartSecurityUpdate = vi.fn().mockResolvedValue({ + overallStatus: 'completed', + summary: { total: 2, updated: 2, pending: 0, skipped: 0, failed: 0 }, + issues: [], + }); + + const result = await bootstrapSecureConfig({ + ...args, + autoStartLegacySecurityUpdate: true, + backend: { + GetSecurityUpdateStatus: vi.fn().mockResolvedValue({ + overallStatus: 'pending', + summary: { total: 0, updated: 0, pending: 0, skipped: 0, failed: 0 }, + issues: [], + }), + StartSecurityUpdate, + GetSavedConnections: vi.fn().mockResolvedValue([ + { + id: 'legacy-1', + name: 'Legacy', + config: { + id: 'legacy-1', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + }, + hasPrimaryPassword: true, + }, + ]), + }, + }); + + expect(StartSecurityUpdate).toHaveBeenCalledWith(expect.objectContaining({ + sourceType: 'current_app_saved_config', + rawPayload: legacyPayload, + })); + expect(result.status.overallStatus).toBe('completed'); + expect(result.hasLegacySensitiveItems).toBe(false); + expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"'); + expect(args.replaceConnections).toHaveBeenLastCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'legacy-1' })]), + ); + }); + it('keeps banner flow without intro when backend status is postponed', async () => { const args = createBaseArgs(); @@ -485,6 +533,62 @@ describe('secureConfigBootstrap', () => { expect(args.storage.getItem(LEGACY_PERSIST_KEY)).toContain('"password":"secret"'); }); + it('prepares secure backend connections before external MCP use', async () => { + const args = createBaseArgs(); + const StartSecurityUpdate = vi.fn().mockResolvedValue({ + overallStatus: 'completed', + summary: { total: 2, updated: 2, pending: 0, skipped: 0, failed: 0 }, + issues: [], + }); + + const result = await prepareSecureConfigForExternalMCP({ + ...args, + backend: { + StartSecurityUpdate, + GetSavedConnections: vi.fn().mockResolvedValue([ + { + id: 'legacy-1', + name: 'Legacy', + config: { + id: 'legacy-1', + type: 'postgres', + host: 'db.local', + port: 5432, + user: 'postgres', + }, + hasPrimaryPassword: true, + }, + ]), + }, + }); + + expect(result.attempted).toBe(true); + expect(result.error).toBeNull(); + expect(result.status?.overallStatus).toBe('completed'); + expect(StartSecurityUpdate).toHaveBeenCalledWith(expect.objectContaining({ + rawPayload: legacyPayload, + })); + expect(args.storage.getItem(LEGACY_PERSIST_KEY)).not.toContain('"password":"secret"'); + }); + + it('skips external MCP preparation when no legacy connection source exists', async () => { + const storage = createMemoryStorage(); + const StartSecurityUpdate = vi.fn(); + const result = await prepareSecureConfigForExternalMCP({ + storage, + replaceConnections: vi.fn(), + replaceGlobalProxy: vi.fn(), + backend: { + StartSecurityUpdate, + }, + }); + + expect(result.attempted).toBe(false); + expect(result.status).toBeNull(); + expect(result.error).toBeNull(); + expect(StartSecurityUpdate).not.toHaveBeenCalled(); + }); + it('starts security update even when rawPayload is empty but backend supports AI-only update', async () => { const storage = createMemoryStorage(); const replaceConnections = vi.fn(); diff --git a/frontend/src/utils/secureConfigBootstrap.ts b/frontend/src/utils/secureConfigBootstrap.ts index 0ccd186..4c54ba5 100644 --- a/frontend/src/utils/secureConfigBootstrap.ts +++ b/frontend/src/utils/secureConfigBootstrap.ts @@ -38,6 +38,7 @@ type SecurityUpdateBackend = { type SecureConfigBootstrapArgs = { backend?: SecurityUpdateBackend; storage?: StorageLike; + autoStartLegacySecurityUpdate?: boolean; replaceConnections: (connections: SavedConnection[]) => void; replaceGlobalProxy: (proxy: GlobalProxyConfig) => void; t?: SecureConfigBootstrapTranslator; @@ -56,6 +57,10 @@ type StartSecurityUpdateResult = { error: Error | null; }; +type PrepareExternalMCPResult = StartSecurityUpdateResult & { + attempted: boolean; +}; + type MergeSecurityUpdateStatusOptions = { previousStatus?: Partial | null; t?: SecureConfigBootstrapTranslator; @@ -332,6 +337,13 @@ const cleanupLegacySourceIfCompleted = ( } }; +const shouldAutoStartLegacySecurityUpdate = (status: SecurityUpdateStatus): boolean => { + if (String(status.migrationId || '').trim() !== '') { + return false; + } + return status.overallStatus === 'not_detected' || status.overallStatus === 'pending'; +}; + export async function finalizeSecurityUpdateStatus( args: SecureConfigBootstrapArgs, rawStatus: Partial | undefined, @@ -350,15 +362,29 @@ export async function finalizeSecurityUpdateStatus( export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Promise { const storage = resolveStorage(args.storage); - const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null; - const hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload); + let rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null; + let hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload); applyLegacyVisibleConfig(rawPayload, args.replaceConnections, args.replaceGlobalProxy); const backendStatus = typeof args.backend?.GetSecurityUpdateStatus === 'function' ? await args.backend.GetSecurityUpdateStatus() : undefined; - const status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload, { t: args.t }); + let status = mergeSecurityUpdateStatusWithLegacySource(backendStatus, rawPayload, { t: args.t }); + + if ( + hasLegacySensitiveItems + && args.autoStartLegacySecurityUpdate === true + && typeof args.backend?.StartSecurityUpdate === 'function' + && shouldAutoStartLegacySecurityUpdate(status) + ) { + const startResult = await startSecurityUpdateFromBootstrap(args); + if (!startResult.error && startResult.status) { + status = startResult.status; + rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? rawPayload; + hasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(rawPayload); + } + } if (!hasLegacySensitiveItems) { await refreshVisibleConfigFromBackend(args.backend, args.replaceConnections, args.replaceGlobalProxy, true); @@ -376,6 +402,25 @@ export async function bootstrapSecureConfig(args: SecureConfigBootstrapArgs): Pr }; } +export async function prepareSecureConfigForExternalMCP(args: SecureConfigBootstrapArgs): Promise { + const storage = resolveStorage(args.storage); + const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null; + if (!hasLegacyMigratableSensitiveItems(rawPayload)) { + return { + attempted: false, + status: null, + error: null, + }; + } + + const result = await startSecurityUpdateFromBootstrap(args); + return { + attempted: true, + status: result.status, + error: result.error, + }; +} + export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstrapArgs): Promise { const storage = resolveStorage(args.storage); const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null; @@ -419,6 +464,7 @@ export async function startSecurityUpdateFromBootstrap(args: SecureConfigBootstr export type { BackendGlobalProxyResult, MergeSecurityUpdateStatusOptions, + PrepareExternalMCPResult, SecurityUpdateBackend, SecureConfigBootstrapArgs, SecureConfigBootstrapResult,