Skip to content

Commit bb069b3

Browse files
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
1 parent b4d0090 commit bb069b3

File tree

6 files changed

+160
-13
lines changed

6 files changed

+160
-13
lines changed

packages/opencode/src/config/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,11 @@ export namespace Config {
542542
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
543543
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
544544
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
545+
callbackHost: z
546+
.string()
547+
.min(1)
548+
.optional()
549+
.describe("Host address to bind the OAuth callback server to (e.g. '0.0.0.0' for WSL2/Docker)"),
545550
})
546551
.strict()
547552
.meta({

packages/opencode/src/mcp/index.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -726,19 +726,18 @@ export namespace MCP {
726726
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
727727
}
728728

729+
// OAuth config is optional - if not provided, we'll use auto-discovery
730+
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
731+
729732
// Start the callback server
730-
await McpOAuthCallback.ensureRunning()
733+
await McpOAuthCallback.ensureRunning({ callbackHost: oauthConfig?.callbackHost })
731734

732735
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
733736
// The SDK will call provider.state() to read this value
734737
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
735738
.map((b) => b.toString(16).padStart(2, "0"))
736739
.join("")
737740
await McpAuth.updateOAuthState(mcpName, oauthState)
738-
739-
// Create a new auth provider for this flow
740-
// OAuth config is optional - if not provided, we'll use auto-discovery
741-
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
742741
let capturedUrl: URL | undefined
743742
const authProvider = new McpOAuthProvider(
744743
mcpName,

packages/opencode/src/mcp/oauth-callback.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ const HTML_SUCCESS = `<!DOCTYPE html>
2323
</body>
2424
</html>`
2525

26+
function escapeHtml(str: string): string {
27+
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
28+
}
29+
2630
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
2731
<html>
2832
<head>
@@ -39,7 +43,7 @@ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
3943
<div class="container">
4044
<h1>Authorization Failed</h1>
4145
<p>An error occurred during authorization.</p>
42-
<div class="error">${error}</div>
46+
<div class="error">${escapeHtml(error)}</div>
4347
</div>
4448
</body>
4549
</html>`
@@ -52,21 +56,34 @@ interface PendingAuth {
5256

5357
export namespace McpOAuthCallback {
5458
let server: ReturnType<typeof Bun.serve> | undefined
59+
let currentHost: string | undefined
5560
const pendingAuths = new Map<string, PendingAuth>()
5661

5762
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
5863

59-
export async function ensureRunning(): Promise<void> {
60-
if (server) return
64+
export async function ensureRunning(opts?: { callbackHost?: string }): Promise<void> {
65+
const callbackHost = opts?.callbackHost
66+
67+
if (server && callbackHost === currentHost) return
68+
if (server && callbackHost !== currentHost) {
69+
log.info("restarting oauth callback server with new host", { oldHost: currentHost, newHost: callbackHost })
70+
server.stop()
71+
server = undefined
72+
}
6173

62-
const running = await isPortInUse()
74+
const checkHost = !callbackHost || callbackHost === "0.0.0.0" ? "127.0.0.1" : callbackHost
75+
const running = await isPortInUse(checkHost)
6376
if (running) {
64-
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
77+
log.info("oauth callback server already running on another instance", {
78+
port: OAUTH_CALLBACK_PORT,
79+
host: checkHost,
80+
})
6581
return
6682
}
6783

6884
server = Bun.serve({
6985
port: OAUTH_CALLBACK_PORT,
86+
...(callbackHost ? { hostname: callbackHost } : {}),
7087
fetch(req) {
7188
const url = new URL(req.url)
7289

@@ -133,7 +150,8 @@ export namespace McpOAuthCallback {
133150
},
134151
})
135152

136-
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
153+
currentHost = callbackHost
154+
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT, host: callbackHost ?? "default" })
137155
}
138156

139157
export function waitForCallback(oauthState: string): Promise<string> {
@@ -158,10 +176,10 @@ export namespace McpOAuthCallback {
158176
}
159177
}
160178

161-
export async function isPortInUse(): Promise<boolean> {
179+
export async function isPortInUse(host: string = "127.0.0.1"): Promise<boolean> {
162180
return new Promise((resolve) => {
163181
Bun.connect({
164-
hostname: "127.0.0.1",
182+
hostname: host,
165183
port: OAUTH_CALLBACK_PORT,
166184
socket: {
167185
open(socket) {

packages/opencode/test/config/config.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,3 +1884,39 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
18841884
}
18851885
})
18861886
})
1887+
1888+
describe("MCP OAuth callbackHost validation", () => {
1889+
test("accepts valid callbackHost", () => {
1890+
const result = Config.McpOAuth.safeParse({ callbackHost: "0.0.0.0" })
1891+
expect(result.success).toBe(true)
1892+
if (result.success) expect(result.data.callbackHost).toBe("0.0.0.0")
1893+
})
1894+
1895+
test("accepts 127.0.0.1", () => {
1896+
const result = Config.McpOAuth.safeParse({ callbackHost: "127.0.0.1" })
1897+
expect(result.success).toBe(true)
1898+
})
1899+
1900+
test("rejects empty string", () => {
1901+
const result = Config.McpOAuth.safeParse({ callbackHost: "" })
1902+
expect(result.success).toBe(false)
1903+
})
1904+
1905+
test("allows omitting callbackHost", () => {
1906+
const result = Config.McpOAuth.safeParse({})
1907+
expect(result.success).toBe(true)
1908+
if (result.success) expect(result.data.callbackHost).toBeUndefined()
1909+
})
1910+
1911+
test("works with other oauth fields", () => {
1912+
const result = Config.McpOAuth.safeParse({
1913+
clientId: "my-client",
1914+
callbackHost: "0.0.0.0",
1915+
})
1916+
expect(result.success).toBe(true)
1917+
if (result.success) {
1918+
expect(result.data.clientId).toBe("my-client")
1919+
expect(result.data.callbackHost).toBe("0.0.0.0")
1920+
}
1921+
})
1922+
})
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test"
2+
3+
describe("McpOAuthCallback ensureRunning behavior", () => {
4+
let originalServe: typeof Bun.serve
5+
let originalConnect: typeof Bun.connect
6+
let serveCallArgs: Array<{ hostname?: string; port?: number }>
7+
let mockServer: { stop: ReturnType<typeof mock> }
8+
9+
beforeEach(() => {
10+
serveCallArgs = []
11+
mockServer = { stop: mock(() => {}) }
12+
originalServe = Bun.serve
13+
originalConnect = Bun.connect
14+
15+
Bun.serve = mock((opts: any) => {
16+
serveCallArgs.push({ hostname: opts.hostname, port: opts.port })
17+
return mockServer as any
18+
}) as any
19+
20+
Bun.connect = mock(() => Promise.reject(new Error("Connection refused"))) as any
21+
})
22+
23+
afterEach(async () => {
24+
Bun.serve = originalServe
25+
Bun.connect = originalConnect
26+
const mod = await import("../../src/mcp/oauth-callback")
27+
mod.McpOAuthCallback.stop()
28+
})
29+
30+
test("passes hostname to Bun.serve when callbackHost is set", async () => {
31+
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
32+
await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" })
33+
34+
expect(serveCallArgs.length).toBe(1)
35+
expect(serveCallArgs[0].hostname).toBe("127.0.0.1")
36+
})
37+
38+
test("does not pass hostname when callbackHost is unset", async () => {
39+
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
40+
await McpOAuthCallback.ensureRunning()
41+
42+
expect(serveCallArgs.length).toBe(1)
43+
expect(serveCallArgs[0].hostname).toBeUndefined()
44+
})
45+
46+
test("restarts server when callbackHost changes", async () => {
47+
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
48+
49+
await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" })
50+
expect(serveCallArgs.length).toBe(1)
51+
expect(mockServer.stop).not.toHaveBeenCalled()
52+
53+
await McpOAuthCallback.ensureRunning({ callbackHost: "0.0.0.0" })
54+
expect(serveCallArgs.length).toBe(2)
55+
expect(mockServer.stop).toHaveBeenCalled()
56+
expect(serveCallArgs[1].hostname).toBe("0.0.0.0")
57+
})
58+
59+
test("does not restart when callbackHost unchanged", async () => {
60+
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
61+
62+
await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" })
63+
await McpOAuthCallback.ensureRunning({ callbackHost: "127.0.0.1" })
64+
65+
expect(serveCallArgs.length).toBe(1)
66+
expect(mockServer.stop).not.toHaveBeenCalled()
67+
})
68+
})

packages/web/src/content/docs/mcp-servers.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ If you want to disable automatic OAuth for a server (e.g., for servers that use
272272
| `clientId` | String | OAuth client ID. If not provided, dynamic client registration will be attempted. |
273273
| `clientSecret` | String | OAuth client secret, if required by the authorization server. |
274274
| `scope` | String | OAuth scopes to request during authorization. |
275+
| `callbackHost` | String | Bind address for the callback server. See [Debugging](#debugging). |
275276

276277
#### Debugging
277278

@@ -287,6 +288,26 @@ opencode mcp debug my-oauth-server
287288

288289
The `mcp debug` command shows the current auth status, tests HTTP connectivity, and attempts the OAuth discovery flow.
289290

291+
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`).
292+
293+
:::caution
294+
Binding to `0.0.0.0` exposes the callback listener on your network, not just localhost. Use only when needed.
295+
:::
296+
297+
`callbackHost` only affects the bind address; it does not change `redirectUri`.
298+
299+
```json title="opencode.json" {4}
300+
{
301+
"mcp": {
302+
"my-server": {
303+
"oauth": { "callbackHost": "0.0.0.0" }
304+
}
305+
}
306+
}
307+
```
308+
309+
In containers, you may also need to publish/forward port `19876` (or your configured redirect port) to the host.
310+
290311
---
291312

292313
## Manage

0 commit comments

Comments
 (0)