feat(mcp): add OAuth authentication support for remote MCP servers (#5014)

This commit is contained in:
André Cruz
2025-12-07 20:47:27 +00:00
committed by GitHub
parent e693192e06
commit 509e43d6f8
14 changed files with 1511 additions and 74 deletions

View File

@@ -0,0 +1,82 @@
import path from "path"
import fs from "fs/promises"
import z from "zod"
import { Global } from "../global"
export namespace McpAuth {
export const Tokens = z.object({
accessToken: z.string(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
scope: z.string().optional(),
})
export type Tokens = z.infer<typeof Tokens>
export const ClientInfo = z.object({
clientId: z.string(),
clientSecret: z.string().optional(),
clientIdIssuedAt: z.number().optional(),
clientSecretExpiresAt: z.number().optional(),
})
export type ClientInfo = z.infer<typeof ClientInfo>
export const Entry = z.object({
tokens: Tokens.optional(),
clientInfo: ClientInfo.optional(),
codeVerifier: z.string().optional(),
})
export type Entry = z.infer<typeof Entry>
const filepath = path.join(Global.Path.data, "mcp-auth.json")
export async function get(mcpName: string): Promise<Entry | undefined> {
const data = await all()
return data[mcpName]
}
export async function all(): Promise<Record<string, Entry>> {
const file = Bun.file(filepath)
return file.json().catch(() => ({}))
}
export async function set(mcpName: string, entry: Entry): Promise<void> {
const file = Bun.file(filepath)
const data = await all()
await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2))
await fs.chmod(file.name!, 0o600)
}
export async function remove(mcpName: string): Promise<void> {
const file = Bun.file(filepath)
const data = await all()
delete data[mcpName]
await Bun.write(file, JSON.stringify(data, null, 2))
await fs.chmod(file.name!, 0o600)
}
export async function updateTokens(mcpName: string, tokens: Tokens): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.tokens = tokens
await set(mcpName, entry)
}
export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.clientInfo = clientInfo
await set(mcpName, entry)
}
export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
const entry = (await get(mcpName)) ?? {}
entry.codeVerifier = codeVerifier
await set(mcpName, entry)
}
export async function clearCodeVerifier(mcpName: string): Promise<void> {
const entry = await get(mcpName)
if (entry) {
delete entry.codeVerifier
await set(mcpName, entry)
}
}
}

View File

