diff --git a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx index 4c69249b..fcb68c75 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx @@ -5,7 +5,6 @@ import { useMcpStore } from '@/stores/useMcpStore'; import { McpIcon } from '@/components/icons/McpIcon'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { toast } from 'sonner'; -import { isVSCodeRuntime } from '@/lib/desktop'; import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusRowAction } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; @@ -54,7 +53,6 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { const { opened } = await startMcpAuthorization({ name, directory, - skipRedirectUriBootstrap: isVSCodeRuntime(), }); if (!opened) { toast.error(t('chat.workStatus.mcp.authorizeOpenFailed')); diff --git a/packages/ui/src/components/mcp/McpDropdown.tsx b/packages/ui/src/components/mcp/McpDropdown.tsx index 75134f3d..8c84ffdb 100644 --- a/packages/ui/src/components/mcp/McpDropdown.tsx +++ b/packages/ui/src/components/mcp/McpDropdown.tsx @@ -22,7 +22,6 @@ import { McpIcon } from '@/components/icons/McpIcon'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { toast } from 'sonner'; -import { isVSCodeRuntime } from '@/lib/desktop'; import { startMcpAuthorization } from '@/components/sections/mcp/startMcpAuthorization'; const statusTooltip = ( @@ -211,7 +210,6 @@ export const McpDropdownContent: React.FC = ({ active, const { opened } = await startMcpAuthorization({ name: serverName, directory, - skipRedirectUriBootstrap: isVSCodeRuntime(), }); if (!opened) { toast.error(t('mcpDropdown.toast.authorizeOpenFailed')); diff --git a/packages/ui/src/components/sections/mcp/McpPage.tsx b/packages/ui/src/components/sections/mcp/McpPage.tsx index 14e41930..96409af2 100644 --- a/packages/ui/src/components/sections/mcp/McpPage.tsx +++ b/packages/ui/src/components/sections/mcp/McpPage.tsx @@ -18,6 +18,7 @@ import { applyImportedMcpToDraft, } from './mcpImport'; import { useMcpStore } from '@/stores/useMcpStore'; +import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { cn } from '@/lib/utils'; @@ -439,6 +440,7 @@ const StatusBadge: React.FC<{ failed: { text: 'text-[var(--status-error)]', bg: 'bg-[var(--status-error)]/10' }, needs_auth: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' }, needs_client_registration: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' }, + awaiting_restart: { text: 'text-[var(--status-warning)]', bg: 'bg-[var(--status-warning)]/10' }, }; const colors = colorClassMap[status] ?? { text: 'text-muted-foreground', bg: '' }; @@ -584,6 +586,7 @@ export const McpPage: React.FC = () => { const completeAuthMcp = useMcpStore((state) => state.completeAuth); const clearAuthMcp = useMcpStore((state) => state.clearAuth); const testConnectionMcp = useMcpStore((state) => state.testConnection); + const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes); const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null; const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer); @@ -1032,15 +1035,40 @@ export const McpPage: React.FC = () => { // One implementation for every surface that can authorise; the page // used to own this flow while the dropdown and the work-status panel // called plain `connect`, which cannot start OAuth at all. - const { authorizationUrl: nextAuthUrl, opened } = await startMcpAuthorization({ + const { authorizationUrl: nextAuthUrl, opened, nativeFlow, completion } = await startMcpAuthorization({ name: selectedMcpName, directory: currentDirectory, - // Only VS Code keeps OpenCode's own redirect. Skipping whenever some - // value was stored left a stale one — a dead loopback port from an - // earlier launch — unrepairable from this page; the bootstrap already - // rewrites nothing when the stored value is right. - skipRedirectUriBootstrap: isVSCodeAuthRuntime, }); + + if (nativeFlow) { + // OpenCode opened the browser and completes the flow itself; there is + // no URL or state to track. The completion promise is the authoritative + // end signal — status polling alone cannot tell a finished + // reauthorization from the still-connected state it started in. + if (runtimeActionKeyRef.current !== actionKey) return; + setAuthUrl(null); + setAuthStateKey(null); + setIsAuthPolling(true); + authPollAttemptsRef.current = 0; + toast.message(t('settings.mcp.page.toast.completeAuthorizationInBrowser')); + completion + ?.then(() => { + if (runtimeActionKeyRef.current !== actionKey) return; + setIsAuthPolling(false); + authPollAttemptsRef.current = 0; + authPollStartsFromNeedsAuthRef.current = false; + toast.success(t('settings.mcp.page.toast.authorizationCompleted')); + }) + .catch((completionError) => { + if (runtimeActionKeyRef.current !== actionKey) return; + setIsAuthPolling(false); + authPollAttemptsRef.current = 0; + authPollStartsFromNeedsAuthRef.current = false; + toast.error(normalizeMcpAuthErrorMessage(completionError, t('settings.mcp.page.toast.authorizationFailed'), tUnsafe)); + }); + return; + } + const stateKey = parseMcpOAuthCallbackStateKey(new URL(nextAuthUrl).searchParams); queuedStateKey = stateKey; @@ -1269,6 +1297,12 @@ export const McpPage: React.FC = () => { const runtimeStatus = mcpStatus[selectedMcpName]; const runtimeDiagnostic = selectedMcpName ? mcpDiagnostics[selectedMcpName] : undefined; const effectiveRuntimeStatus = runtimeStatus ?? runtimeDiagnostic; + // Saved into the config but queued behind Apply & Restart: OpenCode does not + // know this server yet, so every runtime action (connect, authorize, clear + // auth) can only fail with "server not found". The page says that instead of + // offering the buttons. + const isAwaitingRestart = !isNewServer && !effectiveRuntimeStatus + && pendingRestartChanges.some((change) => change.scope === 'mcp' && change.id.startsWith(`mcp:${selectedMcpName}:`)); const isConnected = runtimeStatus?.status === 'connected'; const needsAuthorization = runtimeStatus?.status === 'needs_auth' || runtimeStatus?.status === 'needs_client_registration'; // Must be the very URI `startMcpAuthorization` writes into the config, not a @@ -1305,6 +1339,8 @@ export const McpPage: React.FC = () => { return t('settings.mcp.page.status.label.needsAuth'); case 'needs_client_registration': return t('settings.mcp.page.status.label.needsRegistration'); + case 'awaiting_restart': + return t('settings.mcp.page.status.label.awaitingRestart'); default: return status; } @@ -1315,12 +1351,17 @@ export const McpPage: React.FC = () => { + ) : undefined} description={isNewServer ? t('settings.mcp.page.header.configureNewServer') : t('settings.mcp.page.header.transport', { type: mcpType === 'local' ? t('settings.mcp.page.transport.local') : t('settings.mcp.page.transport.remote') })} - headerEnd={!isNewServer ? ( + headerEnd={!isNewServer && !isAwaitingRestart ? (