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
6 changes: 6 additions & 0 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -598,13 +599,18 @@ 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,
{
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 @@ -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({
Expand Down
29 changes: 20 additions & 9 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,13 +294,16 @@ export namespace MCP {
let authProvider: McpOAuthProvider | undefined

if (!oauthDisabled) {
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)

authProvider = new McpOAuthProvider(
key,
mcp.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -714,6 +719,7 @@ export namespace MCP {
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async (url) => {
Expand Down Expand Up @@ -742,6 +748,7 @@ export namespace MCP {
pendingOAuthTransports.set(mcpName, transport)
return { authorizationUrl: capturedUrl.toString() }
}

throw error
}
}
Expand All @@ -751,9 +758,9 @@ export namespace MCP {
* Opens the browser and waits for callback.
*/
export async function authenticate(mcpName: string): Promise<Status> {
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" }
Expand All @@ -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)
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 }
}
}
75 changes: 75 additions & 0 deletions packages/opencode/test/mcp/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})