Skip to content

Commit a7743e6

Browse files
egzerekram1-node
andauthored
feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent 5d3dba6 commit a7743e6

6 files changed

Lines changed: 95 additions & 12 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
688688
clientId: oauthConfig?.clientId,
689689
clientSecret: oauthConfig?.clientSecret,
690690
scope: oauthConfig?.scope,
691+
redirectUri: oauthConfig?.redirectUri,
691692
},
692693
{
693694
onRedirect: async () => {},

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,10 @@ export namespace Config {
399399
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
400400
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
401401
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
402+
redirectUri: z
403+
.string()
404+
.optional()
405+
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
402406
})
403407
.strict()
404408
.meta({

packages/opencode/src/mcp/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export namespace MCP {
286286
clientId: oauthConfig?.clientId,
287287
clientSecret: oauthConfig?.clientSecret,
288288
scope: oauthConfig?.scope,
289+
redirectUri: oauthConfig?.redirectUri,
289290
},
290291
{
291292
onRedirect: async (url) => {
@@ -716,13 +717,16 @@ export namespace MCP {
716717
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
717718
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
718719

719-
yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
720+
// OAuth config is optional - if not provided, we'll use auto-discovery
721+
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
722+
723+
// Start the callback server with custom redirectUri if configured
724+
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
720725

721726
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
722727
.map((b) => b.toString(16).padStart(2, "0"))
723728
.join("")
724729
yield* auth.updateOAuthState(mcpName, oauthState)
725-
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
726730
let capturedUrl: URL | undefined
727731
const authProvider = new McpOAuthProvider(
728732
mcpName,
@@ -731,6 +735,7 @@ export namespace MCP {
731735
clientId: oauthConfig?.clientId,
732736
clientSecret: oauthConfig?.clientSecret,
733737
scope: oauthConfig?.scope,
738+
redirectUri: oauthConfig?.redirectUri,
734739
},
735740
{
736741
onRedirect: async (url) => {

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

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { createConnection } from "net"
22
import { createServer } from "http"
33
import { Log } from "../util/log"
4-
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
4+
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
55

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

8+
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
9+
let currentPort = OAUTH_CALLBACK_PORT
10+
let currentPath = OAUTH_CALLBACK_PATH
11+
812
const HTML_SUCCESS = `<!DOCTYPE html>
913
<html>
1014
<head>
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
7175
}
7276

7377
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
74-
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
78+
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
7579

76-
if (url.pathname !== OAUTH_CALLBACK_PATH) {
80+
if (url.pathname !== currentPath) {
7781
res.writeHead(404)
7882
res.end("Not found")
7983
return
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
135139
res.end(HTML_SUCCESS)
136140
}
137141

138-
export async function ensureRunning(): Promise<void> {
142+
export async function ensureRunning(redirectUri?: string): Promise<void> {
143+
// Parse the redirect URI to get port and path (uses defaults if not provided)
144+
const { port, path } = parseRedirectUri(redirectUri)
145+
146+
// If server is running on a different port/path, stop it first
147+
if (server && (currentPort !== port || currentPath !== path)) {
148+
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
149+
await stop()
150+
}
151+
139152
if (server) return
140153

141-
const running = await isPortInUse()
154+
const running = await isPortInUse(port)
142155
if (running) {
143-
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
156+
log.info("oauth callback server already running on another instance", { port })
144157
return
145158
}
146159

160+
currentPort = port
161+
currentPath = path
162+
147163
server = createServer(handleRequest)
148164
await new Promise<void>((resolve, reject) => {
149-
server!.listen(OAUTH_CALLBACK_PORT, () => {
150-
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
165+
server!.listen(currentPort, () => {
166+
log.info("oauth callback server started", { port: currentPort, path: currentPath })
151167
resolve()
152168
})
153169
server!.on("error", reject)
@@ -182,9 +198,9 @@ export namespace McpOAuthCallback {
182198
}
183199
}
184200

185-
export async function isPortInUse(): Promise<boolean> {
201+
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
186202
return new Promise((resolve) => {
187-
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
203+
const socket = createConnection(port, "127.0.0.1")
188204
socket.on("connect", () => {
189205
socket.destroy()
190206
resolve(true)

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

Lines changed: 23 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,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
3233
) {}
3334

3435
get redirectUrl(): string {
36+
if (this.config.redirectUri) {
37+
return this.config.redirectUri
38+
}
3539
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
3640
}
3741

@@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
183187
}
184188

185189
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
190+
191+
/**
192+
* Parse a redirect URI to extract port and path for the callback server.
193+
* Returns defaults if the URI can't be parsed.
194+
*/
195+
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
196+
if (!redirectUri) {
197+
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
198+
}
199+
200+
try {
201+
const url = new URL(redirectUri)
202+
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
203+
const path = url.pathname || OAUTH_CALLBACK_PATH
204+
return { port, path }
205+
} catch {
206+
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
207+
}
208+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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("parseRedirectUri", () => {
6+
test("returns defaults when no URI provided", () => {
7+
const result = parseRedirectUri()
8+
expect(result.port).toBe(19876)
9+
expect(result.path).toBe("/mcp/oauth/callback")
10+
})
11+
12+
test("parses port and path from URI", () => {
13+
const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback")
14+
expect(result.port).toBe(8080)
15+
expect(result.path).toBe("/oauth/callback")
16+
})
17+
18+
test("returns defaults for invalid URI", () => {
19+
const result = parseRedirectUri("not-a-valid-url")
20+
expect(result.port).toBe(19876)
21+
expect(result.path).toBe("/mcp/oauth/callback")
22+
})
23+
})
24+
25+
describe("McpOAuthCallback.ensureRunning", () => {
26+
afterEach(async () => {
27+
await McpOAuthCallback.stop()
28+
})
29+
30+
test("starts server with custom redirectUri port and path", async () => {
31+
await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback")
32+
expect(McpOAuthCallback.isRunning()).toBe(true)
33+
})
34+
})

0 commit comments

Comments
 (0)