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 e12f8f1250ea..35c5142346ec 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,6 +12,8 @@ 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 { consumeRecentlyOpened } from "./global-sync/mcp-auth-tracker" import { useGlobal } from "./global" import { ServerConnection, useServer } from "./server" import { type DraftTab, useTabs } from "./tabs" @@ -386,6 +388,30 @@ 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 + // 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}`, + 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/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 e574e20fbaac..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 @@ -891,6 +891,39 @@ const layer = Layer.effect( const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName) onAuthorization?.(result.authorizationUrl) + if (process.env.OPENCODE_PUBLIC_URL) { + // Running as a remote web server — no browser to open on the server. + // 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) + + 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) }), + ), + ), + ) + + return { authorizationUrl: result.authorizationUrl, oauthState: result.oauthState } + } + yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe( Effect.flatMap((subprocess) => Effect.callback((resume) => { 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/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/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:` 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]