Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions apps/app/src/react-app/domains/settings/pages/mcp-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1110,15 +1110,39 @@ function McpConfiguredServerDetails(props: Parameters<typeof McpConfiguredServer
function McpConfiguredServerAuthActions(props: Parameters<typeof McpConfiguredServerRow>[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 (
<>
<div className="flex items-center justify-between gap-3 pt-1">
<div className="text-xs text-dls-secondary">{t("mcp.logout_label")}</div>
<Button size="sm" disabled={props.busy} onClick={() => props.onAuthorize(props.entry)}>
{t("mcp.login_action")}
</Button>
<div className="flex items-center gap-2">
{showClearAuth ? (
<Button
variant="outline"
size="sm"
disabled={props.busy || props.logoutBusy}
onClick={() => props.onRequestLogout(props.entry.name)}
>
{clearingThis ? t("mcp.logout_working") : "Clear sign-in data"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: New user-facing strings are hardcoded and bypass i18n, causing untranslated text in non-English locales.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/app/src/react-app/domains/settings/pages/mcp-view.tsx, line 1133:

<comment>New user-facing strings are hardcoded and bypass i18n, causing untranslated text in non-English locales.</comment>

<file context>
@@ -1110,15 +1110,39 @@ function McpConfiguredServerDetails(props: Parameters<typeof McpConfiguredServer
+                disabled={props.busy || props.logoutBusy}
+                onClick={() => props.onRequestLogout(props.entry.name)}
+              >
+                {clearingThis ? t("mcp.logout_working") : "Clear sign-in data"}
+              </Button>
+            ) : null}
</file context>

</Button>
) : null}
<Button size="sm" disabled={props.busy} onClick={() => props.onAuthorize(props.entry)}>
{t("mcp.login_action")}
</Button>
</div>
</div>
<div className="text-[11px] text-dls-secondary/70">
{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")}
</div>
<div className="text-[11px] text-dls-secondary/70">{t("mcp.login_hint")}</div>
</>
);
}
Expand Down
117 changes: 117 additions & 0 deletions apps/server/src/mcp-auth-store.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> };

const stops: Array<() => void | Promise<void>> = [];
const roots: string[] = [];
let previousEnv: Record<string, string | undefined> = {};

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<string, unknown>;
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<string, unknown>;
expect(store.posthog).toBeUndefined();
expect(store.linear).toBeDefined();
});
});
61 changes: 61 additions & 0 deletions apps/server/src/mcp-auth-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: MCP auth-store path resolution omits OPENWORK_DATA_DIR, so clear-auth can silently report success without deleting the real entry in orchestrator-managed data dirs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/src/mcp-auth-store.ts, line 24:

<comment>MCP auth-store path resolution omits `OPENWORK_DATA_DIR`, so clear-auth can silently report success without deleting the real entry in orchestrator-managed data dirs.</comment>

<file context>
@@ -0,0 +1,61 @@
+// 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"));
</file context>

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<boolean> {
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;
}
}
10 changes: 8 additions & 2 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
}
}

Expand Down