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
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,7 @@ export const McpDebugCommand = cmd({
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 7 additions & 4 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -744,6 +746,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
Expand Down
34 changes: 25 additions & 9 deletions packages/opencode/src/mcp/oauth-callback.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!DOCTYPE html>
<html>
<head>
Expand Down Expand Up @@ -56,21 +60,33 @@ export namespace McpOAuthCallback {

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

export async function ensureRunning(): Promise<void> {
export async function ensureRunning(redirectUri?: string): Promise<void> {
// 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 })
}

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

export async function isPortInUse(): Promise<boolean> {
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
port,
socket: {
open(socket) {
socket.end()
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
redirectUri?: string
}

export interface McpOAuthCallbacks {
Expand All @@ -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}`
}

Expand Down Expand Up @@ -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 }
}
}
34 changes: 34 additions & 0 deletions packages/opencode/test/mcp/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading