Files
openchamber/packages/ui/src/components/sections/mcp/startMcpAuthorization.ts
T
Bohdan Triapitsyn 75978cf188 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.
2026-08-10 20:23:45 +03:00

306 lines
13 KiB
TypeScript

import { runtimeFetch } from '@/lib/runtime-fetch';
import { applyPendingOpenCodeRestart } from '@/lib/opencode/deferredRestart';
import { getRuntimeApiBaseUrl, getRuntimeKey } from '@/lib/runtime-switch';
import { openExternalUrl } from '@/lib/url';
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';
/**
* Starting MCP authorization, for every surface that offers it.
*
* A server in `needs_auth` cannot be fixed by reconnecting: `POST /mcp/:name/connect`
* just repeats the attempt that produced `needs_auth` in the first place. The
* flow OpenCode expects is explicit — ask for an authorization URL, send the
* user to it, then hand the returned code back:
*
* POST /mcp/:name/auth → { authorizationUrl, oauthState }
* (user authorises in a browser)
* POST /mcp/:name/auth/callback → status
*
* OpenCode does not open the browser for this flow; that is the caller's job.
*
* The redirect URI matters as much as the call. Without one of ours in the
* server's config, OpenCode falls back to its own loopback listener on
* 127.0.0.1 — which only works when the browser runs on the same machine as
* the OpenCode process. For a remote or web client the callback would simply
* never arrive, so the first authorization writes our own callback URL into
* the config before asking for the URL.
*/
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 {}
/**
* The callback lands in the system browser, which is a different surface from
* the desktop app. Recording where the flow began lets the callback page hand
* control back correctly: a browser session returns to the app it is already
* showing, while the desktop shell has to be raised through its own deep link.
*
* This travels with the pending context, not in the redirect URI. That URI is
* written into the server's config once and never rewritten, so a marker
* encoded there would be frozen at whatever runtime happened to authorise
* first — a desktop user would keep being sent to the web UI forever.
*/
export const MCP_OAUTH_ORIGIN_DESKTOP = 'desktop';
/**
* Stable for a given server, whatever session is open.
*
* It used to carry the directory as well, which made the address different for
* every worktree: switching sessions produced a new value, so the config was
* rewritten and OpenCode reloaded in front of the user. The directory is not
* needed here — authorization is not per-directory — and the pending context
* parked under the OAuth `state` carries it for the completion call.
*
* The server name stays. It never varies for a given entry, since the redirect
* lives in that entry's own config, and it lets the callback page identify the
* server straight from the URL rather than depending solely on server-side
* memory surviving the reload this very write triggers.
*/
export const buildMcpAuthorizationRedirectUri = (name: string): string => {
if (typeof window === 'undefined') {
throw new McpAuthorizationError('No browser context to build a callback URL from');
}
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
url.searchParams.set('server', name);
return url.toString();
};
/**
* Correlates the eventual browser redirect with the server it belongs to. The
* callback page has only the OAuth `state` to go on, so the pair is parked
* server-side under that key.
*/
const queuePendingContext = async (input: {
state: string;
name: string;
directory?: string | null;
origin: string | null;
}): Promise<void> => {
const response = await runtimeFetch('/api/mcp/auth/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
state: input.state,
name: input.name,
directory: input.directory?.trim() ? input.directory.trim() : null,
origin: input.origin,
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new McpAuthorizationError(payload?.error || 'Failed to prepare the MCP authorization callback');
}
};
const clearPendingContext = async (state: string | null): Promise<void> => {
if (!state) return;
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(state)}`, { method: 'DELETE' })
.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;
const waitForAuthorizationThenFocus = async (name: string, directory: string | null): Promise<void> => {
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, AUTHORIZATION_POLL_MS));
try {
await useMcpStore.getState().refresh({ directory, silent: true });
} catch {
continue;
}
const status = useMcpStore.getState().getStatusForDirectory(directory)[name]?.status;
if (status === 'connected') {
void focusDesktopWindow();
return;
}
}
};
export const startMcpAuthorization = async (input: {
name: string;
directory?: string | null;
}): 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 {
{
// 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
// one — so the config was rewritten needlessly and OpenCode reloaded in
// front of the user for no reason.
if (!useMcpConfigStore.getState().getMcpByName(name)) {
await useMcpConfigStore.getState().loadMcpConfigs();
}
const configStore = useMcpConfigStore.getState();
const existing = configStore.getMcpByName(name);
// `oauth: false` means the user disabled it explicitly.
const currentOAuth = existing && 'oauth' in existing && existing.oauth
? existing.oauth
: null;
// Rewritten when it does not match the callback we would receive right
// now — not merely when it is missing.
//
// The desktop app's loopback port changes between launches, so a stored
// redirect from an earlier session points at a port nothing serves any
// more: the provider redirects into the void and authorization never
// completes. Comparing instead of checking for absence also means the
// config is left alone — and OpenCode is not reloaded — whenever the
// stored value is already right, which is every run after the first.
const desiredRedirectUri = buildMcpAuthorizationRedirectUri(name);
if (existing && currentOAuth?.redirectUri !== desiredRedirectUri) {
const saved = await configStore.updateMcp(name, {
oauthEnabled: true,
oauthClientId: currentOAuth?.clientId ?? '',
oauthClientSecret: currentOAuth?.clientSecret ?? '',
oauthScope: currentOAuth?.scope ?? '',
oauthRedirectUri: desiredRedirectUri,
});
if (!saved.ok) {
throw new McpAuthorizationError(
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.',
);
}
}
}
}
const authorizationUrl = await useMcpStore.getState().startAuth(name, directory ?? null);
const state = parseMcpOAuthCallbackStateKey(new URL(authorizationUrl).searchParams);
if (state) {
queuedState = state;
await queuePendingContext({
state,
name,
directory,
origin: isDesktopShell() ? MCP_OAUTH_ORIGIN_DESKTOP : null,
});
}
const opened = await openExternalUrl(authorizationUrl);
// The desktop app raises itself once the server reports success, rather
// than waiting for the browser to hand control back. A browser will not
// follow a custom-protocol link without a user gesture, and the completion
// page has none — so the return trip cannot start from there.
if (opened && isDesktopShell()) {
void waitForAuthorizationThenFocus(name, directory ?? null);
}
return { authorizationUrl, opened };
} catch (error) {
// A parked context whose flow never started would later resolve a stale
// server for an unrelated callback.
await clearPendingContext(queuedState);
throw error;
}
};