From 92fe640165b5831a73ccb788e0d53a5ad42cc05d Mon Sep 17 00:00:00 2001 From: Joaquin Arroyo Date: Wed, 1 Jul 2026 10:17:09 -0300 Subject: [PATCH 1/2] fix(mcp): support OAuth on remote/web deployments (OPENCODE_PUBLIC_URL) When OPENCODE_PUBLIC_URL is set, the MCP OAuth redirect URI now points at the main server instead of a local-only callback port, and the server route at /mcp/oauth/callback resolves the pending auth directly. Since the server can't open a browser on the user's behalf in this mode, it emits an mcp.browser.open.failed event with the authorization URL. The web UI surfaces this as a persistent toast (in context/notification.tsx, which is mounted for every route/layout) with an "Open in browser" action, so the user can complete the flow from their local browser. --- packages/app/src/context/notification.tsx | 22 ++++++++ packages/opencode/src/mcp/index.ts | 43 ++++++++------- packages/opencode/src/mcp/oauth-callback.ts | 53 +++++++++++-------- packages/opencode/src/mcp/oauth-provider.ts | 4 ++ .../server/routes/instance/httpapi/server.ts | 28 ++++++++++ packages/opencode/src/server/server.ts | 4 +- .../opencode/src/server/shared/public-ui.ts | 1 + packages/opencode/src/server/shared/ui.ts | 2 +- 8 files changed, 115 insertions(+), 42 deletions(-) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index e12f8f1250ea..9f0740a535ad 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,6 +12,7 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" +import { showToast } from "@/utils/toast" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { type DraftTab, useTabs } from "./tabs" @@ -386,6 +387,27 @@ function createServerNotificationState(input: { const unsub = serverSDK().event.listen((e) => { const event = e.details + + if (event.type === "mcp.browser.open.failed") { + const { mcpName, url } = event.properties + showToast({ + persistent: true, + title: `Authorize ${mcpName}`, + description: "Open the link in your browser to complete MCP authorization.", + actions: [ + { + label: "Open in browser", + onClick: () => window.open(url, "_blank"), + }, + { + label: language.t("common.dismiss"), + onClick: "dismiss", + }, + ], + }) + return + } + if (event.type !== "session.idle" && event.type !== "session.error") return const directory = e.name diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e574e20fbaac..3eae83e3b2a8 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -891,26 +891,33 @@ const layer = Layer.effect( const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) onAuthorization?.(result.authorizationUrl) - yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( - Effect.flatMap((subprocess) => - Effect.callback((resume) => { - const timer = setTimeout(() => resume(Effect.void), 500) - subprocess.on("error", (err) => { - clearTimeout(timer) - resume(Effect.fail(err)) - }) - subprocess.on("exit", (code) => { - if (code !== null && code !== 0) { + if (process.env.OPENCODE_PUBLIC_URL) { + // Running as a remote web server — no browser to open on the server. + // Emit the event so the web UI can show the URL to the user. + console.log("[MCP OAuth] emitting BrowserOpenFailed", { mcpName, url: result.authorizationUrl }) + yield* events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) + } else { + yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( + Effect.flatMap((subprocess) => + Effect.callback((resume) => { + const timer = setTimeout(() => resume(Effect.void), 500) + subprocess.on("error", (err) => { clearTimeout(timer) - resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) - } - }) + resume(Effect.fail(err)) + }) + subprocess.on("exit", (code) => { + if (code !== null && code !== 0) { + clearTimeout(timer) + resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) + } + }) + }), + ), + Effect.catch(() => { + return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) }), - ), - Effect.catch(() => { - return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) - }), - ) + ) + } const code = yield* Effect.promise(() => callbackPromise) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 84007902b8c0..14317b766f92 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -4,6 +4,7 @@ import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const OAUTH_CALLBACK_HOST = "127.0.0.1" +const LOCALHOST_REDIRECT_RE = /^https?:\/\/(127\.0\.0\.1|localhost)([:\/]|$)/ // Current callback server configuration (may differ from defaults if custom redirectUri is used) let currentPort = OAUTH_CALLBACK_PORT @@ -39,6 +40,17 @@ function stopIfIdle() { server = undefined } +function settleAuth(state: string, settler: (p: PendingAuth) => void): boolean { + const pending = pendingAuths.get(state) + if (!pending) return false + clearTimeout(pending.timeout) + pendingAuths.delete(state) + cleanupStateIndex(state) + settler(pending) + stopIfIdle() + return true +} + function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { const url = new URL(req.url || "/", `http://localhost:${currentPort}`) @@ -63,16 +75,9 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). if (error) { const errorMsg = errorDescription || error - if (pendingAuths.has(state)) { - const pending = pendingAuths.get(state)! - clearTimeout(pending.timeout) - pendingAuths.delete(state) - cleanupStateIndex(state) - pending.reject(new Error(errorMsg)) - } + settleAuth(state, (p) => p.reject(new Error(errorMsg))) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) - stopIfIdle() return } @@ -82,28 +87,32 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). return } - // Validate state parameter - if (!pendingAuths.has(state)) { - const errorMsg = "Invalid or expired state parameter - potential CSRF attack" + const resolved = settleAuth(state, (p) => p.resolve(code)) + if (!resolved) { res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) - res.end(OauthCallbackPage.error(errorMsg, { provider: "MCP" })) + res.end(OauthCallbackPage.error("Invalid or expired state parameter - potential CSRF attack", { provider: "MCP" })) return } - - const pending = pendingAuths.get(state)! - - clearTimeout(pending.timeout) - pendingAuths.delete(state) - cleanupStateIndex(state) - pending.resolve(code) - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(OauthCallbackPage.success({ provider: "MCP" })) - stopIfIdle() +} + +/** Resolve a pending OAuth callback received by the main web server (OPENCODE_PUBLIC_URL path). */ +export function resolveFromExternal(code: string, state: string): boolean { + return settleAuth(state, (p) => p.resolve(code)) +} + +/** Reject a pending OAuth callback received by the main web server with an error. */ +export function rejectFromExternal(state: string, errorMessage: string): boolean { + return settleAuth(state, (p) => p.reject(new Error(errorMessage))) } export async function ensureRunning(redirectUri?: string): Promise { - // Parse the redirect URI to get port and path (uses defaults if not provided) + // If the redirect URI points to a non-localhost host, the callback will be + // received by an external handler (e.g. the main web server via + // OPENCODE_PUBLIC_URL). No local server needed. + if (redirectUri && !LOCALHOST_REDIRECT_RE.test(redirectUri)) return + const { port, path } = parseRedirectUri(redirectUri) // If server is running on a different port/path, stop it first diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 596bfe1d551f..1e5fa3b56f82 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -36,6 +36,10 @@ export class McpOAuthProvider implements OAuthClientProvider { if (this.config.redirectUri) { return this.config.redirectUri } + const publicUrl = process.env.OPENCODE_PUBLIC_URL + if (publicUrl) { + return `${publicUrl.replace(/\/$/, "")}${OAUTH_CALLBACK_PATH}` + } const port = this.config.callbackPort ?? OAUTH_CALLBACK_PORT return `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}` } diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 73de083f9705..be449c9c1e68 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -19,6 +19,9 @@ import { Installation } from "@/installation" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { McpAuth } from "@/mcp/auth" +import { McpOAuthCallback } from "@/mcp/oauth-callback" +import { OAUTH_CALLBACK_PATH } from "@/mcp/oauth-provider" +import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { PluginPtyEnvironment } from "@/plugin/pty-environment" @@ -190,6 +193,30 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe Layer.provide(authOnlyRouterLayer), ) +const oauthCallbackRoute = HttpRouter.use((router) => + router.add("GET", OAUTH_CALLBACK_PATH, (request) => { + const url = new URL(request.url, "http://localhost") + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const provider = "MCP" + let html: string + if (error) { + const reason = url.searchParams.get("error_description") ?? error + McpOAuthCallback.rejectFromExternal(state ?? "", reason) + html = OauthCallbackPage.error(reason, { provider }) + } else if (code && state) { + const resolved = McpOAuthCallback.resolveFromExternal(code, state) + html = resolved + ? OauthCallbackPage.success({ provider }) + : OauthCallbackPage.error("Invalid or expired state parameter", { provider }) + } else { + html = OauthCallbackPage.error("Invalid or expired state parameter", { provider }) + } + return Effect.succeed(HttpServerResponse.html(html)) + }), +).pipe(Layer.provide(authOnlyRouterLayer)) + const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -277,6 +304,7 @@ export function createRoutes( instanceRoutes, serverRoutes, docRoute, + oauthCallbackRoute, uiRoute, ).pipe( Layer.provide([ diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 440b992c1557..839d3f9b32a1 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -56,7 +56,9 @@ class ListenerServerService extends Context.Service { const handler = HttpApiApp.webHandler().handler const app: ServerApp = { - fetch: (request: Request) => handler(request, HttpApiApp.context), + fetch: (request: Request) => { + return handler(request, HttpApiApp.context) + }, request(input, init) { return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) }, diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts index fece09592fa9..010a7d109cff 100644 --- a/packages/opencode/src/server/shared/public-ui.ts +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -5,6 +5,7 @@ export const PUBLIC_UI_PATHS = new Set([ "/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png", + "/mcp/oauth/callback", ]) export function isPublicUIPath(method: string, pathname: string) { diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c2fd3b86375c..b1a490aedb90 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -6,7 +6,7 @@ import { ProxyUtil } from "../proxy-util" let embeddedUIPromise: Promise | null> | undefined -export const UI_UPSTREAM = new URL("https://app.opencode.ai") +export const UI_UPSTREAM = new URL(process.env.OPENCODE_UI_UPSTREAM ?? "https://app.opencode.ai") export const csp = (hash = "") => `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src * data:` From 6a573ce9a169f9c78680d3040beb451010e99e46 Mon Sep 17 00:00:00 2001 From: Joaquin Arroyo Date: Wed, 1 Jul 2026 15:50:30 -0300 Subject: [PATCH 2/2] fix(mcp): deliver OAuth authorize URL via click response instead of SSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mcp.browser.open.failed toast depends on the /global/event SSE stream, which doesn't reliably deliver events to the client in remote `serve` mode (connections cycle at the 15s heartbeat without the event landing). Instead of depending on that channel, authenticate() now returns the authorizationUrl directly in the HTTP response for the OPENCODE_PUBLIC_URL case, without blocking on the OAuth callback: waitForCallback + finishAuth continue in a detached background fiber (via EffectBridge), and the client opens the URL with window.open() synchronously in the same click handler that triggered authentication (preserving the user gesture so popup blockers don't block it). The event emission and the notification.tsx toast stay as a genuine fallback (e.g. if window.open() gets silently blocked) — deduped against the direct open via mcp-auth-tracker.ts, marked *before* the request goes out since the SSE event can otherwise win the race and show a redundant toast. Once the background fiber finishes the OAuth callback, it emits ToolsChanged so the client refetches MCP status instead of showing stale "needs_auth" until a manual reload. The local/desktop flow (opening a browser server-side) is unchanged. --- .../context/global-sync/mcp-auth-tracker.ts | 17 +++++ .../app/src/context/global-sync/mcp.test.ts | 1 + packages/app/src/context/global-sync/mcp.ts | 12 +++- packages/app/src/context/notification.tsx | 4 ++ packages/app/src/context/server-sync.tsx | 8 ++- packages/opencode/src/cli/cmd/mcp.ts | 4 +- packages/opencode/src/mcp/index.ts | 68 +++++++++++++------ .../routes/instance/httpapi/groups/mcp.ts | 8 ++- .../test/mcp/oauth-auto-connect.test.ts | 38 +++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 9 ++- 11 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 packages/app/src/context/global-sync/mcp-auth-tracker.ts diff --git a/packages/app/src/context/global-sync/mcp-auth-tracker.ts b/packages/app/src/context/global-sync/mcp-auth-tracker.ts new file mode 100644 index 000000000000..1e1bcb50aab7 --- /dev/null +++ b/packages/app/src/context/global-sync/mcp-auth-tracker.ts @@ -0,0 +1,17 @@ +// Tracks MCP servers whose authorization URL was just opened via a direct +// window.open() from the "authenticate" click response, so the SSE-driven +// toast fallback (context/notification.tsx) can skip showing a redundant +// prompt for the same attempt. +const recentlyOpened = new Map() +const TTL_MS = 10_000 + +export function markAuthorizationUrlOpened(mcpName: string) { + recentlyOpened.set(mcpName, Date.now()) +} + +export function consumeRecentlyOpened(mcpName: string) { + const at = recentlyOpened.get(mcpName) + if (at === undefined) return false + recentlyOpened.delete(mcpName) + return Date.now() - at <= TTL_MS +} diff --git a/packages/app/src/context/global-sync/mcp.test.ts b/packages/app/src/context/global-sync/mcp.test.ts index a292d23df94b..77385d3a532d 100644 --- a/packages/app/src/context/global-sync/mcp.test.ts +++ b/packages/app/src/context/global-sync/mcp.test.ts @@ -5,6 +5,7 @@ describe("toggleMcp", () => { test("runs the status action before refreshing the owning query", async () => { const calls: string[] = [] const input = (status: "connected" | "needs_auth" | "disabled") => ({ + name: "test-mcp", status, connect: async () => { calls.push("connect") diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts index 2eeb297b955a..468e13433a54 100644 --- a/packages/app/src/context/global-sync/mcp.ts +++ b/packages/app/src/context/global-sync/mcp.ts @@ -1,18 +1,26 @@ import type { McpStatus } from "@opencode-ai/sdk/v2/client" +import { markAuthorizationUrlOpened } from "./mcp-auth-tracker" export async function toggleMcp(input: { + name: string status: McpStatus["status"] connect: () => Promise disconnect: () => Promise - authenticate: () => Promise + authenticate: () => Promise<{ authorizationUrl: string } | void> refresh: () => Promise }) { - await { + // Mark before the request goes out: the mcp.browser.open.failed SSE event + // (published server-side as part of the same authenticate() call) can reach + // an already-open event stream before this request's own response comes + // back, so marking after the response would lose the race. + if (input.status === "needs_auth") markAuthorizationUrlOpened(input.name) + const result = await { connected: input.disconnect, needs_auth: input.authenticate, disabled: input.connect, failed: input.connect, needs_client_registration: input.connect, }[input.status]() + if (result?.authorizationUrl) window.open(result.authorizationUrl, "_blank") await input.refresh() } diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 9f0740a535ad..35c5142346ec 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -13,6 +13,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" import { showToast } from "@/utils/toast" +import { consumeRecentlyOpened } from "./global-sync/mcp-auth-tracker" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { type DraftTab, useTabs } from "./tabs" @@ -390,6 +391,9 @@ function createServerNotificationState(input: { if (event.type === "mcp.browser.open.failed") { const { mcpName, url } = event.properties + // The authenticate click already opened this URL directly via window.open() + // (context/global-sync/mcp.ts) — skip the redundant toast for that attempt. + if (consumeRecentlyOpened(mcpName)) return showToast({ persistent: true, title: `Authorize ${mcpName}`, diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index f2ee8869acf5..84d8204aefea 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -358,6 +358,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.type === "mcp.tools.changed") { + void queryClient.refetchQueries(queryOptionsApi.mcp(key)) + } + session.apply(event) if (directory === "global") { @@ -472,6 +476,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const sdk = sdkFor(key) const status = children.child(key, { bootstrap: false })[0].mcp[name].status await toggleMcp({ + name, status, connect: async () => { await sdk.mcp.connect({ name }) @@ -480,7 +485,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { await sdk.mcp.disconnect({ name }) }, authenticate: async () => { - await sdk.mcp.auth.authenticate({ name }) + const result = (await sdk.mcp.auth.authenticate({ name })).data + if (result && "authorizationUrl" in result) return { authorizationUrl: result.authorizationUrl } }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..95440cc61bcb 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -265,7 +265,9 @@ export const McpAuthCommand = effectCmd({ ).pipe( Effect.tap((status) => Effect.sync(() => { - if (status.status === "connected") { + if ("authorizationUrl" in status) { + spinner.stop("Authorization started; completing in the background once you finish in the browser.") + } else if (status.status === "connected") { spinner.stop("Authentication successful!") } else if (status.status === "needs_client_registration") { spinner.stop("Authentication failed", 1) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3eae83e3b2a8..21c45992b879 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -182,7 +182,7 @@ export interface Interface { readonly authenticate: ( mcpName: string, onAuthorization?: (authorizationUrl: string) => void, - ) => Effect.Effect + ) => Effect.Effect readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect @@ -893,32 +893,58 @@ const layer = Layer.effect( if (process.env.OPENCODE_PUBLIC_URL) { // Running as a remote web server — no browser to open on the server. - // Emit the event so the web UI can show the URL to the user. + // Emit the event as a fallback (in case a client is listening on /global/event), + // but don't rely on it: return the authorizationUrl directly in the response so + // the client can open it itself from the request that triggered this click. console.log("[MCP OAuth] emitting BrowserOpenFailed", { mcpName, url: result.authorizationUrl }) yield* events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) - } else { - yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( - Effect.flatMap((subprocess) => - Effect.callback((resume) => { - const timer = setTimeout(() => resume(Effect.void), 500) - subprocess.on("error", (err) => { - clearTimeout(timer) - resume(Effect.fail(err)) - }) - subprocess.on("exit", (code) => { - if (code !== null && code !== 0) { - clearTimeout(timer) - resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) - } - }) - }), + + const bridge = yield* EffectBridge.make() + bridge.fork( + Effect.gen(function* () { + const code = yield* Effect.promise(() => callbackPromise) + const storedState = yield* auth.getOAuthState(mcpName) + if (storedState !== result.oauthState) { + yield* auth.clearOAuthState(mcpName) + yield* Effect.logWarning("MCP OAuth state mismatch - potential CSRF attack", { mcpName }) + return + } + yield* auth.clearOAuthState(mcpName) + yield* finishAuth(mcpName, code) + // The client returned from authenticate() before this ran, so it has + // no way to know completion happened — nudge it to refetch status. + yield* events.publish(ToolsChanged, { server: mcpName }).pipe(Effect.ignore) + }).pipe( + Effect.catch((error) => + Effect.logWarning("MCP OAuth background completion failed", { mcpName, error: String(error) }), + ), ), - Effect.catch(() => { - return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) - }), ) + + return { authorizationUrl: result.authorizationUrl, oauthState: result.oauthState } } + yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( + Effect.flatMap((subprocess) => + Effect.callback((resume) => { + const timer = setTimeout(() => resume(Effect.void), 500) + subprocess.on("error", (err) => { + clearTimeout(timer) + resume(Effect.fail(err)) + }) + subprocess.on("exit", (code) => { + if (code !== null && code !== 0) { + clearTimeout(timer) + resume(Effect.fail(new Error(`Browser open failed with exit code ${code}`))) + } + }) + }), + ), + Effect.catch(() => { + return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore) + }), + ) + const code = yield* Effect.promise(() => callbackPromise) const storedState = yield* auth.getOAuthState(mcpName) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73e4..081ec9f3e4c1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -93,13 +93,17 @@ export const McpApi = HttpApi.make("mcp") HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, query: WorkspaceRoutingQuery, - success: described(MCP.Status, "OAuth authentication completed"), + success: described( + Schema.Union([MCP.Status, AuthStartResponse]), + "OAuth authentication completed, or started (authorizationUrl) when it must be opened client-side", + ), error: [UnsupportedOAuthError, McpServerNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "mcp.auth.authenticate", summary: "Authenticate MCP OAuth", - description: "Start OAuth flow and wait for callback (opens browser).", + description: + "Start OAuth flow. Returns the authorization URL immediately if the client must open it (e.g. no local browser); the callback is completed in the background.", }), ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index febbe0d0bdc7..4dd634081845 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -332,6 +332,7 @@ mcpTest.instance( connectSucceedsImmediately = true const result = yield* mcp.authenticate("test-oauth-connect") + if (!("status" in result)) throw new Error("expected a Status result") expect(result.status).toBe("connected") const after = yield* mcp.status() @@ -341,6 +342,42 @@ mcpTest.instance( { config: config("test-oauth-connect") }, ) +mcpTest.instance( + "authenticate() returns the URL immediately under OPENCODE_PUBLIC_URL and finishes auth in the background", + () => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + delete process.env.OPENCODE_PUBLIC_URL + }), + ) + const mcp = yield* MCP.Service + const name = "test-remote-web-auth" + + process.env.OPENCODE_PUBLIC_URL = "https://opencode.example.com" + + const result = yield* mcp.authenticate(name) + if (!("authorizationUrl" in result)) throw new Error("expected an authorizationUrl result") + expect(result.authorizationUrl).toContain("https://auth.example.com/authorize") + + // Auth is still pending — the callback hasn't landed yet. + expect((yield* mcp.status())[name]?.status).toBe("needs_auth") + + // Simulate the callback landing (what the /mcp/oauth/callback route does). + connectSucceedsImmediately = true + McpOAuthCallback.resolveFromExternal("test-code", result.oauthState) + + // The rest (finishAuth) runs in a detached background fiber; poll for it. + let status = (yield* mcp.status())[name]?.status + for (let i = 0; i < 50 && status !== "connected"; i++) { + yield* Effect.sleep("10 millis") + status = (yield* mcp.status())[name]?.status + } + expect(status).toBe("connected") + }), + { config: config("test-remote-web-auth") }, +) + mcpTest.instance( "authenticate() connects a resource-only server without listing tools", () => @@ -358,6 +395,7 @@ mcpTest.instance( serverCapabilities = { resources: {} } const result = yield* mcp.authenticate("test-oauth-resources") + if (!("status" in result)) throw new Error("expected a Status result") expect(result.status).toBe("connected") expect(listToolsCalls).toBe(0) expect(Object.keys(yield* mcp.resources())).toEqual(["test-oauth-resources:docs://readme"]) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..b156e8bf9f55 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2359,7 +2359,7 @@ export class Auth2 extends HeyApiClient { /** * Authenticate MCP OAuth * - * Start OAuth flow and wait for callback (opens browser). + * Start OAuth flow. Returns the authorization URL immediately if the client must open it (e.g. no local browser); the callback is completed in the background. */ public authenticate( parameters: { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5e067f3afb23..1e35e95aa65e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8622,9 +8622,14 @@ export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAu export type McpAuthAuthenticateResponses = { /** - * OAuth authentication completed + * OAuth authentication completed, or started (authorizationUrl) when it must be opened client-side */ - 200: McpStatus + 200: + | McpStatus + | { + authorizationUrl: string + oauthState: string + } } export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses]