213 lines
8.7 KiB
TypeScript
213 lines
8.7 KiB
TypeScript
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|||
|
|
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||
|
|
import { openExternalUrl } from '@/lib/url';
|
||
|
|
import { focusDesktopWindow, isDesktopShell } 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;
|
||
|
|
};
|
||
|
|
|
||
|
|
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);
|
||
|
|
};
|
||
|
|
|
||
|
|
/** 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;
|
||
|
|
/** 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;
|
||
|
|
|
||
|
|
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
|
||
|
|
// 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',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
};
|