From bb069b3ead692ccaaa424b37c860af5d321dd024 Mon Sep 17 00:00:00 2001 From: Chris Olszewski Date: Fri, 16 Jan 2026 23:47:14 -0500 Subject: [PATCH] feat(mcp): add oauth callbackHost config Add optional callbackHost field to MCP OAuth config that controls the bind address for the OAuth callback server. Useful for WSL2, Docker, and devcontainer environments where the default loopback bind is not reachable from the host browser. - Add callbackHost to McpOAuth schema with .min(1) validation - Update ensureRunning(opts?) with backward-compatible signature - Track currentHost and restart server on host change - Normalize 0.0.0.0 to 127.0.0.1 for port-in-use checks - Fix HTML injection in error page (escapeHtml) - Add config validation tests - Document in mcp-servers.mdx Debugging section --- packages/opencode/src/config/config.ts | 5 ++ packages/opencode/src/mcp/index.ts | 9 ++- packages/opencode/src/mcp/oauth-callback.ts | 34 +++++++--- packages/opencode/test/config/config.test.ts | 36 ++++++++++ .../opencode/test/mcp/oauth-callback.test.ts | 68 +++++++++++++++++++ packages/web/src/content/docs/mcp-servers.mdx | 21 ++++++ 6 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/mcp/oauth-callback.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28aea4d6777..042ee853316 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -542,6 +542,11 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), + callbackHost: z + .string() + .min(1) + .optional() + .describe("Host address to bind the OAuth callback server to (e.g. '0.0.0.0' for WSL2/Docker)"), }) .strict() .meta({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3c29fe03d30..4197ac2ec4e 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -726,8 +726,11 @@ export namespace MCP { throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) } + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + // Start the callback server - await McpOAuthCallback.ensureRunning() + await McpOAuthCallback.ensureRunning({ callbackHost: oauthConfig?.callbackHost }) // Generate and store a cryptographically secure state parameter BEFORE creating the provider // The SDK will call provider.state() to read this value @@ -735,10 +738,6 @@ export namespace MCP { .map((b) => b.toString(16).padStart(2, "0")) .join("") await McpAuth.updateOAuthState(mcpName, oauthState) - - // Create a new auth provider for this flow - // OAuth config is optional - if not provided, we'll use auto-discovery - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index bb3b56f2e95..dd57ca8ac51 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -23,6 +23,10 @@ const HTML_SUCCESS = ` ` +function escapeHtml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) +} + const HTML_ERROR = (error: string) => ` @@ -39,7 +43,7 @@ const HTML_ERROR = (error: string) => `

Authorization Failed

An error occurred during authorization.

-
${error}
+
${escapeHtml(error)}
` @@ -52,21 +56,34 @@ interface PendingAuth { export namespace McpOAuthCallback { let server: ReturnType | undefined + let currentHost: string | undefined const pendingAuths = new Map() const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes - export async function ensureRunning(): Promise { - if (server) return + export async function ensureRunning(opts?: { callbackHost?: string }): Promise { + const callbackHost = opts?.callbackHost + + if (server && callbackHost === currentHost) return + if (server && callbackHost !== currentHost) { + log.info("restarting oauth callback server with new host", { oldHost: currentHost, newHost: callbackHost }) + server.stop() + server = undefined + } - const running = await isPortInUse() + const checkHost = !callbackHost || callbackHost === "0.0.0.0" ? "127.0.0.1" : callbackHost + const running = await isPortInUse(checkHost) if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server already running on another instance", { + port: OAUTH_CALLBACK_PORT, + host: checkHost, + }) return } server = Bun.serve({ port: OAUTH_CALLBACK_PORT, + ...(callbackHost ? { hostname: callbackHost } : {}), fetch(req) { const url = new URL(req.url) @@ -133,7 +150,8 @@ export namespace McpOAuthCallback { }, }) - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + currentHost = callbackHost + log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT, host: callbackHost ?? "default" }) } export function waitForCallback(oauthState: string): Promise { @@ -158,10 +176,10 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { + export async function isPortInUse(host: string = "127.0.0.1"): Promise { return new Promise((resolve) => { Bun.connect({ - hostname: "127.0.0.1", + hostname: host, port: OAUTH_CALLBACK_PORT, socket: { open(socket) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f245dc3493d..5d1a33d021a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1884,3 +1884,39 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { } }) }) + +describe("MCP OAuth callbackHost validation", () => { + test("accepts valid callbackHost", () => { + const result = Config.McpOAuth.safeParse({ callbackHost: "0.0.0.0" }) + expect(result.success).toBe(true) + if (result.success) expect(result.data.callbackHost).toBe("0.0.0.0") + }) + + test("accepts 127.0.0.1", () => { + const result = Config.McpOAuth.safeParse({ callbackHost: "127.0.0.1" }) + expect(result.success).toBe(true) + }) + + test("rejects empty string", () => { + const result = Config.McpOAuth.safeParse({ callbackHost: "" }) + expect(result.success).toBe(false) + }) + + test("allows omitting callbackHost", () => { + const result = Config.McpOAuth.safeParse({}) + expect(result.success).toBe(true) + if (result.success) expect(result.data.callbackHost).toBeUndefined() + }) + + test("works with other oauth fields", () => { + const result = Config.McpOAuth.safeParse({ + clientId: "my-client", + callbackHost: "0.0.0.0", + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.clientId).toBe("my-client") + expect(result.data.callbackHost).toBe("0.0.0.0") + } + }) +}) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 00000000000..2023435586f --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,68 @@ +import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test" + +describe("McpOAuthCallback ensureRunning behavior", () => { + let originalServe: typeof Bun.serve + let originalConnect: typeof Bun.connect + let serveCallArgs: Array<{ hostname?: string; port?: number }> + let mockServer: { stop: ReturnType } + + beforeEach(() => { + serveCallArgs = [] + mockServer = { stop: mock(() => {}) } + originalServe = Bun.serve + originalConnect = Bun.connect + + Bun.serve = mock((opts: any) => { + serveCallArgs.push({ hostname: opts.hostname, port: opts.port }) + return mockServer as any + }) as any + + Bun.connect = mock(() => Promise.reject(new Error("Connection refused"))) as any + }) + + afterEach(async () => { + Bun.serve = originalServe + Bun.connect = originalConnect + const mod = await import("../../src/mcp/oauth-callback") + mod.McpOAuthCallback.stop() + }) + + test("passes hostname to Bun.serve when callbackHost is set", async () => { + const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") + await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" }) + + expect(serveCallArgs.length).toBe(1) + expect(serveCallArgs[0].hostname).toBe("127.0.0.1") + }) + + test("does not pass hostname when callbackHost is unset", async () => { + const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") + await McpOAuthCallback.ensureRunning() + + expect(serveCallArgs.length).toBe(1) + expect(serveCallArgs[0].hostname).toBeUndefined() + }) + + test("restarts server when callbackHost changes", async () => { + const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") + + await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" }) + expect(serveCallArgs.length).toBe(1) + expect(mockServer.stop).not.toHaveBeenCalled() + + await McpOAuthCallback.ensureRunning({ callbackHost: "0.0.0.0" }) + expect(serveCallArgs.length).toBe(2) + expect(mockServer.stop).toHaveBeenCalled() + expect(serveCallArgs[1].hostname).toBe("0.0.0.0") + }) + + test("does not restart when callbackHost unchanged", async () => { + const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") + + await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" }) + await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" }) + + expect(serveCallArgs.length).toBe(1) + expect(mockServer.stop).not.toHaveBeenCalled() + }) +}) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 1b3006b1cbf..7c2de9b2939 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -272,6 +272,7 @@ If you want to disable automatic OAuth for a server (e.g., for servers that use | `clientId` | String | OAuth client ID. If not provided, dynamic client registration will be attempted. | | `clientSecret` | String | OAuth client secret, if required by the authorization server. | | `scope` | String | OAuth scopes to request during authorization. | +| `callbackHost` | String | Bind address for the callback server. See [Debugging](#debugging). | #### Debugging @@ -287,6 +288,26 @@ opencode mcp debug my-oauth-server The `mcp debug` command shows the current auth status, tests HTTP connectivity, and attempts the OAuth discovery flow. +If you're running OpenCode in WSL2, Docker, or a devcontainer and OAuth callbacks fail, the callback server may not be reachable from your host browser. Set `callbackHost` to an address your host can reach (commonly `0.0.0.0`). + +:::caution +Binding to `0.0.0.0` exposes the callback listener on your network, not just localhost. Use only when needed. +::: + +`callbackHost` only affects the bind address; it does not change `redirectUri`. + +```json title="opencode.json" {4} +{ + "mcp": { + "my-server": { + "oauth": { "callbackHost": "0.0.0.0" } + } + } +} +``` + +In containers, you may also need to publish/forward port `19876` (or your configured redirect port) to the host. + --- ## Manage