Explorar el Código

feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)

Co-authored-by: Aiden Cline <[email protected]>
Aleksandr Lossenko hace 1 semana
padre
commit
a7743e6467

+ 1 - 0
packages/opencode/src/cli/cmd/mcp.ts

@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
                 clientId: oauthConfig?.clientId,
                 clientSecret: oauthConfig?.clientSecret,
                 scope: oauthConfig?.scope,
+                redirectUri: oauthConfig?.redirectUri,
               },
               {
                 onRedirect: async () => {},

+ 4 - 0
packages/opencode/src/config/config.ts

@@ -399,6 +399,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({

+ 7 - 2
packages/opencode/src/mcp/index.ts

@@ -286,6 +286,7 @@ export namespace MCP {
               clientId: oauthConfig?.clientId,
               clientSecret: oauthConfig?.clientSecret,
               scope: oauthConfig?.scope,
+              redirectUri: oauthConfig?.redirectUri,
             },
             {
               onRedirect: async (url) => {
@@ -716,13 +717,16 @@ export namespace MCP {
         if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
         if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
 
-        yield* Effect.promise(() => 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
+        yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
 
         const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
           .map((b) => b.toString(16).padStart(2, "0"))
           .join("")
         yield* auth.updateOAuthState(mcpName, oauthState)
-        const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
         let capturedUrl: URL | undefined
         const authProvider = new McpOAuthProvider(
           mcpName,
@@ -731,6 +735,7 @@ export namespace MCP {
             clientId: oauthConfig?.clientId,
             clientSecret: oauthConfig?.clientSecret,
             scope: oauthConfig?.scope,
+            redirectUri: oauthConfig?.redirectUri,
           },
           {
             onRedirect: async (url) => {

+ 26 - 10
packages/opencode/src/mcp/oauth-callback.ts

@@ -1,10 +1,14 @@
 import { createConnection } from "net"
 import { createServer } from "http"
 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>
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
   }
 
   function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
-    const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
+    const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
 
-    if (url.pathname !== OAUTH_CALLBACK_PATH) {
+    if (url.pathname !== currentPath) {
       res.writeHead(404)
       res.end("Not found")
       return
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
     res.end(HTML_SUCCESS)
   }
 
-  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 = createServer(handleRequest)
     await new Promise<void>((resolve, reject) => {
-      server!.listen(OAUTH_CALLBACK_PORT, () => {
-        log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
+      server!.listen(currentPort, () => {
+        log.info("oauth callback server started", { port: currentPort, path: currentPath })
         resolve()
       })
       server!.on("error", reject)
@@ -182,9 +198,9 @@ export namespace McpOAuthCallback {
     }
   }
 
-  export async function isPortInUse(): Promise<boolean> {
+  export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
     return new Promise((resolve) => {
-      const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
+      const socket = createConnection(port, "127.0.0.1")
       socket.on("connect", () => {
         socket.destroy()
         resolve(true)

+ 23 - 0
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,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
   ) {}
 
   get redirectUrl(): string {
+    if (this.config.redirectUri) {
+      return this.config.redirectUri
+    }
     return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
   }
 
@@ -183,3 +187,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 - 0
packages/opencode/test/mcp/oauth-callback.test.ts

@@ -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)
+  })
+})