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) + }) +})