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:
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1417,6 +1417,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': 'Fehlgeschlagen',
|
||||
'settings.mcp.page.status.label.needsAuth': 'Autorisierung erforderlich',
|
||||
'settings.mcp.page.status.label.needsRegistration': 'Registrierung erforderlich',
|
||||
'settings.mcp.page.status.label.awaitingRestart': 'Wartet auf Neustart',
|
||||
'settings.mcp.page.status.description.awaitingRestart': 'Dieser Server ist gespeichert, aber noch nicht übernommen. Verwenden Sie „Apply & Restart“, damit OpenCode ihn lädt — danach sind Verbindung und Autorisierung verfügbar.',
|
||||
'settings.mcp.page.status.description.connected': 'Verbunden und bereit, Tools und Ressourcen zu entdecken.',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode konnte diesen MCP-Server nicht erreichen.',
|
||||
'settings.mcp.page.status.description.needsAuth': 'Dieser entfernte MCP-Server erfordert Autorisierung, bevor eine Verbindung hergestellt werden kann.',
|
||||
|
||||
@@ -1482,6 +1482,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': 'Failed',
|
||||
'settings.mcp.page.status.label.needsAuth': 'Needs auth',
|
||||
'settings.mcp.page.status.label.needsRegistration': 'Needs registration',
|
||||
'settings.mcp.page.status.label.awaitingRestart': 'Awaiting restart',
|
||||
'settings.mcp.page.status.description.awaitingRestart': 'This server is saved but not applied yet. Use Apply & Restart to load it into OpenCode — connect and authorization become available after that.',
|
||||
'settings.mcp.page.status.description.connected': 'Connected and ready for OpenCode to discover tools and resources.',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode could not reach this MCP server.',
|
||||
'settings.mcp.page.status.description.needsAuth': 'This remote MCP server requires authorization before it can connect.',
|
||||
|
||||
@@ -1459,6 +1459,8 @@ export const settingsDict = {
|
||||
"settings.mcp.page.status.label.failed": "Fallido",
|
||||
"settings.mcp.page.status.label.needsAuth": "Necesita autenticación",
|
||||
"settings.mcp.page.status.label.needsRegistration": "Necesita registro",
|
||||
"settings.mcp.page.status.label.awaitingRestart": "Esperando reinicio",
|
||||
"settings.mcp.page.status.description.awaitingRestart": "Este servidor está guardado pero aún no aplicado. Usa «Apply & Restart» para cargarlo en OpenCode; después estarán disponibles la conexión y la autorización.",
|
||||
"settings.mcp.page.status.description.connected": "Conectado y listo para que OpenCode descubra herramientas y recursos.",
|
||||
"settings.mcp.page.status.description.failedDefault": "OpenCode no pudo alcanzar este servidor MCP.",
|
||||
"settings.mcp.page.status.description.needsAuth": "Este servidor MCP remoto requiere autorización antes de poder conectarse.",
|
||||
|
||||
@@ -1377,6 +1377,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': 'Échoué',
|
||||
'settings.mcp.page.status.label.needsAuth': 'Nécessite une authentification',
|
||||
'settings.mcp.page.status.label.needsRegistration': 'Nécessite une inscription',
|
||||
'settings.mcp.page.status.label.awaitingRestart': 'En attente de redémarrage',
|
||||
'settings.mcp.page.status.description.awaitingRestart': 'Ce serveur est enregistré mais pas encore appliqué. Utilisez « Apply & Restart » pour le charger dans OpenCode — la connexion et l\'autorisation seront ensuite disponibles.',
|
||||
'settings.mcp.page.status.description.connected': 'Connecté et prêt pour OpenCode pour découvrir des outils et des ressources.',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode n\'a pas pu atteindre ce serveur MCP.',
|
||||
'settings.mcp.page.status.description.needsAuth': 'Ce serveur MCP distant nécessite une autorisation avant de pouvoir se connecter.',
|
||||
|
||||
@@ -1492,6 +1492,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': '失敗',
|
||||
'settings.mcp.page.status.label.needsAuth': '認証が必要',
|
||||
'settings.mcp.page.status.label.needsRegistration': '登録が必要',
|
||||
'settings.mcp.page.status.label.awaitingRestart': '再起動待ち',
|
||||
'settings.mcp.page.status.description.awaitingRestart': 'このサーバーは保存されていますが、まだ適用されていません。「Apply & Restart」で OpenCode に読み込むと、接続と認可が利用できるようになります。',
|
||||
'settings.mcp.page.status.description.connected': '接続済み。OpenCode がツールとリソースを検出できます。',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode がこの MCP サーバーに到達できませんでした。',
|
||||
'settings.mcp.page.status.description.needsAuth': 'このリモート MCP サーバーは接続する前に認証が必要です。',
|
||||
|
||||
@@ -1459,6 +1459,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': '실패',
|
||||
'settings.mcp.page.status.label.needsAuth': '인증 필요',
|
||||
'settings.mcp.page.status.label.needsRegistration': '등록 필요',
|
||||
'settings.mcp.page.status.label.awaitingRestart': '재시작 대기 중',
|
||||
'settings.mcp.page.status.description.awaitingRestart': '이 서버는 저장되었지만 아직 적용되지 않았습니다. Apply & Restart를 사용해 OpenCode에 불러오면 연결과 인증을 사용할 수 있습니다.',
|
||||
'settings.mcp.page.status.description.connected': '연결되었습니다. OpenCode가 도구와 리소스를 찾을 준비가 됐습니다.',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode가 이 MCP 서버에 연결할 수 없습니다.',
|
||||
'settings.mcp.page.status.description.needsAuth': '이 원격 MCP 서버는 연결 전에 권한 부여가 필요합니다.',
|
||||
|
||||
@@ -503,6 +503,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': 'Błąd',
|
||||
'settings.mcp.page.status.label.needsAuth': 'Wymaga autoryzacji',
|
||||
'settings.mcp.page.status.label.needsRegistration': 'Wymaga rejestracji',
|
||||
'settings.mcp.page.status.label.awaitingRestart': 'Oczekuje na restart',
|
||||
'settings.mcp.page.status.description.awaitingRestart': 'Ten serwer został zapisany, ale nie został jeszcze zastosowany. Użyj Apply & Restart, aby OpenCode go wczytał — potem będą dostępne połączenie i autoryzacja.',
|
||||
'settings.mcp.page.status.projectScopedTo': 'Ograniczony do projektu w {directory}',
|
||||
'settings.mcp.page.status.runtimeStatus': 'Status uruchomieniowy',
|
||||
'settings.mcp.page.status.userScoped': 'Konfiguracja użytkownika',
|
||||
|
||||
@@ -1459,6 +1459,8 @@ export const settingsDict = {
|
||||
"settings.mcp.page.status.label.failed": "Falhou",
|
||||
"settings.mcp.page.status.label.needsAuth": "Precisa de autenticação",
|
||||
"settings.mcp.page.status.label.needsRegistration": "Precisa de registro",
|
||||
"settings.mcp.page.status.label.awaitingRestart": "Aguardando reinício",
|
||||
"settings.mcp.page.status.description.awaitingRestart": "Este servidor foi salvo, mas ainda não aplicado. Use Apply & Restart para carregá-lo no OpenCode — depois disso, conexão e autorização ficam disponíveis.",
|
||||
"settings.mcp.page.status.description.connected": "Conectado e pronto para que OpenCode descubra ferramentas e recursos.",
|
||||
"settings.mcp.page.status.description.failedDefault": "OpenCode não conseguiu acessar este servidor MCP.",
|
||||
"settings.mcp.page.status.description.needsAuth": "Este servidor MCP remoto exige autorização antes de poder se conectar.",
|
||||
|
||||
@@ -1459,6 +1459,8 @@ export const settingsDict = {
|
||||
"settings.mcp.page.status.label.failed": "Не вдалося",
|
||||
"settings.mcp.page.status.label.needsAuth": "Потрібна авторизація",
|
||||
"settings.mcp.page.status.label.needsRegistration": "Потрібна реєстрація",
|
||||
"settings.mcp.page.status.label.awaitingRestart": "Очікує перезапуску",
|
||||
"settings.mcp.page.status.description.awaitingRestart": "Сервер збережено, але ще не застосовано. Натисніть Apply & Restart, щоб OpenCode його завантажив — після цього стануть доступні підключення та авторизація.",
|
||||
"settings.mcp.page.status.description.connected": "Підключено, OpenCode може відкривати інструменти й ресурси.",
|
||||
"settings.mcp.page.status.description.failedDefault": "OpenCode не вдалося отримати доступ до цього сервера MCP.",
|
||||
"settings.mcp.page.status.description.needsAuth": "Цей віддалений сервер MCP потребує авторизації, перш ніж він зможе підключитися.",
|
||||
|
||||
@@ -1459,6 +1459,8 @@ export const settingsDict = {
|
||||
'settings.mcp.page.status.label.failed': '失败',
|
||||
'settings.mcp.page.status.label.needsAuth': '需要授权',
|
||||
'settings.mcp.page.status.label.needsRegistration': '需要注册',
|
||||
'settings.mcp.page.status.label.awaitingRestart': '等待重启',
|
||||
'settings.mcp.page.status.description.awaitingRestart': '该服务器已保存但尚未生效。使用 Apply & Restart 让 OpenCode 加载它——之后即可进行连接和授权。',
|
||||
'settings.mcp.page.status.description.connected': '已连接,OpenCode 可发现工具和资源。',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode 无法连接到此 MCP 服务器。',
|
||||
'settings.mcp.page.status.description.needsAuth': '此远程 MCP 服务器在连接前需要授权。',
|
||||
|
||||
@@ -1365,6 +1365,8 @@
|
||||
'settings.mcp.page.status.label.failed': '失敗',
|
||||
'settings.mcp.page.status.label.needsAuth': '需要授權',
|
||||
'settings.mcp.page.status.label.needsRegistration': '需要註冊',
|
||||
'settings.mcp.page.status.label.awaitingRestart': '等待重新啟動',
|
||||
'settings.mcp.page.status.description.awaitingRestart': '該伺服器已儲存但尚未生效。使用 Apply & Restart 讓 OpenCode 載入它——之後即可進行連線與授權。',
|
||||
'settings.mcp.page.status.description.connected': '已連線,OpenCode 可發現工具和資源。',
|
||||
'settings.mcp.page.status.description.failedDefault': 'OpenCode 無法連線到此 MCP 伺服器。',
|
||||
'settings.mcp.page.status.description.needsAuth': '此遠端 MCP 伺服器在連線前需要授權。',
|
||||
|
||||
@@ -72,6 +72,12 @@ interface McpStore {
|
||||
connect: (name: string, directory?: string | null) => Promise<void>;
|
||||
disconnect: (name: string, directory?: string | null) => Promise<void>;
|
||||
startAuth: (name: string, directory?: string | null) => Promise<string>;
|
||||
/**
|
||||
* OpenCode's native full OAuth flow: OpenCode opens the browser, receives
|
||||
* the callback on its own fixed loopback listener, and exchanges the code
|
||||
* itself. Resolves only when the whole flow finishes (minutes, not ms).
|
||||
*/
|
||||
authenticate: (name: string, directory?: string | null) => Promise<void>;
|
||||
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
|
||||
clearAuth: (name: string, directory?: string | null) => Promise<void>;
|
||||
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
|
||||
@@ -178,6 +184,28 @@ export const useMcpStore = create<McpStore>()(
|
||||
},
|
||||
|
||||
|
||||
authenticate: async (name, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const key = toKey(normalized);
|
||||
const api = getMcpApiClient(normalized);
|
||||
try {
|
||||
await api.mcp.auth.authenticate({ name }, { throwOnError: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Authorization failed';
|
||||
set((state) => ({
|
||||
diagnosticsByDirectory: {
|
||||
...state.diagnosticsByDirectory,
|
||||
[key]: {
|
||||
...(state.diagnosticsByDirectory[key] ?? {}),
|
||||
[name]: { status: 'failed', error: message },
|
||||
},
|
||||
},
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
await get().refresh({ directory: normalized, silent: true });
|
||||
},
|
||||
|
||||
completeAuth: async (name, code, directory) => {
|
||||
const normalized = normalizeDirectory(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
const api = getMcpApiClient(normalized);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
|
||||
// No global body parser on purpose: the real server parses JSON per-route, so
|
||||
// these tests must fail if the pending route loses its own parser again.
|
||||
const createApp = (overrides = {}) => {
|
||||
const app = express();
|
||||
const dependencies = {
|
||||
buildOpenCodeUrl: (path) => `http://opencode.local${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({ 'x-opencode-auth': 'test' }),
|
||||
...overrides,
|
||||
};
|
||||
registerOpenCodeRoutes(app, dependencies);
|
||||
return { app, dependencies };
|
||||
};
|
||||
|
||||
const queuePending = (app, { state, name, directory = null, origin = null }) =>
|
||||
request(app)
|
||||
.post('/api/mcp/auth/pending')
|
||||
.send({ state, name, directory, origin })
|
||||
.expect(200);
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('MCP OAuth browser callback route', () => {
|
||||
it('completes authorization server-side for a parked state and clears it', async () => {
|
||||
const upstreamFetch = vi.fn(async () => new Response(JSON.stringify({ success: true }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', upstreamFetch);
|
||||
const { app } = createApp();
|
||||
await queuePending(app, { state: 'state-1', name: 'linear', directory: '/projects/demo', origin: 'desktop' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/mcp/oauth/callback')
|
||||
.query({ state: 'state-1', code: 'auth-code', server: 'linear' })
|
||||
.expect(200);
|
||||
|
||||
expect(upstreamFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = upstreamFetch.mock.calls[0];
|
||||
expect(String(url)).toBe('http://opencode.local/mcp/linear/auth/callback?directory=%2Fprojects%2Fdemo');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({ code: 'auth-code' });
|
||||
expect(init.headers['x-opencode-auth']).toBe('test');
|
||||
|
||||
expect(response.text).toContain('Authorization Complete');
|
||||
// Started from the desktop shell: the page hands control back via deep link.
|
||||
expect(response.text).toContain('openchamber://focus/mcp-auth');
|
||||
|
||||
await request(app).get('/api/mcp/auth/pending').query({ state: 'state-1' }).expect(404);
|
||||
});
|
||||
|
||||
it('never forwards a code whose state is unknown', async () => {
|
||||
const upstreamFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', upstreamFetch);
|
||||
const { app } = createApp();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/mcp/oauth/callback')
|
||||
.query({ state: 'forged', code: 'attacker-code', server: 'linear' })
|
||||
.expect(400);
|
||||
|
||||
expect(upstreamFetch).not.toHaveBeenCalled();
|
||||
expect(response.text).toContain('Authorization Failed');
|
||||
});
|
||||
|
||||
it('omits the desktop deep link for flows started outside the desktop shell', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
const { app } = createApp();
|
||||
await queuePending(app, { state: 'state-web', name: 'linear' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/mcp/oauth/callback')
|
||||
.query({ state: 'state-web', code: 'auth-code' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.text).not.toContain('openchamber://');
|
||||
});
|
||||
|
||||
it('reports a provider error without contacting OpenCode', async () => {
|
||||
const upstreamFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', upstreamFetch);
|
||||
const { app } = createApp();
|
||||
await queuePending(app, { state: 'state-2', name: 'linear' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/mcp/oauth/callback')
|
||||
.query({ state: 'state-2', error: 'access_denied', error_description: 'User <denied> access' })
|
||||
.expect(400);
|
||||
|
||||
expect(upstreamFetch).not.toHaveBeenCalled();
|
||||
// Interpolated provider text is escaped, not rendered as markup.
|
||||
expect(response.text).toContain('User <denied> access');
|
||||
await request(app).get('/api/mcp/auth/pending').query({ state: 'state-2' }).expect(404);
|
||||
});
|
||||
|
||||
it('surfaces an OpenCode rejection as a failed page', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'invalid code' }), { status: 400 })));
|
||||
const { app } = createApp();
|
||||
await queuePending(app, { state: 'state-3', name: 'linear' });
|
||||
|
||||
const response = await request(app)
|
||||
.get('/mcp/oauth/callback')
|
||||
.query({ state: 'state-3', code: 'stale-code' })
|
||||
.expect(502);
|
||||
|
||||
expect(response.text).toContain('invalid code');
|
||||
});
|
||||
});
|
||||
@@ -839,5 +839,9 @@ export const registerOpenCodeProxy = (app, deps) => {
|
||||
|
||||
app.use('/api', applyProxyResponseDeadline);
|
||||
app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy);
|
||||
// OpenCode's native MCP OAuth flow: the request blocks until the user
|
||||
// finishes authorization in the browser (up to OpenCode's 5-minute callback
|
||||
// timeout), so it needs the interactive-OAuth deadline, not the default one.
|
||||
app.post('/api/mcp/:name/auth/authenticate', interactiveOAuthProxy);
|
||||
app.use('/api', apiProxy);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import express from 'express';
|
||||
import { createProjectIdFromPath } from '../projects/project-id.js';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
@@ -46,6 +47,46 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
return trimmed || null;
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
// Self-contained page for the OAuth return leg: the system browser has no UI
|
||||
// session, so it cannot load the SPA behind the auth gate — everything it
|
||||
// needs ships inline. `openchamber://focus/mcp-auth` raises the desktop app;
|
||||
// the link stays visible because some browsers only follow custom-protocol
|
||||
// URLs from a user gesture.
|
||||
const renderMcpOAuthCallbackPage = ({ title, message, desktopReturn }) => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)} — OpenChamber</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: Canvas; color: CanvasText; }
|
||||
main { max-width: 34rem; padding: 2.5rem 2rem; text-align: center; }
|
||||
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
|
||||
p { margin: 0; line-height: 1.5; opacity: 0.85; }
|
||||
a.return { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; border-radius: 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); color: inherit; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return to OpenChamber</a>
|
||||
<script>window.location.href = 'openchamber://focus/mcp-auth';</script>` : ''}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const readOpenCodeCurrentVersion = async () => {
|
||||
const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), {
|
||||
method: 'GET',
|
||||
@@ -341,7 +382,10 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mcp/auth/pending', async (req, res) => {
|
||||
// The body parser is per-route on this server; without it req.body is
|
||||
// undefined here, the state read as absent, and the "parked" context was
|
||||
// silently never stored — the callback then always failed as unknown.
|
||||
app.post('/api/mcp/auth/pending', express.json({ limit: '16kb' }), async (req, res) => {
|
||||
try {
|
||||
pruneExpiredPendingMcpAuthContexts();
|
||||
|
||||
@@ -417,6 +461,89 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Browser return leg of the MCP OAuth flow, completed entirely server-side.
|
||||
//
|
||||
// The provider redirects the SYSTEM browser here, and that browser has no
|
||||
// OpenChamber UI session — the SPA route this path used to land on sits
|
||||
// behind the client-side auth gate, so the user saw a login page instead of
|
||||
// a finished authorization. No session can be required on this path.
|
||||
//
|
||||
// Safe without auth because it acts only on a code+state pair whose `state`
|
||||
// matches a context parked by an authenticated start call: `state` is the
|
||||
// OAuth CSRF secret, generated per flow and known only to the initiating
|
||||
// client and the provider. Without a match the code is NOT forwarded, so an
|
||||
// unauthenticated caller cannot bind this server's MCP entry to a foreign
|
||||
// account by fabricating a callback. The endpoint reads nothing and mutates
|
||||
// nothing else.
|
||||
app.get('/mcp/oauth/callback', async (req, res) => {
|
||||
const queryValue = (key) => normalizePendingString(Array.isArray(req.query?.[key]) ? req.query[key][0] : req.query?.[key]);
|
||||
const state = queryValue('state');
|
||||
const code = queryValue('code');
|
||||
const providerError = queryValue('error');
|
||||
const providerErrorDescription = queryValue('error_description');
|
||||
|
||||
pruneExpiredPendingMcpAuthContexts();
|
||||
const context = state ? pendingMcpAuthContextByState.get(state) ?? null : null;
|
||||
const startedFromDesktop = context?.origin === 'desktop';
|
||||
|
||||
const finish = (status, { title, message }) => {
|
||||
if (state) pendingMcpAuthContextByState.delete(state);
|
||||
res.status(status).type('html').send(renderMcpOAuthCallbackPage({
|
||||
title,
|
||||
message,
|
||||
// Browsers only follow custom-protocol links from a user gesture in
|
||||
// some configurations, so the page both tries the jump and keeps a
|
||||
// visible link as the fallback.
|
||||
desktopReturn: startedFromDesktop,
|
||||
}));
|
||||
};
|
||||
|
||||
if (providerError) {
|
||||
return finish(400, {
|
||||
title: 'Authorization Failed',
|
||||
message: providerErrorDescription || providerError,
|
||||
});
|
||||
}
|
||||
if (!code) {
|
||||
return finish(400, {
|
||||
title: 'Authorization Failed',
|
||||
message: 'The provider did not return an authorization code. Start authorization again from MCP Settings.',
|
||||
});
|
||||
}
|
||||
if (!context?.name) {
|
||||
return finish(400, {
|
||||
title: 'Authorization Failed',
|
||||
message: 'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Authorize again.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(buildOpenCodeUrl(`/mcp/${encodeURIComponent(context.name)}/auth/callback`, ''));
|
||||
if (context.directory) callbackUrl.searchParams.set('directory', context.directory);
|
||||
const upstream = await fetch(callbackUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...getOpenCodeAuthHeaders() },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
if (!upstream.ok) {
|
||||
const payload = await upstream.json().catch(() => null);
|
||||
return finish(502, {
|
||||
title: 'Authorization Failed',
|
||||
message: payload?.error || payload?.message || `OpenCode rejected the authorization code (${upstream.status}). Start authorization again from MCP Settings.`,
|
||||
});
|
||||
}
|
||||
return finish(200, {
|
||||
title: 'Authorization Complete',
|
||||
message: 'You can close this tab and return to OpenChamber.',
|
||||
});
|
||||
} catch (error) {
|
||||
return finish(502, {
|
||||
title: 'Authorization Failed',
|
||||
message: error?.message || 'Failed to complete MCP authorization.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/provider/:providerId/source', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
Reference in New Issue
Block a user