Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
9 changes: 4 additions & 5 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,19 +720,18 @@ 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
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
.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,
Expand Down
34 changes: 26 additions & 8 deletions packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const HTML_SUCCESS = `<!DOCTYPE html>
</body>
</html>`

function escapeHtml(str: string): string {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}

const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
Expand All @@ -39,7 +43,7 @@ const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
<div class="error">${escapeHtml(error)}</div>
</div>
</body>
</html>`
Expand All @@ -52,21 +56,34 @@ interface PendingAuth {

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

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

export async function ensureRunning(): Promise<void> {
if (server) return
export async function ensureRunning(opts?: { callbackHost?: string }): Promise<void> {
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)

Expand Down Expand Up @@ -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<string> {
Expand All @@ -158,10 +176,10 @@ export namespace McpOAuthCallback {
}
}

export async function isPortInUse(): Promise<boolean> {
export async function isPortInUse(host: string = "127.0.0.1"): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
hostname: host,
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
Expand Down
36 changes: 36 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
})
})
68 changes: 68 additions & 0 deletions packages/opencode/test/mcp/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mock> }

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()
})
})
21 changes: 21 additions & 0 deletions packages/web/src/content/docs/mcp-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down