diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d32..6be864acf50 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -435,6 +435,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 66843aedc11..2c927e55544 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -720,8 +720,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 @@ -729,10 +732,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 86cadca5d81..b7777f101bf 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1374,3 +1374,39 @@ describe("deduplicatePlugins", () => { }) }) }) + +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