Skip to content

Commit 40b275d

Browse files
authored
feat(mcp): add OAuth redirect URI configuration for MCP servers (anomalyco#7379)
1 parent e4a34be commit 40b275d

6 files changed

Lines changed: 151 additions & 19 deletions

File tree

packages/opencode/src/cli/cmd/mcp.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as prompts from "@clack/prompts"
66
import { UI } from "../ui"
77
import { MCP } from "../../mcp"
88
import { McpAuth } from "../../mcp/auth"
9+
import { McpOAuthCallback } from "../../mcp/oauth-callback"
910
import { McpOAuthProvider } from "../../mcp/oauth-provider"
1011
import { Config } from "../../config/config"
1112
import { Instance } from "../../project/instance"
@@ -682,13 +683,18 @@ export const McpDebugCommand = cmd({
682683

683684
// Try to discover OAuth metadata
684685
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
686+
687+
// Start callback server
688+
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
689+
685690
const authProvider = new McpOAuthProvider(
686691
serverName,
687692
serverConfig.url,
688693
{
689694
clientId: oauthConfig?.clientId,
690695
clientSecret: oauthConfig?.clientSecret,
691696
scope: oauthConfig?.scope,
697+
redirectUri: oauthConfig?.redirectUri,
692698
},
693699
{
694700
onRedirect: async () => {},

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,10 @@ export namespace Config {
433433
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
434434
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
435435
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
436+
redirectUri: z
437+
.string()
438+
.optional()
439+
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
436440
})
437441
.strict()
438442
.meta({

packages/opencode/src/mcp/index.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,16 @@ export namespace MCP {
308308
let authProvider: McpOAuthProvider | undefined
309309

310310
if (!oauthDisabled) {
311+
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
312+
311313
authProvider = new McpOAuthProvider(
312314
key,
313315
mcp.url,
314316
{
315317
clientId: oauthConfig?.clientId,
316318
clientSecret: oauthConfig?.clientSecret,
317319
scope: oauthConfig?.scope,
320+
redirectUri: oauthConfig?.redirectUri,
318321
},
319322
{
320323
onRedirect: async (url) => {
@@ -344,6 +347,7 @@ export namespace MCP {
344347

345348
let lastError: Error | undefined
346349
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
350+
347351
for (const { name, transport } of transports) {
348352
try {
349353
const client = new Client({
@@ -570,7 +574,8 @@ export namespace MCP {
570574

571575
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
572576
// Only include tools from connected MCPs (skip disabled ones)
573-
if (s.status[clientName]?.status !== "connected") {
577+
const clientStatus = s.status[clientName]?.status
578+
if (clientStatus !== "connected") {
574579
continue
575580
}
576581

@@ -720,8 +725,10 @@ export namespace MCP {
720725
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
721726
}
722727

723-
// Start the callback server
724-
await McpOAuthCallback.ensureRunning()
728+
// OAuth config is optional - if not provided, we'll use auto-discovery
729+
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
730+
731+
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
725732

726733
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
727734
// The SDK will call provider.state() to read this value
@@ -731,8 +738,6 @@ export namespace MCP {
731738
await McpAuth.updateOAuthState(mcpName, oauthState)
732739

733740
// Create a new auth provider for this flow
734-
// OAuth config is optional - if not provided, we'll use auto-discovery
735-
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
736741
let capturedUrl: URL | undefined
737742
const authProvider = new McpOAuthProvider(
738743
mcpName,
@@ -741,6 +746,7 @@ export namespace MCP {
741746
clientId: oauthConfig?.clientId,
742747
clientSecret: oauthConfig?.clientSecret,
743748
scope: oauthConfig?.scope,
749+
redirectUri: oauthConfig?.redirectUri,
744750
},
745751
{
746752
onRedirect: async (url) => {
@@ -769,6 +775,7 @@ export namespace MCP {
769775
pendingOAuthTransports.set(mcpName, transport)
770776
return { authorizationUrl: capturedUrl.toString() }
771777
}
778+
772779
throw error
773780
}
774781
}
@@ -778,9 +785,9 @@ export namespace MCP {
778785
* Opens the browser and waits for callback.
779786
*/
780787
export async function authenticate(mcpName: string): Promise<Status> {
781-
const { authorizationUrl } = await startAuth(mcpName)
788+
const result = await startAuth(mcpName)
782789

783-
if (!authorizationUrl) {
790+
if (!result.authorizationUrl) {
784791
// Already authenticated
785792
const s = await state()
786793
return s.status[mcpName] ?? { status: "connected" }
@@ -794,9 +801,9 @@ export namespace MCP {
794801

795802
// The SDK has already added the state parameter to the authorization URL
796803
// We just need to open the browser
797-
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
804+
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState })
798805
try {
799-
const subprocess = await open(authorizationUrl)
806+
const subprocess = await open(result.authorizationUrl)
800807
// The open package spawns a detached process and returns immediately.
801808
// We need to listen for errors which fire asynchronously:
802809
// - "error" event: command not found (ENOENT)
@@ -819,7 +826,7 @@ export namespace MCP {
819826
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
820827
// Emit event so CLI can display the URL for manual opening
821828
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
822-
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
829+
Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl })
823830
}
824831

825832
// Wait for callback using the OAuth state parameter

packages/opencode/src/mcp/oauth-callback.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { Log } from "../util/log"
2-
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
2+
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
33

44
const log = Log.create({ service: "mcp.oauth-callback" })
55

6+
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
7+
let currentPort = OAUTH_CALLBACK_PORT
8+
let currentPath = OAUTH_CALLBACK_PATH
9+
610
const HTML_SUCCESS = `<!DOCTYPE html>
711
<html>
812
<head>
@@ -56,21 +60,33 @@ export namespace McpOAuthCallback {
5660

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

59-
export async function ensureRunning(): Promise<void> {
63+
export async function ensureRunning(redirectUri?: string): Promise<void> {
64+
// Parse the redirect URI to get port and path (uses defaults if not provided)
65+
const { port, path } = parseRedirectUri(redirectUri)
66+
67+
// If server is running on a different port/path, stop it first
68+
if (server && (currentPort !== port || currentPath !== path)) {
69+
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
70+
await stop()
71+
}
72+
6073
if (server) return
6174

62-
const running = await isPortInUse()
75+
const running = await isPortInUse(port)
6376
if (running) {
64-
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
77+
log.info("oauth callback server already running on another instance", { port })
6578
return
6679
}
6780

81+
currentPort = port
82+
currentPath = path
83+
6884
server = Bun.serve({
69-
port: OAUTH_CALLBACK_PORT,
85+
port: currentPort,
7086
fetch(req) {
7187
const url = new URL(req.url)
7288

73-
if (url.pathname !== OAUTH_CALLBACK_PATH) {
89+
if (url.pathname !== currentPath) {
7490
return new Response("Not found", { status: 404 })
7591
}
7692

@@ -133,7 +149,7 @@ export namespace McpOAuthCallback {
133149
},
134150
})
135151

136-
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
152+
log.info("oauth callback server started", { port: currentPort, path: currentPath })
137153
}
138154

139155
export function waitForCallback(oauthState: string): Promise<string> {
@@ -158,11 +174,11 @@ export namespace McpOAuthCallback {
158174
}
159175
}
160176

161-
export async function isPortInUse(): Promise<boolean> {
177+
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
162178
return new Promise((resolve) => {
163179
Bun.connect({
164180
hostname: "127.0.0.1",
165-
port: OAUTH_CALLBACK_PORT,
181+
port,
166182
socket: {
167183
open(socket) {
168184
socket.end()

packages/opencode/src/mcp/oauth-provider.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
1717
clientId?: string
1818
clientSecret?: string
1919
scope?: string
20+
redirectUri?: string
2021
}
2122

2223
export interface McpOAuthCallbacks {
@@ -32,6 +33,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
3233
) {}
3334

3435
get redirectUrl(): string {
36+
// Use configured redirectUri if provided, otherwise use OpenCode defaults
37+
if (this.config.redirectUri) {
38+
return this.config.redirectUri
39+
}
3540
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
3641
}
3742

@@ -152,3 +157,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
152157
}
153158

154159
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
160+
161+
/**
162+
* Parse a redirect URI to extract port and path for the callback server.
163+
* Returns defaults if the URI can't be parsed.
164+
*/
165+
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
166+
if (!redirectUri) {
167+
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
168+
}
169+
170+
try {
171+
const url = new URL(redirectUri)
172+
const port = url.port ? parseInt(url.port, 10) : (url.protocol === "https:" ? 443 : 80)
173+
const path = url.pathname || OAUTH_CALLBACK_PATH
174+
return { port, path }
175+
} catch {
176+
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
177+
}
178+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { test, expect, describe, afterEach } from "bun:test"
2+
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
3+
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
4+
5+
describe("McpOAuthCallback.ensureRunning", () => {
6+
afterEach(async () => {
7+
await McpOAuthCallback.stop()
8+
})
9+
10+
test("starts server with default config when no redirectUri provided", async () => {
11+
await McpOAuthCallback.ensureRunning()
12+
expect(McpOAuthCallback.isRunning()).toBe(true)
13+
})
14+
15+
test("starts server with custom redirectUri", async () => {
16+
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
17+
expect(McpOAuthCallback.isRunning()).toBe(true)
18+
})
19+
20+
test("is idempotent when called with same redirectUri", async () => {
21+
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback")
22+
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18001/callback")
23+
expect(McpOAuthCallback.isRunning()).toBe(true)
24+
})
25+
26+
test("restarts server when redirectUri changes", async () => {
27+
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18002/path1")
28+
expect(McpOAuthCallback.isRunning()).toBe(true)
29+
30+
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18003/path2")
31+
expect(McpOAuthCallback.isRunning()).toBe(true)
32+
})
33+
34+
test("isRunning returns false when not started", async () => {
35+
expect(McpOAuthCallback.isRunning()).toBe(false)
36+
})
37+
38+
test("isRunning returns false after stop", async () => {
39+
await McpOAuthCallback.ensureRunning()
40+
await McpOAuthCallback.stop()
41+
expect(McpOAuthCallback.isRunning()).toBe(false)
42+
})
43+
})
44+
45+
describe("parseRedirectUri", () => {
46+
test("returns defaults when no URI provided", () => {
47+
const result = parseRedirectUri()
48+
expect(result.port).toBe(19876)
49+
expect(result.path).toBe("/mcp/oauth/callback")
50+
})
51+
52+
test("parses port and path from URI", () => {
53+
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
54+
expect(result.port).toBe(8080)
55+
expect(result.path).toBe("/oauth/callback")
56+
})
57+
58+
test("defaults to port 80 for http without explicit port", () => {
59+
const result = parseRedirectUri("http://127.0.0.1/callback")
60+
expect(result.port).toBe(80)
61+
expect(result.path).toBe("/callback")
62+
})
63+
64+
test("defaults to port 443 for https without explicit port", () => {
65+
const result = parseRedirectUri("https://127.0.0.1/callback")
66+
expect(result.port).toBe(443)
67+
expect(result.path).toBe("/callback")
68+
})
69+
70+
test("returns defaults for invalid URI", () => {
71+
const result = parseRedirectUri("not-a-valid-url")
72+
expect(result.port).toBe(19876)
73+
expect(result.path).toBe("/mcp/oauth/callback")
74+
})
75+
})

0 commit comments

Comments
 (0)