From 75978cf188c510c726144dcfec65a5b3ff48e29c Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 10 Aug 2026 20:23:45 +0300 Subject: [PATCH] fix(mcp): reliable OAuth across runtimes and honest pre-restart UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP authorization was broken in several stacked ways. The browser return leg landed on the SPA behind the auth gate, so the system browser saw a login page instead of finishing; the pending-context store silently saved nothing because its route had no JSON body parser; and the callback-URL config write started deferring behind Apply & Restart, so authorization ran against a runtime without the URL and dead-ended on OpenCode's loopback listener. The return leg is now completed entirely server-side by an unauthenticated GET /mcp/oauth/callback that only forwards a code whose state matches a parked context. Desktop with the local server and VS Code switch to OpenCode's native flow over its fixed loopback port — no config writes or restarts at all, with a one-time cleanup of the previously written callback URL — and its completion signal drives the page instead of blind status polling. Remote, hosted-web, and mobile keep the server-callback flow, applying a queued callback-URL write immediately since authorization cannot wait for a manual restart. Also: a server queued behind Apply & Restart now shows an Awaiting restart badge and explanation instead of connect/reauthorize buttons that can only fail, and Reauthorize is offered only while the server is actually connected. --- .../chat/work-status/WorkStatusMcpSection.tsx | 2 - .../ui/src/components/mcp/McpDropdown.tsx | 2 - .../src/components/sections/mcp/McpPage.tsx | 87 ++++++++++-- .../sections/mcp/startMcpAuthorization.ts | 103 +++++++++++++- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/stores/useMcpStore.ts | 28 ++++ .../lib/opencode/mcp-oauth-callback.test.js | 111 +++++++++++++++ packages/web/server/lib/opencode/proxy.js | 4 + packages/web/server/lib/opencode/routes.js | 129 +++++++++++++++++- 19 files changed, 466 insertions(+), 22 deletions(-) create mode 100644 packages/web/server/lib/opencode/mcp-oauth-callback.test.js 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 ? (