feat(mcp): add OAuth redirect URI configuration for MCP servers (#21385)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
co-authored by
Aiden Cline
parent
5d3dba666c
commit
a7743e6467
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
|
|||||||
clientId: oauthConfig?.clientId,
|
clientId: oauthConfig?.clientId,
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
scope: oauthConfig?.scope,
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async () => {},
|
onRedirect: async () => {},
|
||||||
|
|||||||
@@ -399,6 +399,10 @@ export namespace Config {
|
|||||||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
.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)"),
|
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"),
|
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()
|
.strict()
|
||||||
.meta({
|
.meta({
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ export namespace MCP {
|
|||||||
clientId: oauthConfig?.clientId,
|
clientId: oauthConfig?.clientId,
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
scope: oauthConfig?.scope,
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async (url) => {
|
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.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`)
|
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)))
|
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||||
.map((b) => b.toString(16).padStart(2, "0"))
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
.join("")
|
.join("")
|
||||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
|
||||||
let capturedUrl: URL | undefined
|
let capturedUrl: URL | undefined
|
||||||
const authProvider = new McpOAuthProvider(
|
const authProvider = new McpOAuthProvider(
|
||||||
mcpName,
|
mcpName,
|
||||||
@@ -731,6 +735,7 @@ export namespace MCP {
|
|||||||
clientId: oauthConfig?.clientId,
|
clientId: oauthConfig?.clientId,
|
||||||
clientSecret: oauthConfig?.clientSecret,
|
clientSecret: oauthConfig?.clientSecret,
|
||||||
scope: oauthConfig?.scope,
|
scope: oauthConfig?.scope,
|
||||||
|
redirectUri: oauthConfig?.redirectUri,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onRedirect: async (url) => {
|
onRedirect: async (url) => {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createConnection } from "net"
|
import { createConnection } from "net"
|
||||||
import { createServer } from "http"
|
import { createServer } from "http"
|
||||||
import { Log } from "../util/log"
|
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" })
|
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>
|
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
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.writeHead(404)
|
||||||
res.end("Not found")
|
res.end("Not found")
|
||||||
return
|
return
|
||||||
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
|
|||||||
res.end(HTML_SUCCESS)
|
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
|
if (server) return
|
||||||
|
|
||||||
const running = await isPortInUse()
|
const running = await isPortInUse(port)
|
||||||
if (running) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPort = port
|
||||||
|
currentPath = path
|
||||||
|
|
||||||
server = createServer(handleRequest)
|
server = createServer(handleRequest)
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
server!.listen(currentPort, () => {
|
||||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
log.info("oauth callback server started", { port: currentPort, path: currentPath })
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
server!.on("error", reject)
|
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) => {
|
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.on("connect", () => {
|
||||||
socket.destroy()
|
socket.destroy()
|
||||||
resolve(true)
|
resolve(true)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
|
|||||||
clientId?: string
|
clientId?: string
|
||||||
clientSecret?: string
|
clientSecret?: string
|
||||||
scope?: string
|
scope?: string
|
||||||
|
redirectUri?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpOAuthCallbacks {
|
export interface McpOAuthCallbacks {
|
||||||
@@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
get redirectUrl(): string {
|
||||||
|
if (this.config.redirectUri) {
|
||||||
|
return this.config.redirectUri
|
||||||
|
}
|
||||||
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
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 }
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user