diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts
index 95719215e324..29c0b96ff312 100644
--- a/packages/opencode/src/cli/cmd/mcp.ts
+++ b/packages/opencode/src/cli/cmd/mcp.ts
@@ -689,6 +689,7 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
+ redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 54ca94ae4d3f..74ac49483645 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -481,6 +481,10 @@ 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"),
+ redirectUri: z
+ .string()
+ .optional()
+ .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts
index 29e958fe3572..6ef41e2af18e 100644
--- a/packages/opencode/src/mcp/index.ts
+++ b/packages/opencode/src/mcp/index.ts
@@ -315,6 +315,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
+ redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
@@ -723,8 +724,11 @@ export namespace MCP {
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
}
- // Start the callback server
- await McpOAuthCallback.ensureRunning()
+ // 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 with custom redirectUri if configured
+ await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
// The SDK will call provider.state() to read this value
@@ -734,8 +738,6 @@ export namespace MCP {
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,
@@ -744,6 +746,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
+ redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts
index bb3b56f2e95f..a690ab5e3365 100644
--- a/packages/opencode/src/mcp/oauth-callback.ts
+++ b/packages/opencode/src/mcp/oauth-callback.ts
@@ -1,8 +1,12 @@
import { Log } from "../util/log"
-import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
+import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" })
+// Current callback server configuration (may differ from defaults if custom redirectUri is used)
+let currentPort = OAUTH_CALLBACK_PORT
+let currentPath = OAUTH_CALLBACK_PATH
+
const HTML_SUCCESS = `
@@ -56,21 +60,33 @@ export namespace McpOAuthCallback {
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
- export async function ensureRunning(): Promise {
+ export async function ensureRunning(redirectUri?: string): Promise {
+ // Parse the redirect URI to get port and path (uses defaults if not provided)
+ const { port, path } = parseRedirectUri(redirectUri)
+
+ // If server is running on a different port/path, stop it first
+ if (server && (currentPort !== port || currentPath !== path)) {
+ log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
+ await stop()
+ }
+
if (server) return
- const running = await isPortInUse()
+ const running = await isPortInUse(port)
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 })
return
}
+ currentPort = port
+ currentPath = path
+
server = Bun.serve({
- port: OAUTH_CALLBACK_PORT,
+ port: currentPort,
fetch(req) {
const url = new URL(req.url)
- if (url.pathname !== OAUTH_CALLBACK_PATH) {
+ if (url.pathname !== currentPath) {
return new Response("Not found", { status: 404 })
}
@@ -133,7 +149,7 @@ export namespace McpOAuthCallback {
},
})
- log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
+ log.info("oauth callback server started", { port: currentPort, path: currentPath })
}
export function waitForCallback(oauthState: string): Promise {
@@ -158,11 +174,11 @@ export namespace McpOAuthCallback {
}
}
- export async function isPortInUse(): Promise {
+ export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
- port: OAUTH_CALLBACK_PORT,
+ port,
socket: {
open(socket) {
socket.end()
diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts
index 35ead25e8beb..82bad60da339 100644
--- a/packages/opencode/src/mcp/oauth-provider.ts
+++ b/packages/opencode/src/mcp/oauth-provider.ts
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
+ redirectUri?: string
}
export interface McpOAuthCallbacks {
@@ -32,6 +33,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
) {}
get redirectUrl(): string {
+ // Use configured redirectUri if provided, otherwise use OpenCode defaults
+ if (this.config.redirectUri) {
+ return this.config.redirectUri
+ }
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
}
@@ -152,3 +157,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
+
+/**
+ * Parse a redirect URI to extract port and path for the callback server.
+ * Returns defaults if the URI can't be parsed.
+ */
+export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
+ if (!redirectUri) {
+ return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
+ }
+
+ try {
+ const url = new URL(redirectUri)
+ const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
+ const path = url.pathname || OAUTH_CALLBACK_PATH
+ return { port, path }
+ } catch {
+ return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
+ }
+}
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 000000000000..58a4fa8c86cc
--- /dev/null
+++ b/packages/opencode/test/mcp/oauth-callback.test.ts
@@ -0,0 +1,34 @@
+import { test, expect, describe, afterEach } from "bun:test"
+import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
+import { parseRedirectUri } from "../../src/mcp/oauth-provider"
+
+describe("parseRedirectUri", () => {
+ test("returns defaults when no URI provided", () => {
+ const result = parseRedirectUri()
+ expect(result.port).toBe(19876)
+ expect(result.path).toBe("/mcp/oauth/callback")
+ })
+
+ test("parses port and path from URI", () => {
+ const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
+ expect(result.port).toBe(8080)
+ expect(result.path).toBe("/oauth/callback")
+ })
+
+ test("returns defaults for invalid URI", () => {
+ const result = parseRedirectUri("not-a-valid-url")
+ expect(result.port).toBe(19876)
+ expect(result.path).toBe("/mcp/oauth/callback")
+ })
+})
+
+describe("McpOAuthCallback.ensureRunning", () => {
+ afterEach(async () => {
+ await McpOAuthCallback.stop()
+ })
+
+ test("starts server with custom redirectUri port and path", async () => {
+ await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
+ expect(McpOAuthCallback.isRunning()).toBe(true)
+ })
+})