@@ -3,12 +3,17 @@ import { experimental_createMCPClient } from "@ai-sdk/mcp"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
import { withTimeout } from "@/util/timeout"
import { McpOAuthProvider } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import open from "open"
export namespace MCP {
const log = Log.create({ service: "mcp" })
@@ -46,6 +51,21 @@ export namespace MCP {
.meta({
ref: "MCPStatusFailed",
}),
z
.object({
status: z.literal("needs_auth"),
})
.meta({
ref: "MCPStatusNeedsAuth",
}),
z
.object({
status: z.literal("needs_client_registration"),
error: z.string(),
})
.meta({
ref: "MCPStatusNeedsClientRegistration",
}),
])
.meta({
ref: "MCPStatus",
@@ -53,6 +73,10 @@ export namespace MCP {
export type Status = z.infer<typeof Status>
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
// Store transports for OAuth servers to allow finishing auth
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
const state = Instance.state(
async () => {
const cfg = await Config.get()
@@ -87,6 +111,7 @@ export namespace MCP {
}),
),
)
pendingOAuthTransports.clear()
},
)
@@ -120,58 +145,98 @@ export namespace MCP {
async function create(key: string, mcp: Config.Mcp) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
return
return {
mcpClient: undefined,
status: { status: "disabled" as const },
}
}
log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
let status: Status | undefined = undefined
if (mcp.type === "remote") {
const transports = [
// OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined
if (!oauthDisabled) {
authProvider = new McpOAuthProvider(
key,
mcp.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
},
{
onRedirect: async (url) => {
log.info("oauth redirect requested", { key, url: url.toString() })
// Store the URL - actual browser opening is handled by startAuth
},
},
)
}
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
{
name: "StreamableHTTP",
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
requestInit: {
headers: mcp.headers,
},
authProvider,
requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
{
name: "SSE",
transport: new SSEClientTransport(new URL(mcp.url), {
requestInit: {
headers: mcp.headers,
},
authProvider,
requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
]
let lastError: Error | undefined
for (const { name, transport } of transports) {
const result = await experimental_createMCPClient({
name: "opencode",
transport,
})
.then((client) => {
log.info("connected", { key, transport: name })
mcpClient = client
status = { status: "connected" }
return true
try {
mcpClient = await experimental_createMCPClient({
name: "opencode",
transport,
})
.catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
status = {
status: "failed" as const,
error: lastError.message,
log.info("connected", { key, transport: name })
status = { status: "connected" }
break
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
// Handle OAuth-specific errors
if (error instanceof UnauthorizedError) {
log.info("mcp server requires authentication", { key, transport: name })
// Check if this is a "needs registration" error
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
status = {
status: "needs_client_registration" as const,
error: "Server does not support dynamic client registration. Please provide clientId in config.",
}
} else {
// Store transport for later finishAuth call
pendingOAuthTransports.set(key, transport)
status = { status: "needs_auth" as const }
}
return false
break
}
log.debug("transport connection failed", {
key,
transport: name,
url: mcp.url,
error: lastError.message,
})
if (result) break
status = {
status: "failed" as const,
error: lastError.message,
}
}
}
}
@@ -286,4 +351,165 @@ export namespace MCP {
}
return result
}
/**
* Start OAuth authentication flow for an MCP server.
* Returns the authorization URL that should be opened in a browser.
*/
export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
const cfg = await Config.get()
const mcpConfig = cfg.mcp?.[mcpName]
if (!mcpConfig) {
throw new Error(`MCP server not found: ${mcpName}`)
}
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`)
}
// Start the callback server
await McpOAuthCallback.ensureRunning()
// 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
const authProvider = new McpOAuthProvider(
mcpName,
mcpConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
},
{
onRedirect: async (url) => {
capturedUrl = url
},
},
)
// Create transport with auth provider
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
authProvider,
})
// Try to connect - this will trigger the OAuth flow
try {
await experimental_createMCPClient({
name: "opencode",
transport,
})
// If we get here, we're already authenticated
return { authorizationUrl: "" }
} catch (error) {
if (error instanceof UnauthorizedError && capturedUrl) {
// Store transport for finishAuth
pendingOAuthTransports.set(mcpName, transport)
return { authorizationUrl: capturedUrl.toString() }
}
throw error
}
}
/**
* Complete OAuth authentication after user authorizes in browser.
* Opens the browser and waits for callback.
*/
export async function authenticate(mcpName: string): Promise<Status> {
const { authorizationUrl } = await startAuth(mcpName)
if (!authorizationUrl) {
// Already authenticated
const s = await state()
return s.status[mcpName] ?? { status: "connected" }
}
// Extract state from authorization URL to use as callback key
// If no state parameter, use mcpName as fallback
const authUrl = new URL(authorizationUrl)
const oauthState = authUrl.searchParams.get("state") ?? mcpName
// Open browser
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
await open(authorizationUrl)
// Wait for callback using the OAuth state parameter (or mcpName as fallback)
const code = await McpOAuthCallback.waitForCallback(oauthState)
// Finish auth
return finishAuth(mcpName, code)
}
/**
* Complete OAuth authentication with the authorization code.
*/
export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
const transport = pendingOAuthTransports.get(mcpName)
if (!transport) {
throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
}
try {
// Call finishAuth on the transport
await transport.finishAuth(authorizationCode)
// Clear the code verifier after successful auth
await McpAuth.clearCodeVerifier(mcpName)
// Now try to reconnect
const cfg = await Config.get()
const mcpConfig = cfg.mcp?.[mcpName]
if (!mcpConfig) {
throw new Error(`MCP server not found: ${mcpName}`)
}
// Re-add the MCP server to establish connection
pendingOAuthTransports.delete(mcpName)
const result = await add(mcpName, mcpConfig)
const statusRecord = result.status as Record<string, Status>
return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
} catch (error) {
log.error("failed to finish oauth", { mcpName, error })
return {
status: "failed",
error: error instanceof Error ? error.message : String(error),
}
}
}
/**
* Remove OAuth credentials for an MCP server.
*/
export async function removeAuth(mcpName: string): Promise<void> {
await McpAuth.remove(mcpName)
McpOAuthCallback.cancelPending(mcpName)
pendingOAuthTransports.delete(mcpName)
log.info("removed oauth credentials", { mcpName })
}
/**
* Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
*/
export async function supportsOAuth(mcpName: string): Promise<boolean> {
const cfg = await Config.get()
const mcpConfig = cfg.mcp?.[mcpName]
return mcpConfig?.type === "remote" && mcpConfig.oauth !== false
}
/**
* Check if an MCP server has stored OAuth tokens.
*/
export async function hasStoredTokens(mcpName: string): Promise<boolean> {
const entry = await McpAuth.get(mcpName)
return !!entry?.tokens
}
}

View File

@@ -0,0 +1,203 @@
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
const log = Log.create({ service: "mcp.oauth-callback" })
const HTML_SUCCESS = `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Successful</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #4ade80; margin-bottom: 1rem; }
p { color: #aaa; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Successful</h1>
<p>You can close this window and return to OpenCode.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
<html>
<head>
<title>OpenCode - Authorization Failed</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
.container { text-align: center; padding: 2rem; }
h1 { color: #f87171; margin-bottom: 1rem; }
p { color: #aaa; }
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization Failed</h1>
<p>An error occurred during authorization.</p>
<div class="error">${error}</div>
</div>
</body>
</html>`
interface PendingAuth {
resolve: (code: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
export namespace McpOAuthCallback {
let server: ReturnType<typeof Bun.serve> | undefined
const pendingAuths = new Map<string, PendingAuth>()
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
export async function ensureRunning(): Promise<void> {
if (server) return
const running = await isPortInUse()
if (running) {
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
return
}
server = Bun.serve({
port: OAUTH_CALLBACK_PORT,
fetch(req) {
const url = new URL(req.url)
if (url.pathname !== OAUTH_CALLBACK_PATH) {
return new Response("Not found", { status: 404 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
const errorDescription = url.searchParams.get("error_description")
log.info("received oauth callback", { hasCode: !!code, state, error })
if (error) {
const errorMsg = errorDescription || error
if (state && pendingAuths.has(state)) {
const pending = pendingAuths.get(state)!
clearTimeout(pending.timeout)
pendingAuths.delete(state)
pending.reject(new Error(errorMsg))
}
return new Response(HTML_ERROR(errorMsg), {
headers: { "Content-Type": "text/html" },
})
}
if (!code) {
return new Response(HTML_ERROR("No authorization code provided"), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
// Try to find the pending auth by state parameter, or if no state, use the single pending auth
let pending: PendingAuth | undefined
let pendingKey: string | undefined
if (state && pendingAuths.has(state)) {
pending = pendingAuths.get(state)!
pendingKey = state
} else if (!state && pendingAuths.size === 1) {
// No state parameter but only one pending auth - use it
const [key, value] = pendingAuths.entries().next().value as [string, PendingAuth]
pending = value
pendingKey = key
log.info("no state parameter, using single pending auth", { key })
}
if (!pending || !pendingKey) {
const errorMsg = !state
? "No state parameter provided and multiple pending authorizations"
: "Unknown or expired authorization request"
return new Response(HTML_ERROR(errorMsg), {
status: 400,
headers: { "Content-Type": "text/html" },
})
}
clearTimeout(pending.timeout)
pendingAuths.delete(pendingKey)
pending.resolve(code)
return new Response(HTML_SUCCESS, {
headers: { "Content-Type": "text/html" },
})
},
})
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
}
export function waitForCallback(mcpName: string): Promise<string> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (pendingAuths.has(mcpName)) {
pendingAuths.delete(mcpName)
reject(new Error("OAuth callback timeout - authorization took too long"))
}
}, CALLBACK_TIMEOUT_MS)
pendingAuths.set(mcpName, { resolve, reject, timeout })
})
}
export function cancelPending(mcpName: string): void {
const pending = pendingAuths.get(mcpName)
if (pending) {
clearTimeout(pending.timeout)
pendingAuths.delete(mcpName)
pending.reject(new Error("Authorization cancelled"))
}
}
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
resolve(true)
},
error() {
resolve(false)
},
data() {},
close() {},
},
}).catch(() => {
resolve(false)
})
})
}
export async function stop(): Promise<void> {
if (server) {
server.stop()
server = undefined
log.info("oauth callback server stopped")
}
for (const [name, pending] of pendingAuths) {
clearTimeout(pending.timeout)
pending.reject(new Error("OAuth callback server stopped"))
}
pendingAuths.clear()
}
export function isRunning(): boolean {
return server !== undefined
}
}

View File

@@ -0,0 +1,132 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type {
OAuthClientMetadata,
OAuthTokens,
OAuthClientInformation,
OAuthClientInformationFull,
} from "@modelcontextprotocol/sdk/shared/auth.js"
import { McpAuth } from "./auth"
import { Log } from "../util/log"
const log = Log.create({ service: "mcp.oauth" })
const OAUTH_CALLBACK_PORT = 19876
const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback"
export interface McpOAuthConfig {
clientId?: string
clientSecret?: string
scope?: string
}
export interface McpOAuthCallbacks {
onRedirect: (url: URL) => void | Promise<void>
}
export class McpOAuthProvider implements OAuthClientProvider {
constructor(
private mcpName: string,
private serverUrl: string,
private config: McpOAuthConfig,
private callbacks: McpOAuthCallbacks,
) {}
get redirectUrl(): string {
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
}
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl],
client_name: "OpenCode",
client_uri: "https://opencode.ai",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
}
}
async clientInformation(): Promise<OAuthClientInformation | undefined> {
// Check config first (pre-registered client)
if (this.config.clientId) {
return {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}
}
// Check stored client info (from dynamic registration)
const entry = await McpAuth.get(this.mcpName)
if (entry?.clientInfo) {
// Check if client secret has expired
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
log.info("client secret expired, need to re-register", { mcpName: this.mcpName })
return undefined
}
return {
client_id: entry.clientInfo.clientId,
client_secret: entry.clientInfo.clientSecret,
}
}
// No client info - will trigger dynamic registration
return undefined
}
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
await McpAuth.updateClientInfo(this.mcpName, {
clientId: info.client_id,
clientSecret: info.client_secret,
clientIdIssuedAt: info.client_id_issued_at,
clientSecretExpiresAt: info.client_secret_expires_at,
})
log.info("saved dynamically registered client", {
mcpName: this.mcpName,
clientId: info.client_id,
})
}
async tokens(): Promise<OAuthTokens | undefined> {
const entry = await McpAuth.get(this.mcpName)
if (!entry?.tokens) return undefined
return {
access_token: entry.tokens.accessToken,
token_type: "Bearer",
refresh_token: entry.tokens.refreshToken,
expires_in: entry.tokens.expiresAt
? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
: undefined,
scope: entry.tokens.scope,
}
}
async saveTokens(tokens: OAuthTokens): Promise<void> {
await McpAuth.updateTokens(this.mcpName, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
scope: tokens.scope,
})
log.info("saved oauth tokens", { mcpName: this.mcpName })
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
log.info("redirecting to authorization", { mcpName: this.mcpName, url: authorizationUrl.toString() })
await this.callbacks.onRedirect(authorizationUrl)
}
async saveCodeVerifier(codeVerifier: string): Promise<void> {
await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
}
async codeVerifier(): Promise<string> {
const entry = await McpAuth.get(this.mcpName)
if (!entry?.codeVerifier) {
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
}
return entry.codeVerifier
}
}
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }