fix(mcp): reliable OAuth across runtimes and honest pre-restart UI

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-10 20:23:45 +03:00
parent 3feee346da
commit 75978cf188
19 changed files with 466 additions and 22 deletions
@@ -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<Props> = ({ directory }) => {
const { opened } = await startMcpAuthorization({
name,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('chat.workStatus.mcp.authorizeOpenFailed'));
@@ -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<McpDropdownContentProps> = ({ active,
const { opened } = await startMcpAuthorization({
name: serverName,
directory,
skipRedirectUriBootstrap: isVSCodeRuntime(),
});
if (!opened) {
toast.error(t('mcpDropdown.toast.authorizeOpenFailed'));
@@ -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 = () => {
<SettingsPageLayout
title={isNewServer ? t('settings.mcp.page.header.newServer') : selectedMcpName}
titleAccessory={!isNewServer ? (
<StatusBadge status={effectiveRuntimeStatus?.status} enabled={enabled} getStatusLabel={getStatusLabel} variant="pill" />
<StatusBadge
status={isAwaitingRestart ? 'awaiting_restart' : effectiveRuntimeStatus?.status}
enabled={enabled}
getStatusLabel={getStatusLabel}
variant="pill"
/>
) : 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 ? (
<div className="flex flex-wrap items-center gap-2">
<Button
variant={isConnected ? 'outline' : 'default'}
@@ -1340,11 +1381,15 @@ export const McpPage: React.FC = () => {
onClick={() => void handleStartAuthorization()}
disabled={isAuthorizing || !enabled}
>
{/* "Reauthorize" only once a working authorization exists (the
server is connected); every other state — needs_auth,
failed, still unknown — reads "Authorize" so the label does
not imply stored credentials that may not be there. */}
{isAuthorizing
? t('settings.mcp.page.actions.starting')
: needsAuthorization
? t('settings.mcp.page.actions.authorize')
: t('settings.mcp.page.actions.reauthorize')}
: isConnected
? t('settings.mcp.page.actions.reauthorize')
: t('settings.mcp.page.actions.authorize')}
</Button>
<Button
variant="ghost"
@@ -1375,8 +1420,26 @@ export const McpPage: React.FC = () => {
{/* Saved but queued behind Apply & Restart: dynamic status the user
must see, or the missing action buttons read as a broken page. */}
{isAwaitingRestart && (
<SettingsSection divider={false}>
<div className="rounded-lg border p-3 border-[var(--status-warning-border)] bg-[var(--status-warning-background)]">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.mcp.page.status.runtimeStatus')}</span>
<StatusBadge status="awaiting_restart" enabled={enabled} getStatusLabel={getStatusLabel} />
</div>
<p className="typography-meta text-muted-foreground">
{t('settings.mcp.page.status.description.awaitingRestart')}
</p>
</div>
</div>
</SettingsSection>
)}
{/* Runtime Status - Simplified for connected, expanded for errors */}
{!isNewServer && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
{!isNewServer && !isAwaitingRestart && shouldShowFullStatusCard(effectiveRuntimeStatus?.status, authUrl, needsAuthorization, isAuthPolling) && (
<SettingsSection divider={false}>
<div className={cn('rounded-lg border p-3', statusCardClass(effectiveRuntimeStatus?.status))}>
<div className="space-y-4">
@@ -1,7 +1,8 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { applyPendingOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { openExternalUrl } from '@/lib/url';
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
import { focusDesktopWindow, isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackStateKey } from './mcpOAuth';
@@ -32,6 +33,20 @@ type McpAuthorizationStart = {
authorizationUrl: string;
/** False when the runtime refused to open a browser; the caller then offers a manual paste. */
opened: boolean;
/**
* True when OpenCode runs the whole flow itself over its fixed loopback
* listener: it opened the browser, waits for the callback, and exchanges the
* code. There is no URL to display and no state to correlate — callers watch
* runtime status until it turns `connected`.
*/
nativeFlow?: boolean;
/**
* Native flow only: resolves when OpenCode finishes the whole exchange (or
* rejects when it fails) — the precise "authorization is over" signal, since
* runtime status alone cannot distinguish a completed reauthorization from
* the still-connected state it started in.
*/
completion?: Promise<void>;
};
class McpAuthorizationError extends Error {}
@@ -105,6 +120,46 @@ const clearPendingContext = async (state: string | null): Promise<void> => {
.catch(() => undefined);
};
/**
* One-time migration for the native desktop flow: earlier versions wrote this
* app's per-launch callback URL into the server's config, and OpenCode derives
* its listener from that field — pointed at OUR port, it either fails to bind
* or the callback lands on a flow that never registered it. Clearing the field
* returns OpenCode to its fixed default port. Applied immediately when the
* write gets queued behind Apply & Restart, for the same reason as the
* callback-URL write below: authorization runs against the live runtime.
*/
const clearCustomRedirectUriForNativeFlow = async (name: string): Promise<void> => {
if (!useMcpConfigStore.getState().getMcpByName(name)) {
await useMcpConfigStore.getState().loadMcpConfigs();
}
const configStore = useMcpConfigStore.getState();
const existing = configStore.getMcpByName(name);
const currentOAuth = existing && 'oauth' in existing && existing.oauth ? existing.oauth : null;
if (!existing || !currentOAuth?.redirectUri) return;
const saved = await configStore.updateMcp(name, {
oauthEnabled: true,
oauthClientId: currentOAuth.clientId ?? '',
oauthClientSecret: currentOAuth.clientSecret ?? '',
oauthScope: currentOAuth.scope ?? '',
oauthRedirectUri: '',
});
if (!saved.ok) {
throw new McpAuthorizationError(saved.message || 'Failed to reset the authorization callback URL');
}
if (saved.restartDeferred) {
const applied = await applyPendingOpenCodeRestart();
if (!applied.ok) {
throw new McpAuthorizationError(
applied.requiresManualRestart
? 'The callback settings changed, but OpenCode must be restarted manually before authorization can start.'
: 'Failed to apply the callback settings. Use Apply & Restart, then authorize again.',
);
}
}
};
/** How long the user plausibly spends authorising before giving up on them. */
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
const AUTHORIZATION_POLL_MS = 1_500;
@@ -129,14 +184,33 @@ const waitForAuthorizationThenFocus = async (name: string, directory: string | n
export const startMcpAuthorization = async (input: {
name: string;
directory?: string | null;
/** VS Code cannot receive our callback route, so it keeps OpenCode's own redirect. */
skipRedirectUriBootstrap?: boolean;
}): Promise<McpAuthorizationStart> => {
const { name, directory } = input;
let queuedState: string | null = null;
// Runtimes where the system browser provably lives on the same machine as
// OpenCode — desktop with the LOCAL embedded server, and VS Code (the
// extension always spawns its own local OpenCode): OpenCode's own flow works
// end-to-end over its FIXED loopback port (19876) — no config writes, no
// OpenCode restarts, no dependence on this app's per-launch port. The custom
// callback URL below stays for every case where the browser cannot reach
// OpenCode's loopback: remote instances, hosted web, mobile — and the plain
// web runtime too, because same-origin says nothing about the browser being
// on the server's machine.
if (isVSCodeRuntime() || (isDesktopShell() && getRuntimeKey() === 'local')) {
await clearCustomRedirectUriForNativeFlow(name);
const completion = useMcpStore.getState().authenticate(name, directory ?? null);
completion
.then(() => focusDesktopWindow())
.catch(() => {
// Recorded as a runtime diagnostic by the store; the status card and
// the caller's completion handling surface it.
});
return { authorizationUrl: '', opened: true, nativeFlow: true, completion };
}
try {
if (!input.skipRedirectUriBootstrap) {
{
// The config has to be loaded before its absence can mean anything. On
// the first authorization after launch the store is often still empty,
// and reading it then reported "no redirect URI" for a server that had
@@ -176,6 +250,25 @@ export const startMcpAuthorization = async (input: {
saved.message || 'Failed to save the authorization callback URL',
);
}
// Config mutations accumulate behind Apply & Restart now, but the
// authorization flow runs against the LIVE OpenCode runtime: with the
// write still queued, OpenCode hands out its own loopback redirect and
// the callback never reaches us. The user just clicked Authorize —
// explicit intent — so apply the queued changes right away and start
// the flow against the runtime that actually has our callback URL.
if (saved.restartDeferred) {
const applied = await applyPendingOpenCodeRestart();
if (applied.requiresManualRestart) {
throw new McpAuthorizationError(
'The callback URL was saved, but OpenCode must be restarted manually before authorization can start.',
);
}
if (!applied.ok) {
throw new McpAuthorizationError(
'Failed to apply the saved callback URL. Use Apply & Restart, then authorize again.',
);
}
}
}
}