diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index aaef75267df..9961d57df74 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -7,6 +7,7 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" +import { McpOAuthCallback } from "../../mcp/oauth-callback" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "../../config/config" import { Instance } from "../../project/instance" @@ -598,6 +599,10 @@ export const McpDebugCommand = cmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + + // Start callback server + await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + const authProvider = new McpOAuthProvider( serverName, serverConfig.url, @@ -605,6 +610,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 be234948424..19e7f3d5d43 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -363,6 +363,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 aca0c663152..18fd6a4d5b3 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -294,6 +294,8 @@ export namespace MCP { let authProvider: McpOAuthProvider | undefined if (!oauthDisabled) { + await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri) + authProvider = new McpOAuthProvider( key, mcp.url, @@ -301,6 +303,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -330,6 +333,7 @@ export namespace MCP { let lastError: Error | undefined const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT + for (const { name, transport } of transports) { try { const client = new Client({ @@ -546,7 +550,8 @@ export namespace MCP { for (const [clientName, client] of Object.entries(clientsSnapshot)) { // Only include tools from connected MCPs (skip disabled ones) - if (s.status[clientName]?.status !== "connected") { + const clientStatus = s.status[clientName]?.status + if (clientStatus !== "connected") { continue } @@ -693,8 +698,10 @@ 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 + + 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 @@ -704,8 +711,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, @@ -714,6 +719,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -742,6 +748,7 @@ export namespace MCP { pendingOAuthTransports.set(mcpName, transport) return { authorizationUrl: capturedUrl.toString() } } + throw error } } @@ -751,9 +758,9 @@ export namespace MCP { * Opens the browser and waits for callback. */ export async function authenticate(mcpName: string): Promise { - const { authorizationUrl } = await startAuth(mcpName) + const result = await startAuth(mcpName) - if (!authorizationUrl) { + if (!result.authorizationUrl) { // Already authenticated const s = await state() return s.status[mcpName] ?? { status: "connected" } @@ -767,8 +774,12 @@ export namespace MCP { // The SDK has already added the state parameter to the authorization URL // We just need to open the browser - log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState }) - await open(authorizationUrl) + log.info("opening browser for oauth", { + mcpName, + url: result.authorizationUrl, + state: oauthState, + }) + await open(result.authorizationUrl) // Wait for callback using the OAuth state parameter const code = await McpOAuthCallback.waitForCallback(oauthState) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index bb3b56f2e95..a690ab5e336 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 35ead25e8be..e1fd3acd374 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 00000000000..aa23f4dfb5d --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,75 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with default config when no redirectUri provided", async () => { + await McpOAuthCallback.ensureRunning() + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("starts server with custom redirectUri", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("is idempotent when called with same redirectUri", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("restarts server when redirectUri changes", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1") + expect(McpOAuthCallback.isRunning()).toBe(true) + + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) + + test("isRunning returns false when not started", async () => { + expect(McpOAuthCallback.isRunning()).toBe(false) + }) + + test("isRunning returns false after stop", async () => { + await McpOAuthCallback.ensureRunning() + await McpOAuthCallback.stop() + expect(McpOAuthCallback.isRunning()).toBe(false) + }) +}) + +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("defaults to port 80 for http without explicit port", () => { + const result = parseRedirectUri("http://127.0.0.1/callback") + expect(result.port).toBe(80) + expect(result.path).toBe("/callback") + }) + + test("defaults to port 443 for https without explicit port", () => { + const result = parseRedirectUri("https://127.0.0.1/callback") + expect(result.port).toBe(443) + expect(result.path).toBe("/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") + }) +})