From 579fffd27b06cdada8da51184f644642bfdb54a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Tue, 2 Dec 2025 23:01:43 +0000 Subject: [PATCH 1/8] feat(mcp): add OAuth infrastructure for MCP servers (Phase 1) - Add McpAuth namespace for OAuth token and client info storage - Add McpOAuthProvider implementing OAuthClientProvider interface - Support dynamic client registration (RFC 7591) - Add McpOAuth config schema with optional clientId/clientSecret/scope - Store credentials in ~/.local/share/opencode/mcp-auth.json with 0o600 permissions --- packages/opencode/src/config/config.ts | 16 +++ packages/opencode/src/mcp/auth.ts | 82 ++++++++++++ packages/opencode/src/mcp/oauth-provider.ts | 132 ++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 packages/opencode/src/mcp/auth.ts create mode 100644 packages/opencode/src/mcp/oauth-provider.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d38de8a94078..c665a1f5493c 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -325,12 +325,28 @@ export namespace Config { ref: "McpLocalConfig", }) + export const McpOAuth = z + .object({ + clientId: z + .string() + .optional() + .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"), + }) + .strict() + .meta({ + ref: "McpOAuthConfig", + }) + export type McpOAuth = z.infer + export const McpRemote = z .object({ type: z.literal("remote").describe("Type of MCP server connection"), url: z.string().describe("URL of the remote MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), + oauth: McpOAuth.optional().describe("OAuth authentication configuration for the MCP server"), timeout: z .number() .int() diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts new file mode 100644 index 000000000000..385cb3c7339c --- /dev/null +++ b/packages/opencode/src/mcp/auth.ts @@ -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 + + 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 + + export const Entry = z.object({ + tokens: Tokens.optional(), + clientInfo: ClientInfo.optional(), + codeVerifier: z.string().optional(), + }) + export type Entry = z.infer + + const filepath = path.join(Global.Path.data, "mcp-auth.json") + + export async function get(mcpName: string): Promise { + const data = await all() + return data[mcpName] + } + + export async function all(): Promise> { + const file = Bun.file(filepath) + return file.json().catch(() => ({})) + } + + export async function set(mcpName: string, entry: Entry): Promise { + 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 { + 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 { + const entry = (await get(mcpName)) ?? {} + entry.tokens = tokens + await set(mcpName, entry) + } + + export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo): Promise { + const entry = (await get(mcpName)) ?? {} + entry.clientInfo = clientInfo + await set(mcpName, entry) + } + + export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise { + const entry = (await get(mcpName)) ?? {} + entry.codeVerifier = codeVerifier + await set(mcpName, entry) + } + + export async function clearCodeVerifier(mcpName: string): Promise { + const entry = await get(mcpName) + if (entry) { + delete entry.codeVerifier + await set(mcpName, entry) + } + } +} diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts new file mode 100644 index 000000000000..584eca8e88ae --- /dev/null +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -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 +} + +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 { + // 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 { + 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 { + 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 { + 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 { + log.info("redirecting to authorization", { mcpName: this.mcpName, url: authorizationUrl.toString() }) + await this.callbacks.onRedirect(authorizationUrl) + } + + async saveCodeVerifier(codeVerifier: string): Promise { + await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier) + } + + async codeVerifier(): Promise { + 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 } From d9bcc4b209487f0787a0d40c73b5ff0ddb36b759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Tue, 2 Dec 2025 23:05:04 +0000 Subject: [PATCH 2/8] feat(mcp): add OAuth callback server and MCP module integration (Phase 2) - Add McpOAuthCallback server for handling OAuth redirects on port 19876 - Add new MCP status types: needs_auth, needs_client_registration - Integrate OAuth provider with StreamableHTTP and SSE transports - Add MCP.startAuth(), MCP.authenticate(), MCP.finishAuth() methods - Add MCP.removeAuth() for credential management - Handle UnauthorizedError to detect OAuth requirements - Use 'open' package for browser-based authorization flow --- packages/opencode/src/mcp/index.ts | 282 +++++++++++++++++--- packages/opencode/src/mcp/oauth-callback.ts | 203 ++++++++++++++ 2 files changed, 455 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/mcp/oauth-callback.ts diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index a68a1716f0cd..9f4d7d4adc37 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -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 type MCPClient = Awaited> + // Store transports for OAuth servers to allow finishing auth + type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport + const pendingOAuthTransports = new Map() + const state = Instance.state( async () => { const cfg = await Config.get() @@ -87,6 +111,7 @@ export namespace MCP { }), ), ) + pendingOAuthTransports.clear() }, ) @@ -120,58 +145,96 @@ 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 = [ + const hasOAuth = !!mcp.oauth + let authProvider: McpOAuthProvider | undefined + + if (hasOAuth) { + authProvider = new McpOAuthProvider( + key, + mcp.url, + { + clientId: mcp.oauth!.clientId, + clientSecret: mcp.oauth!.clientSecret, + scope: mcp.oauth!.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: !hasOAuth && mcp.headers ? { headers: mcp.headers } : undefined, }), }, { name: "SSE", transport: new SSEClientTransport(new URL(mcp.url), { - requestInit: { - headers: mcp.headers, - }, + authProvider, + requestInit: !hasOAuth && 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 +349,163 @@ 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) { + throw new Error(`MCP server ${mcpName} does not have OAuth configured`) + } + + // Start the callback server + await McpOAuthCallback.ensureRunning() + + // Create a new auth provider for this flow + let capturedUrl: URL | undefined + const authProvider = new McpOAuthProvider( + mcpName, + mcpConfig.url, + { + clientId: mcpConfig.oauth.clientId, + clientSecret: mcpConfig.oauth.clientSecret, + scope: mcpConfig.oauth.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 { + 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 { + 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 + 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 { + await McpAuth.remove(mcpName) + McpOAuthCallback.cancelPending(mcpName) + pendingOAuthTransports.delete(mcpName) + log.info("removed oauth credentials", { mcpName }) + } + + /** + * Check if an MCP server has OAuth configured. + */ + export async function hasOAuthConfig(mcpName: string): Promise { + const cfg = await Config.get() + const mcpConfig = cfg.mcp?.[mcpName] + return mcpConfig?.type === "remote" && !!mcpConfig.oauth + } + + /** + * Check if an MCP server has stored OAuth tokens. + */ + export async function hasStoredTokens(mcpName: string): Promise { + const entry = await McpAuth.get(mcpName) + return !!entry?.tokens + } } diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts new file mode 100644 index 000000000000..67bb5168410b --- /dev/null +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -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 = ` + + + OpenCode - Authorization Successful + + + +
+

Authorization Successful

+

You can close this window and return to OpenCode.

+
+ + +` + +const HTML_ERROR = (error: string) => ` + + + OpenCode - Authorization Failed + + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${error}
+
+ +` + +interface PendingAuth { + resolve: (code: string) => void + reject: (error: Error) => void + timeout: ReturnType +} + +export namespace McpOAuthCallback { + let server: ReturnType | undefined + const pendingAuths = new Map() + + const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + + export async function ensureRunning(): Promise { + 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 { + 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 { + 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 { + 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 + } +} From d088cee92b742b59eb5d93d9eba27c3abb0df2c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Tue, 2 Dec 2025 23:07:54 +0000 Subject: [PATCH 3/8] feat(mcp): add CLI commands and server API for OAuth (Phase 3) CLI commands: - mcp list: Show MCP servers with connection and auth status - mcp auth [name]: Authenticate with OAuth-enabled MCP server - mcp logout [name]: Remove OAuth credentials for an MCP server - Enhanced mcp add: Support OAuth configuration during setup Server API endpoints: - POST /mcp/:name/auth - Start OAuth flow, returns authorization URL - POST /mcp/:name/auth/callback - Complete OAuth with auth code - POST /mcp/:name/auth/authenticate - Full OAuth flow (opens browser) - DELETE /mcp/:name/auth - Remove OAuth credentials --- packages/opencode/src/cli/cmd/mcp.ts | 334 ++++++++++++++++++++++++- packages/opencode/src/server/server.ts | 111 ++++++++ 2 files changed, 438 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index df0046b23f50..9ca4b3bff8b2 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -3,13 +3,272 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import * as prompts from "@clack/prompts" import { UI } from "../ui" +import { MCP } from "../../mcp" +import { McpAuth } from "../../mcp/auth" +import { Config } from "../../config/config" +import { Instance } from "../../project/instance" +import path from "path" +import os from "os" +import { Global } from "../../global" export const McpCommand = cmd({ command: "mcp", - builder: (yargs) => yargs.command(McpAddCommand).demandCommand(), + builder: (yargs) => + yargs + .command(McpAddCommand) + .command(McpListCommand) + .command(McpAuthCommand) + .command(McpLogoutCommand) + .demandCommand(), async handler() {}, }) +export const McpListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list MCP servers and their status", + async handler() { + await Instance.provide({ + directory: process.cwd(), + async fn() { + UI.empty() + prompts.intro("MCP Servers") + + const config = await Config.get() + const mcpServers = config.mcp ?? {} + const statuses = await MCP.status() + + if (Object.keys(mcpServers).length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } + + for (const [name, serverConfig] of Object.entries(mcpServers)) { + const status = statuses[name] + const hasOAuth = serverConfig.type === "remote" && !!serverConfig.oauth + const hasStoredTokens = await MCP.hasStoredTokens(name) + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" + } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } + + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } + + prompts.outro(`${Object.keys(mcpServers).length} server(s)`) + }, + }) + }, +}) + +export const McpAuthCommand = cmd({ + command: "auth [name]", + describe: "authenticate with an OAuth-enabled MCP server", + builder: (yargs) => + yargs.positional("name", { + describe: "name of the MCP server", + type: "string", + }), + async handler(args) { + await Instance.provide({ + directory: process.cwd(), + async fn() { + UI.empty() + prompts.intro("MCP OAuth Authentication") + + const config = await Config.get() + const mcpServers = config.mcp ?? {} + + // Get OAuth-enabled servers + const oauthServers = Object.entries(mcpServers).filter(([_, cfg]) => cfg.type === "remote" && !!cfg.oauth) + + if (oauthServers.length === 0) { + prompts.log.warn("No OAuth-enabled MCP servers configured") + prompts.log.info("Add OAuth config to a remote MCP server in opencode.json:") + prompts.log.info(` + "mcp": { + "my-server": { + "type": "remote", + "url": "https://example.com/mcp", + "oauth": { + "scope": "tools:read" + } + } + }`) + prompts.outro("Done") + return + } + + let serverName = args.name + if (!serverName) { + const selected = await prompts.select({ + message: "Select MCP server to authenticate", + options: oauthServers.map(([name, cfg]) => ({ + label: name, + value: name, + hint: cfg.type === "remote" ? cfg.url : undefined, + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } + + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } + + if (serverConfig.type !== "remote" || !serverConfig.oauth) { + prompts.log.error(`MCP server ${serverName} does not have OAuth configured`) + prompts.outro("Done") + return + } + + // Check if already authenticated + const hasTokens = await MCP.hasStoredTokens(serverName) + if (hasTokens) { + const confirm = await prompts.confirm({ + message: `${serverName} already has stored credentials. Re-authenticate?`, + }) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } + + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") + + try { + const status = await MCP.authenticate(serverName) + + if (status.status === "connected") { + spinner.stop("Authentication successful!") + } else if (status.status === "needs_client_registration") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + prompts.log.info("Add clientId to your MCP server config:") + prompts.log.info(` + "mcp": { + "${serverName}": { + "type": "remote", + "url": "${serverConfig.url}", + "oauth": { + "clientId": "your-client-id", + "clientSecret": "your-client-secret" + } + } + }`) + } else if (status.status === "failed") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + } else { + spinner.stop("Unexpected status: " + status.status, 1) + } + } catch (error) { + spinner.stop("Authentication failed", 1) + prompts.log.error(error instanceof Error ? error.message : String(error)) + } + + prompts.outro("Done") + }, + }) + }, +}) + +export const McpLogoutCommand = cmd({ + command: "logout [name]", + describe: "remove OAuth credentials for an MCP server", + builder: (yargs) => + yargs.positional("name", { + describe: "name of the MCP server", + type: "string", + }), + async handler(args) { + await Instance.provide({ + directory: process.cwd(), + async fn() { + UI.empty() + prompts.intro("MCP OAuth Logout") + + const authPath = path.join(Global.Path.data, "mcp-auth.json") + const credentials = await McpAuth.all() + const serverNames = Object.keys(credentials) + + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } + + let serverName = args.name + if (!serverName) { + const selected = await prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } + + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } + + await MCP.removeAuth(serverName) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }, + }) + }, +}) + export const McpAddCommand = cmd({ command: "add", describe: "add an MCP server", @@ -66,13 +325,74 @@ export const McpAddCommand = cmd({ }) if (prompts.isCancel(url)) throw new UI.CancelledError() - const client = new Client({ - name: "opencode", - version: "1.0.0", + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, }) - const transport = new StreamableHTTPClientTransport(new URL(url)) - await client.connect(transport) - prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", + initialValue: false, + }) + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() + + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", + initialValue: false, + }) + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() + + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", + }) + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } + + prompts.log.info(`Remote MCP server "${name}" configured with OAuth (client ID: ${clientId})`) + prompts.log.info("Add this to your opencode.json:") + prompts.log.info(` + "mcp": { + "${name}": { + "type": "remote", + "url": "${url}", + "oauth": { + "clientId": "${clientId}"${clientSecret ? `,\n "clientSecret": "${clientSecret}"` : ""} + } + } + }`) + } else { + prompts.log.info(`Remote MCP server "${name}" configured with OAuth (dynamic registration)`) + prompts.log.info("Add this to your opencode.json:") + prompts.log.info(` + "mcp": { + "${name}": { + "type": "remote", + "url": "${url}", + "oauth": {} + } + }`) + } + } else { + const client = new Client({ + name: "opencode", + version: "1.0.0", + }) + const transport = new StreamableHTTPClientTransport(new URL(url)) + await client.connect(transport) + prompts.log.info(`Remote MCP server "${name}" configured with URL: ${url}`) + } } prompts.outro("MCP server added successfully") diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7a105e7467cf..cf1f5fe45371 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1804,6 +1804,117 @@ export namespace Server { return c.json(result.status) }, ) + .post( + "/mcp/:name/auth", + describeRoute({ + description: "Start OAuth authentication flow for an MCP server", + operationId: "mcp.auth.start", + responses: { + 200: { + description: "OAuth flow started", + content: { + "application/json": { + schema: resolver( + z.object({ + authorizationUrl: z.string().describe("URL to open in browser for authorization"), + }), + ), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const hasOAuth = await MCP.hasOAuthConfig(name) + if (!hasOAuth) { + return c.json({ error: `MCP server ${name} does not have OAuth configured` }, 400) + } + const result = await MCP.startAuth(name) + return c.json(result) + }, + ) + .post( + "/mcp/:name/auth/callback", + describeRoute({ + description: "Complete OAuth authentication with authorization code", + operationId: "mcp.auth.callback", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "json", + z.object({ + code: z.string().describe("Authorization code from OAuth callback"), + }), + ), + async (c) => { + const name = c.req.param("name") + const { code } = c.req.valid("json") + const status = await MCP.finishAuth(name, code) + return c.json(status) + }, + ) + .post( + "/mcp/:name/auth/authenticate", + describeRoute({ + description: "Start OAuth flow and wait for callback (opens browser)", + operationId: "mcp.auth.authenticate", + responses: { + 200: { + description: "OAuth authentication completed", + content: { + "application/json": { + schema: resolver(MCP.Status), + }, + }, + }, + ...errors(400, 404), + }, + }), + async (c) => { + const name = c.req.param("name") + const hasOAuth = await MCP.hasOAuthConfig(name) + if (!hasOAuth) { + return c.json({ error: `MCP server ${name} does not have OAuth configured` }, 400) + } + const status = await MCP.authenticate(name) + return c.json(status) + }, + ) + .delete( + "/mcp/:name/auth", + describeRoute({ + description: "Remove OAuth credentials for an MCP server", + operationId: "mcp.auth.remove", + responses: { + 200: { + description: "OAuth credentials removed", + content: { + "application/json": { + schema: resolver(z.object({ success: z.literal(true) })), + }, + }, + }, + ...errors(404), + }, + }), + async (c) => { + const name = c.req.param("name") + await MCP.removeAuth(name) + return c.json({ success: true as const }) + }, + ) .get( "/lsp", describeRoute({ From 13c735f5a3a756a210fd1bdc424b3a158334d393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Tue, 2 Dec 2025 23:11:22 +0000 Subject: [PATCH 4/8] feat(mcp): add TUI support for OAuth status display (Phase 4) - Update DialogStatus to show needs_auth and needs_client_registration states - Update Sidebar MCP section with OAuth status indicators - Use warning color (yellow) for needs_auth status - Use error color (red) for needs_client_registration status - Add helpful message for needs_auth: 'run: opencode mcp auth ' --- .../cli/cmd/tui/component/dialog-status.tsx | 22 ++++++++++++++----- .../cli/cmd/tui/routes/session/sidebar.tsx | 22 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index e427e24e952a..f3ce4d4dea59 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -28,11 +28,15 @@ export function DialogStatus() { + )[item.status], }} > • @@ -40,10 +44,16 @@ export function DialogStatus() { {key}{" "} - + Connected {(val) => val().error} Disabled in configuration + + Needs authentication (run: opencode mcp auth {key}) + + + {(val) => (val() as { error: string }).error} + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index c63f5116ab25..e734fdc48b83 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -104,11 +104,15 @@ export function Sidebar(props: { sessionID: string }) { + )[item.status], }} > • @@ -116,10 +120,14 @@ export function Sidebar(props: { sessionID: string }) { {key}{" "} - + Connected {(val) => {val().error}} - Disabled in configuration + Disabled + Needs auth + + Needs client ID + From b0b107a4b21fa3d44a27e15eb974cdd797a9b66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Wed, 3 Dec 2025 09:54:12 +0000 Subject: [PATCH 5/8] docs(mcp): add OAuth documentation (Phase 5) - Remove 'OAuth coming soon' note - Add comprehensive OAuth section with configuration examples - Document dynamic client registration (RFC 7591) - Document pre-registered client configuration - Add OAuth options table (clientId, clientSecret, scope) - Document CLI commands: mcp auth, mcp list, mcp logout --- packages/web/src/content/docs/mcp-servers.mdx | 77 ++++++++++++++++++- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 6e2cb7be1a74..6b20cec84bc7 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -12,10 +12,6 @@ OpenCode supports both: Once added, MCP tools are automatically available to the LLM alongside built-in tools. -:::note -OAuth support for MCP servers is coming soon. -::: - --- ## Caveats @@ -146,10 +142,83 @@ Here the `url` is the URL of the remote MCP server and with the `headers` option | `url` | String | Y | URL of the remote MCP server. | | `enabled` | Boolean | | Enable or disable the MCP server on startup. | | `headers` | Object | | Headers to send with the request. | +| `oauth` | Object | | OAuth authentication configuration. See [OAuth](#oauth) section below. | | `timeout` | Number | | Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds). | --- +### OAuth + +For remote MCP servers that require OAuth authentication, you can configure the `oauth` field. OpenCode supports: + +- **Dynamic Client Registration (RFC 7591)**: If the server supports it, OpenCode will automatically register as a client. +- **Pre-registered Clients**: If you have a client ID from the MCP server provider, you can configure it directly. + +#### Dynamic Registration + +If the MCP server supports dynamic client registration, you can simply add an empty `oauth` object: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": {} + } + } +} +``` + +#### Pre-registered Client + +If you have client credentials from the MCP server provider: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": { + "clientId": "{env:MY_MCP_CLIENT_ID}", + "clientSecret": "{env:MY_MCP_CLIENT_SECRET}", + "scope": "tools:read tools:execute" + } + } + } +} +``` + +#### OAuth Options + +| Option | Type | Required | Description | +| -------------- | ------ | -------- | -------------------------------------------------------------------------------- | +| `clientId` | String | | OAuth client ID. If not provided, dynamic client registration will be attempted. | +| `clientSecret` | String | | OAuth client secret, if required by the authorization server. | +| `scope` | String | | OAuth scopes to request during authorization. | + +#### Authenticating + +After configuring an OAuth-enabled MCP server, you need to authenticate: + +```bash +# Authenticate with a specific MCP server +opencode mcp auth my-oauth-server + +# List all MCP servers and their auth status +opencode mcp list + +# Remove stored credentials +opencode mcp logout my-oauth-server +``` + +The `mcp auth` command will open your browser for authorization. After you authorize, OpenCode will store the tokens securely in `~/.local/share/opencode/mcp-auth.json`. + +--- + ## Manage Your MCPs are available as tools in OpenCode, alongside built-in tools. So you From 2dd37cba3245ba493d8255d05e0225bc7ecf57fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Wed, 3 Dec 2025 09:54:27 +0000 Subject: [PATCH 6/8] chore(sdk): regenerate JS SDK with new MCP OAuth status types - Add needs_auth status type for MCP servers requiring OAuth - Add needs_client_registration status type for servers without dynamic registration - Add new MCP OAuth API endpoints (mcp.auth.start, mcp.auth.callback, etc.) Note: This exposes a pre-existing hono-openapi bug where spurious path parameters leak into unrelated routes. This causes type errors in packages that use the SDK (e.g., @opencode-ai/slack). --- packages/sdk/js/src/gen/sdk.gen.ts | 91 +++++++++++--- packages/sdk/js/src/gen/types.gen.ts | 175 ++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 17 deletions(-) diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index d04277cbc819..af69b42ffd88 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -148,6 +148,18 @@ import type { McpAddData, McpAddResponses, McpAddErrors, + McpAuthRemoveData, + McpAuthRemoveResponses, + McpAuthRemoveErrors, + McpAuthStartData, + McpAuthStartResponses, + McpAuthStartErrors, + McpAuthCallbackData, + McpAuthCallbackResponses, + McpAuthCallbackErrors, + McpAuthAuthenticateData, + McpAuthAuthenticateResponses, + McpAuthAuthenticateErrors, LspStatusData, LspStatusResponses, FormatterStatusData, @@ -847,6 +859,68 @@ class App extends _HeyApiClient { } } +class Auth extends _HeyApiClient { + /** + * Remove OAuth credentials for an MCP server + */ + public remove(options: Options) { + return (options.client ?? this._client).delete({ + url: "/mcp/{name}/auth", + ...options, + }) + } + + /** + * Start OAuth authentication flow for an MCP server + */ + public start(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/auth", + ...options, + }) + } + + /** + * Complete OAuth authentication with authorization code + */ + public callback(options: Options) { + return (options.client ?? this._client).post({ + url: "/mcp/{name}/auth/callback", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } + + /** + * Start OAuth flow and wait for callback (opens browser) + */ + public authenticate(options: Options) { + return (options.client ?? this._client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + }, + ) + } + + /** + * Set authentication credentials + */ + public set(options: Options) { + return (options.client ?? this._client).put({ + url: "/auth/{id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }) + } +} + class Mcp extends _HeyApiClient { /** * Get MCP server status @@ -871,6 +945,7 @@ class Mcp extends _HeyApiClient { }, }) } + auth = new Auth({ client: this._client }) } class Lsp extends _HeyApiClient { @@ -1042,22 +1117,6 @@ class Tui extends _HeyApiClient { control = new Control({ client: this._client }) } -class Auth extends _HeyApiClient { - /** - * Set authentication credentials - */ - public set(options: Options) { - return (options.client ?? this._client).put({ - url: "/auth/{id}", - ...options, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - }) - } -} - class Event extends _HeyApiClient { /** * Get events diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index c640f41a7190..9e1254556ae9 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1103,6 +1103,24 @@ export type McpLocalConfig = { timeout?: number } +/** + * OAuth authentication configuration for the MCP server + */ +export type McpOAuthConfig = { + /** + * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. + */ + clientId?: string + /** + * OAuth client secret (if required by the authorization server) + */ + clientSecret?: string + /** + * OAuth scopes to request during authorization + */ + scope?: string +} + export type McpRemoteConfig = { /** * Type of MCP server connection @@ -1122,6 +1140,7 @@ export type McpRemoteConfig = { headers?: { [key: string]: string } + oauth?: McpOAuthConfig /** * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. */ @@ -1583,7 +1602,21 @@ export type McpStatusFailed = { error: string } -export type McpStatus = McpStatusConnected | McpStatusDisabled | McpStatusFailed +export type McpStatusNeedsAuth = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration" + error: string +} + +export type McpStatus = + | McpStatusConnected + | McpStatusDisabled + | McpStatusFailed + | McpStatusNeedsAuth + | McpStatusNeedsClientRegistration export type LspStatus = { id: string @@ -3321,6 +3354,146 @@ export type McpAddResponses = { export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] + +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true + } +} + +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] + +export type McpAuthStartData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth" +} + +export type McpAuthStartErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] + +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + /** + * URL to open in browser for authorization + */ + authorizationUrl: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + /** + * Authorization code from OAuth callback + */ + code: string + } + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth/callback" +} + +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] + +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] + +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/mcp/{name}/auth/authenticate" +} + +export type McpAuthAuthenticateErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] + +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus +} + +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] + export type LspStatusData = { body?: never path?: never From ecf95ca1b03cd5756298e1444d84cb78b4cb948b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Fri, 5 Dec 2025 08:33:06 +0000 Subject: [PATCH 7/8] feat(mcp): auto-detect OAuth for remote MCP servers Remote MCP servers now automatically support OAuth without requiring explicit configuration. When a server returns 401, OpenCode will initiate the OAuth flow using dynamic client registration (RFC 7591). Changes: - Config: oauth field now accepts McpOAuth | false (false to opt-out) - MCP: Auto-create McpOAuthProvider for all remote servers unless oauth: false - Server: Rename hasOAuthConfig() to supportsOAuth() to reflect new behavior - SDK: Update types for oauth field - Docs: Update MCP OAuth documentation with new automatic behavior Users can now simply configure: { "mcp": { "server": { "type": "remote", "url": "..." } } } And OAuth will be handled automatically when needed. --- packages/opencode/src/config/config.ts | 7 ++- packages/opencode/src/mcp/index.ts | 34 +++++++------ packages/opencode/src/server/server.ts | 12 ++--- packages/sdk/js/src/gen/types.gen.ts | 8 +-- packages/web/src/content/docs/mcp-servers.mdx | 51 ++++++++++++++----- 5 files changed, 72 insertions(+), 40 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c665a1f5493c..267278b747e9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -346,7 +346,12 @@ export namespace Config { url: z.string().describe("URL of the remote MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"), - oauth: McpOAuth.optional().describe("OAuth authentication configuration for the MCP server"), + oauth: z + .union([McpOAuth, z.literal(false)]) + .optional() + .describe( + "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", + ), timeout: z .number() .int() diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 9f4d7d4adc37..82a9a3d36b82 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -155,17 +155,19 @@ export namespace MCP { let status: Status | undefined = undefined if (mcp.type === "remote") { - const hasOAuth = !!mcp.oauth + // 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 (hasOAuth) { + if (!oauthDisabled) { authProvider = new McpOAuthProvider( key, mcp.url, { - clientId: mcp.oauth!.clientId, - clientSecret: mcp.oauth!.clientSecret, - scope: mcp.oauth!.scope, + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, }, { onRedirect: async (url) => { @@ -181,14 +183,14 @@ export namespace MCP { name: "StreamableHTTP", transport: new StreamableHTTPClientTransport(new URL(mcp.url), { authProvider, - requestInit: !hasOAuth && mcp.headers ? { headers: mcp.headers } : undefined, + requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined, }), }, { name: "SSE", transport: new SSEClientTransport(new URL(mcp.url), { authProvider, - requestInit: !hasOAuth && mcp.headers ? { headers: mcp.headers } : undefined, + requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined, }), }, ] @@ -366,22 +368,24 @@ export namespace MCP { throw new Error(`MCP server ${mcpName} is not a remote server`) } - if (!mcpConfig.oauth) { - throw new Error(`MCP server ${mcpName} does not have OAuth configured`) + 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: mcpConfig.oauth.clientId, - clientSecret: mcpConfig.oauth.clientSecret, - scope: mcpConfig.oauth.scope, + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, }, { onRedirect: async (url) => { @@ -493,12 +497,12 @@ export namespace MCP { } /** - * Check if an MCP server has OAuth configured. + * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled). */ - export async function hasOAuthConfig(mcpName: string): Promise { + export async function supportsOAuth(mcpName: string): Promise { const cfg = await Config.get() const mcpConfig = cfg.mcp?.[mcpName] - return mcpConfig?.type === "remote" && !!mcpConfig.oauth + return mcpConfig?.type === "remote" && mcpConfig.oauth !== false } /** diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index cf1f5fe45371..1a71410f8df2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1827,9 +1827,9 @@ export namespace Server { }), async (c) => { const name = c.req.param("name") - const hasOAuth = await MCP.hasOAuthConfig(name) - if (!hasOAuth) { - return c.json({ error: `MCP server ${name} does not have OAuth configured` }, 400) + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) } const result = await MCP.startAuth(name) return c.json(result) @@ -1884,9 +1884,9 @@ export namespace Server { }), async (c) => { const name = c.req.param("name") - const hasOAuth = await MCP.hasOAuthConfig(name) - if (!hasOAuth) { - return c.json({ error: `MCP server ${name} does not have OAuth configured` }, 400) + const supportsOAuth = await MCP.supportsOAuth(name) + if (!supportsOAuth) { + return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) } const status = await MCP.authenticate(name) return c.json(status) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 9e1254556ae9..5267c0e51e51 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1103,9 +1103,6 @@ export type McpLocalConfig = { timeout?: number } -/** - * OAuth authentication configuration for the MCP server - */ export type McpOAuthConfig = { /** * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. @@ -1140,7 +1137,10 @@ export type McpRemoteConfig = { headers?: { [key: string]: string } - oauth?: McpOAuthConfig + /** + * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. + */ + oauth?: McpOAuthConfig | false /** * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. */ diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 6b20cec84bc7..48b38442c7d3 100644 --- a/packages/web/src/content/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/mcp-servers.mdx @@ -149,14 +149,15 @@ Here the `url` is the URL of the remote MCP server and with the `headers` option ### OAuth -For remote MCP servers that require OAuth authentication, you can configure the `oauth` field. OpenCode supports: +OpenCode automatically handles OAuth authentication for remote MCP servers. When a server requires authentication, OpenCode will: -- **Dynamic Client Registration (RFC 7591)**: If the server supports it, OpenCode will automatically register as a client. -- **Pre-registered Clients**: If you have a client ID from the MCP server provider, you can configure it directly. +1. Detect the 401 response and initiate the OAuth flow +2. Use **Dynamic Client Registration (RFC 7591)** if supported by the server +3. Store tokens securely for future requests -#### Dynamic Registration +#### Automatic OAuth -If the MCP server supports dynamic client registration, you can simply add an empty `oauth` object: +For most OAuth-enabled MCP servers, no special configuration is needed. Just configure the remote server: ```json title="opencode.json" { @@ -164,16 +165,17 @@ If the MCP server supports dynamic client registration, you can simply add an em "mcp": { "my-oauth-server": { "type": "remote", - "url": "https://mcp.example.com/mcp", - "oauth": {} + "url": "https://mcp.example.com/mcp" } } } ``` +If the server requires authentication, OpenCode will prompt you to authenticate when you first try to use it. + #### Pre-registered Client -If you have client credentials from the MCP server provider: +If you have client credentials from the MCP server provider, you can configure them: ```json title="opencode.json" { @@ -192,17 +194,38 @@ If you have client credentials from the MCP server provider: } ``` +#### Disabling OAuth + +If you want to disable automatic OAuth for a server (e.g., for servers that use API keys instead), set `oauth` to `false`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-api-key-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "oauth": false, + "headers": { + "Authorization": "Bearer {env:MY_API_KEY}" + } + } + } +} +``` + #### OAuth Options -| Option | Type | Required | Description | -| -------------- | ------ | -------- | -------------------------------------------------------------------------------- | -| `clientId` | String | | OAuth client ID. If not provided, dynamic client registration will be attempted. | -| `clientSecret` | String | | OAuth client secret, if required by the authorization server. | -| `scope` | String | | OAuth scopes to request during authorization. | +| Option | Type | Required | Description | +| -------------- | --------------- | -------- | -------------------------------------------------------------------------------- | +| `oauth` | Object \| false | | OAuth config object, or `false` to disable OAuth auto-detection. | +| `clientId` | String | | OAuth client ID. If not provided, dynamic client registration will be attempted. | +| `clientSecret` | String | | OAuth client secret, if required by the authorization server. | +| `scope` | String | | OAuth scopes to request during authorization. | #### Authenticating -After configuring an OAuth-enabled MCP server, you need to authenticate: +You can manually trigger authentication or manage credentials: ```bash # Authenticate with a specific MCP server From 7ac793bdff5602fcbb7444c8b597e0f05316810a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Sat, 6 Dec 2025 10:13:50 +0000 Subject: [PATCH 8/8] chore: update hono-openapi to 1.1.2 Fixes a bug where path parameters (e.g., {name}) would incorrectly leak to all routes when using .use() middleware with validators. This was causing the generated OpenAPI spec to include spurious path parameters on routes that didn't have them. See: https://github.com/rhinobase/hono-openapi/pull/202 --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 5141ec417398..00fff5052d88 100644 --- a/bun.lock +++ b/bun.lock @@ -468,7 +468,7 @@ "diff": "8.0.2", "fuzzysort": "3.1.0", "hono": "4.10.7", - "hono-openapi": "1.1.1", + "hono-openapi": "1.1.2", "luxon": "3.6.1", "remeda": "2.26.0", "solid-js": "1.9.10", @@ -2526,7 +2526,7 @@ "hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="], - "hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="], + "hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], diff --git a/package.json b/package.json index a962be926058..2a7fad530b42 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "diff": "8.0.2", "ai": "5.0.97", "hono": "4.10.7", - "hono-openapi": "1.1.1", + "hono-openapi": "1.1.2", "fuzzysort": "3.1.0", "luxon": "3.6.1", "typescript": "5.8.2",