feat(mcp): add OAuth redirect URI configuration for MCP servers (#7379)
This commit is contained in:
@@ -6,6 +6,7 @@ import * as prompts from "@clack/prompts"
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { MCP } from "../../mcp"
|
import { MCP } from "../../mcp"
|
||||||
import { McpAuth } from "../../mcp/auth"
|
import { McpAuth } from "../../mcp/auth"
|
||||||
|
import { McpOAuthCallback } from "../../mcp/oauth-callback"
|
||||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
import { Instance } from "../../project/instance"
|
import { Instance } from "../../project/instance"
|
||||||
@@ -682,6 +683,10 @@ export const McpDebugCommand = cmd({
|
|||||||
|
|
||||||
// Try to discover OAuth metadata
|
// Try to discover OAuth metadata
|
||||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||||
|
|
||||||
|
// Start callback server
|
||||||
|
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
|
||||||
|
|
||||||
const authProvider = new McpOAuthProvider(
|
const authProvider = new McpOAuthProvider(
|
||||||
serverName,
|
serverName,
|
||||||
serverConfig.url,
|
serverConfig.url,
|
||||||
@@ -689,6 +694,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 () => {},
|
||||||
|
|||||||
@@ -433,6 +433,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({
|
||||||
|
|||||||
@@ -308,6 +308,8 @@ export namespace MCP {
|
|||||||
let authProvider: McpOAuthProvider | undefined
|
let authProvider: McpOAuthProvider | undefined
|
||||||
|
|
||||||
if (!oauthDisabled) {
|
if (!oauthDisabled) {
|
||||||
|
await McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)
|
||||||
|
|
||||||
authProvider = new McpOAuthProvider(
|
authProvider = new McpOAuthProvider(
|
||||||
key,
|
key,
|
||||||
mcp.url,
|
mcp.url,
|
||||||
@@ -315,6 +317,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) => {
|
||||||
@@ -344,6 +347,7 @@ export namespace MCP {
|
|||||||
|
|
||||||
let lastError: Error | undefined
|
let lastError: Error | undefined
|
||||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||||
|
|
||||||
for (const { name, transport } of transports) {
|
for (const { name, transport } of transports) {
|
||||||
try {
|
try {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
@@ -570,7 +574,8 @@ export namespace MCP {
|
|||||||
|
|
||||||
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
||||||
// Only include tools from connected MCPs (skip disabled ones)
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,8 +725,10 @@ export namespace MCP {
|
|||||||
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the callback server
|
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||||
await McpOAuthCallback.ensureRunning()
|
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
|
// Generate and store a cryptographically secure state parameter BEFORE creating the provider
|
||||||
// The SDK will call provider.state() to read this value
|
// The SDK will call provider.state() to read this value
|
||||||
@@ -731,8 +738,6 @@ export namespace MCP {
|
|||||||
await McpAuth.updateOAuthState(mcpName, oauthState)
|
await McpAuth.updateOAuthState(mcpName, oauthState)
|
||||||
|
|
||||||
// Create a new auth provider for this flow
|
// 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
|
let capturedUrl: URL | undefined
|
||||||
const authProvider = new McpOAuthProvider(
|
const authProvider = new McpOAuthProvider(
|
||||||
mcpName,
|
mcpName,
|
||||||
@@ -741,6 +746,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) => {
|
||||||
@@ -769,6 +775,7 @@ export namespace MCP {
|
|||||||
pendingOAuthTransports.set(mcpName, transport)
|
pendingOAuthTransports.set(mcpName, transport)
|
||||||
return { authorizationUrl: capturedUrl.toString() }
|
return { authorizationUrl: capturedUrl.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -778,9 +785,9 @@ export namespace MCP {
|
|||||||
* Opens the browser and waits for callback.
|
* Opens the browser and waits for callback.
|
||||||
*/
|
*/
|
||||||
export async function authenticate(mcpName: string): Promise<Status> {
|
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
|
// Already authenticated
|
||||||
const s = await state()
|
const s = await state()
|
||||||
return s.status[mcpName] ?? { status: "connected" }
|
return s.status[mcpName] ?? { status: "connected" }
|
||||||
@@ -794,9 +801,9 @@ export namespace MCP {
|
|||||||
|
|
||||||
// The SDK has already added the state parameter to the authorization URL
|
// The SDK has already added the state parameter to the authorization URL
|
||||||
// We just need to open the browser
|
// We just need to open the browser
|
||||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: oauthState })
|
||||||
try {
|
try {
|
||||||
const subprocess = await open(authorizationUrl)
|
const subprocess = await open(result.authorizationUrl)
|
||||||
// The open package spawns a detached process and returns immediately.
|
// The open package spawns a detached process and returns immediately.
|
||||||
// We need to listen for errors which fire asynchronously:
|
// We need to listen for errors which fire asynchronously:
|
||||||
// - "error" event: command not found (ENOENT)
|
// - "error" event: command not found (ENOENT)
|
||||||
@@ -819,7 +826,7 @@ export namespace MCP {
|
|||||||
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
|
// Browser opening failed (e.g., in remote/headless sessions like SSH, devcontainers)
|
||||||
// Emit event so CLI can display the URL for manual opening
|
// Emit event so CLI can display the URL for manual opening
|
||||||
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
|
log.warn("failed to open browser, user must open URL manually", { mcpName, error })
|
||||||
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
|
Bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for callback using the OAuth state parameter
|
// Wait for callback using the OAuth state parameter
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
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>
|
||||||
@@ -56,21 +60,33 @@ export namespace McpOAuthCallback {
|
|||||||
|
|
||||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
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
|
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 = Bun.serve({
|
server = Bun.serve({
|
||||||
port: OAUTH_CALLBACK_PORT,
|
port: currentPort,
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
|
|
||||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
if (url.pathname !== currentPath) {
|
||||||
return new Response("Not found", { status: 404 })
|
return new Response("Not found", { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
export function waitForCallback(oauthState: string): Promise<string> {
|
||||||
@@ -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) => {
|
return new Promise((resolve) => {
|
||||||
Bun.connect({
|
Bun.connect({
|
||||||
hostname: "127.0.0.1",
|
hostname: "127.0.0.1",
|
||||||
port: OAUTH_CALLBACK_PORT,
|
port,
|
||||||
socket: {
|
socket: {
|
||||||
open(socket) {
|
open(socket) {
|
||||||
socket.end()
|
socket.end()
|
||||||
|
|||||||
@@ -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,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
get redirectUrl(): string {
|
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}`
|
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,3 +157,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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
75
packages/opencode/test/mcp/oauth-callback.test.ts
Normal file
75
packages/opencode/test/mcp/oauth-callback.test.ts
Normal file
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user