From 2e49e2e6791bbe9205260317e0171e24431b369e Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 11 Jun 2026 20:54:43 -0700 Subject: [PATCH] feat(mcp): 'Clear sign-in data' recovery for stuck MCP auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale OAuth state (tokens + clientInfo from a broken flow, e.g. PostHog's 'Unable to determine region') can permanently block re-auth, and the only fix was hand-editing ~/.local/share/opencode/mcp-auth.json. Two gaps: - UI: the sign-out action was only rendered when status === connected — hidden in exactly the broken states (failed / needs_auth / needs_client_registration) where users get stuck. Those states now show a 'Clear sign-in data' button next to Sign in, reusing the existing logout confirm flow, with a hint explaining the reset. - Server: DELETE /workspace/:id/mcp/:name/auth depended on the engine being reachable. When the engine call fails (not a 404), the route now falls back to removing the entry from mcp-auth.json directly (mcp-auth-store.ts, mirroring opencode's xdg data-dir resolution). Also fixes the approval/audit path, which pointed at the config dir instead of the engine's data dir. --- .../domains/settings/pages/mcp-view.tsx | 32 ++++- apps/server/src/mcp-auth-store.e2e.test.ts | 117 ++++++++++++++++++ apps/server/src/mcp-auth-store.ts | 61 +++++++++ apps/server/src/server.ts | 10 +- 4 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/mcp-auth-store.e2e.test.ts create mode 100644 apps/server/src/mcp-auth-store.ts diff --git a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx index d5ee8a1be3..42ac06c7a7 100644 --- a/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/mcp-view.tsx @@ -1110,15 +1110,39 @@ function McpConfiguredServerDetails(props: Parameters[0]) { if (!props.supportsOauth(props.entry)) return null; if (props.status !== "connected") { + // Broken auth states: stale tokens or a poisoned OAuth client + // registration can make "Sign in" fail forever. Offer a recovery + // action that clears the stored sign-in data so a retry starts fresh. + const showClearAuth = + props.status === "failed" || + props.status === "needs_auth" || + props.status === "needs_client_registration"; + const clearingThis = props.logoutBusy && props.logoutTarget === props.entry.name; return ( <>
{t("mcp.logout_label")}
- +
+ {showClearAuth ? ( + + ) : null} + +
+
+
+ {showClearAuth + ? "Stuck signing in? Clear sign-in data to reset the saved OAuth tokens and app registration, then sign in again." + : t("mcp.login_hint")}
-
{t("mcp.login_hint")}
); } diff --git a/apps/server/src/mcp-auth-store.e2e.test.ts b/apps/server/src/mcp-auth-store.e2e.test.ts new file mode 100644 index 0000000000..fb51b0a1cb --- /dev/null +++ b/apps/server/src/mcp-auth-store.e2e.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startServer } from "./server.js"; +import type { ServerConfig } from "./types.js"; + +type Served = { port: number; stop: (closeActiveConnections?: boolean) => void | Promise }; + +const stops: Array<() => void | Promise> = []; +const roots: string[] = []; +let previousEnv: Record = {}; + +afterEach(async () => { + while (stops.length) await stops.pop()?.(); + while (roots.length) await rm(roots.pop()!, { recursive: true, force: true }); + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + previousEnv = {}; +}); + +function setEnv(key: string, value: string) { + if (!(key in previousEnv)) previousEnv[key] = process.env[key]; + process.env[key] = value; +} + +async function setup() { + const root = await mkdtemp(join(tmpdir(), "openwork-mcp-auth-clear-")); + roots.push(root); + setEnv("OPENWORK_RUNTIME_DB", join(root, "runtime.sqlite")); + + // Point the engine data dir resolution at a temp store with a stale entry. + const dataHome = join(root, "xdg-data"); + setEnv("XDG_DATA_HOME", dataHome); + const authStorePath = join(dataHome, "opencode", "mcp-auth.json"); + await mkdir(join(dataHome, "opencode"), { recursive: true }); + await writeFile( + authStorePath, + JSON.stringify({ + posthog: { + serverUrl: "https://mcp.us.posthog.com/mcp", + tokens: { access_token: "stale" }, + clientInfo: { client_id: "poisoned-client" }, + oauthState: "stale-state", + }, + linear: { serverUrl: "https://mcp.linear.app/mcp", tokens: { access_token: "good" } }, + }, null, 2), + "utf8", + ); + + const config: ServerConfig = { + host: "127.0.0.1", + port: 0, + token: "owt_test_token", + hostToken: "owt_host_token", + approval: { mode: "auto", timeoutMs: 1000 }, + corsOrigins: ["*"], + workspaces: [ + { + id: "ws_1", + name: "Workspace", + path: root, + preset: "starter", + workspaceType: "local", + // Unreachable engine: forces the file-level recovery fallback. + baseUrl: "http://127.0.0.1:9", + }, + ], + authorizedRoots: [root], + readOnly: false, + startedAt: Date.now(), + tokenSource: "cli", + hostTokenSource: "cli", + logFormat: "pretty", + logRequests: false, + }; + const server = await startServer(config) as Served; + stops.push(() => server.stop(true)); + return { + base: `http://127.0.0.1:${server.port}`, + headers: { Authorization: "Bearer owt_test_token" }, + authStorePath, + }; +} + +describe("MCP auth clear recovery", () => { + test("clears a stale auth entry even when the engine is unreachable", async () => { + const { base, headers, authStorePath } = await setup(); + + const response = await fetch(`${base}/workspace/ws_1/mcp/posthog/auth`, { + method: "DELETE", + headers, + }); + expect(response.status).toBe(200); + + const store = JSON.parse(await readFile(authStorePath, "utf8")) as Record; + expect(store.posthog).toBeUndefined(); + // Other entries are untouched. + expect(store.linear).toBeDefined(); + }); + + test("is idempotent: clearing an absent entry still succeeds", async () => { + const { base, headers, authStorePath } = await setup(); + + const first = await fetch(`${base}/workspace/ws_1/mcp/posthog/auth`, { method: "DELETE", headers }); + expect(first.status).toBe(200); + const second = await fetch(`${base}/workspace/ws_1/mcp/posthog/auth`, { method: "DELETE", headers }); + expect(second.status).toBe(200); + + const store = JSON.parse(await readFile(authStorePath, "utf8")) as Record; + expect(store.posthog).toBeUndefined(); + expect(store.linear).toBeDefined(); + }); +}); diff --git a/apps/server/src/mcp-auth-store.ts b/apps/server/src/mcp-auth-store.ts new file mode 100644 index 0000000000..0fe0e6dede --- /dev/null +++ b/apps/server/src/mcp-auth-store.ts @@ -0,0 +1,61 @@ +/** + * Direct access to the opencode engine's MCP auth store (mcp-auth.json). + * + * The engine owns this file (Global.Path.data/mcp-auth.json) and exposes + * DELETE /mcp/:name/auth, which is the preferred path. This module is the + * recovery fallback for when the engine is unreachable or refuses: stale + * entries (tokens + clientInfo from a broken OAuth flow, e.g. PostHog's + * "Unable to determine region") can permanently block re-auth, and stuck + * users need a way out that doesn't involve hand-editing JSON. + */ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +// Mirrors opencode's Global.Path.data resolution (xdg-basedir), aligned with +// opencodeDataDirs() in opencode-db.ts. +function opencodeDataDirs(): string[] { + const dirs: string[] = []; + const xdg = process.env.XDG_DATA_HOME?.trim(); + if (xdg) dirs.push(join(xdg, "opencode")); + dirs.push(join(homedir(), ".local", "share", "opencode")); + if (process.platform === "darwin") dirs.push(join(homedir(), "Library", "Application Support", "opencode")); + if (process.platform === "win32") { + const appData = process.env.APPDATA?.trim(); + if (appData) dirs.push(join(appData, "opencode")); + } + return Array.from(new Set(dirs)); +} + +/** First existing mcp-auth.json, or the default location when none exists. */ +export function resolveMcpAuthStorePath(): string { + const candidates = opencodeDataDirs().map((dir) => join(dir, "mcp-auth.json")); + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] + ?? join(homedir(), ".local", "share", "opencode", "mcp-auth.json"); +} + +/** + * Remove an MCP's entry from the auth store file directly. Returns true when + * the entry is gone afterwards (deleted, or never existed — idempotent), and + * false only when the store could not be read or written. + */ +export async function removeMcpAuthEntryFromStore(name: string): Promise { + const path = resolveMcpAuthStorePath(); + if (!existsSync(path)) return true; + try { + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + if (!isRecord(parsed)) return false; + if (!(name in parsed)) return true; + const next = { ...parsed }; + delete next[name]; + await writeFile(path, JSON.stringify(next, null, 2), { mode: 0o600 }); + return true; + } catch { + return false; + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6f2985f6ac..827e4150e4 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -32,6 +32,7 @@ import { syncDesktopCloudResources, } from "./desktop-cloud-sync.js"; import { installCloudPlugin, readCloudPluginResolved, readInstalledCloudPlugins, removeCloudPlugin } from "./cloud-plugins.js"; +import { removeMcpAuthEntryFromStore, resolveMcpAuthStorePath } from "./mcp-auth-store.js"; import { applyMaterializedBlueprintSessions, normalizeBlueprintSessionTemplates, @@ -4111,7 +4112,7 @@ function createRoutes( const name = String(ctx.params.name ?? "").trim(); validateMcpName(name); - const authStorePath = join(homedir(), ".config", "opencode", "mcp-auth.json"); + const authStorePath = resolveMcpAuthStorePath(); await requireApproval(ctx, { workspaceId: workspace.id, action: "mcp.auth.remove", @@ -4142,7 +4143,12 @@ function createRoutes( ) { // ok } else { - throw error; + // Recovery fallback: when the engine is unreachable or refuses + // (stale entries can break auth badly enough that even the engine + // route fails), edit the auth store file directly so stuck users + // can always clear sign-in data from the UI. + const cleared = await removeMcpAuthEntryFromStore(name).catch(() => false); + if (!cleared) throw error; } }