From 509e43d6f8f20413f7afceed753270f42bb1e702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Cruz?= Date: Sun, 7 Dec 2025 20:47:27 +0000 Subject: [PATCH] feat(mcp): add OAuth authentication support for remote MCP servers (#5014) --- bun.lock | 4 +- package.json | 2 +- packages/opencode/src/cli/cmd/mcp.ts | 334 +++++++++++++++++- .../cli/cmd/tui/component/dialog-status.tsx | 22 +- .../cli/cmd/tui/routes/session/sidebar.tsx | 22 +- packages/opencode/src/config/config.ts | 21 ++ packages/opencode/src/mcp/auth.ts | 82 +++++ packages/opencode/src/mcp/index.ts | 286 +++++++++++++-- packages/opencode/src/mcp/oauth-callback.ts | 203 +++++++++++ packages/opencode/src/mcp/oauth-provider.ts | 132 +++++++ packages/opencode/src/server/server.ts | 111 ++++++ packages/sdk/js/src/gen/sdk.gen.ts | 91 ++++- packages/sdk/js/src/gen/types.gen.ts | 175 ++++++++- packages/web/src/content/docs/mcp-servers.mdx | 100 +++++- 14 files changed, 1511 insertions(+), 74 deletions(-) create mode 100644 packages/opencode/src/mcp/auth.ts create mode 100644 packages/opencode/src/mcp/oauth-callback.ts create mode 100644 packages/opencode/src/mcp/oauth-provider.ts diff --git a/bun.lock b/bun.lock index fe395e1f5..5db039ab8 100644 --- a/bun.lock +++ b/bun.lock @@ -473,7 +473,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", @@ -2537,7 +2537,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 fd559eed0..f0e80a7ae 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", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index df0046b23..9ca4b3bff 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/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index e427e24e9..f3ce4d4de 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 c63f5116a..e734fdc48 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 + diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d38de8a94..267278b74 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -325,12 +325,33 @@ 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: 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/auth.ts b/packages/opencode/src/mcp/auth.ts new file mode 100644 index 000000000..385cb3c73 --- /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/index.ts b/packages/opencode/src/mcp/index.ts index a68a1716f..82a9a3d36 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,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 { + 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 supports OAuth (remote servers support OAuth by default unless explicitly disabled). + */ + export async function supportsOAuth(mcpName: string): Promise { + 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 { + 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 000000000..67bb51684 --- /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 + } +} diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts new file mode 100644 index 000000000..584eca8e8 --- /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 } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7a105e746..1a71410f8 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 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) + }, + ) + .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 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) + }, + ) + .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({ diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index d04277cbc..af69b42ff 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 c640f41a7..5267c0e51 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1103,6 +1103,21 @@ export type McpLocalConfig = { timeout?: number } +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 +1137,10 @@ export type McpRemoteConfig = { headers?: { [key: string]: string } + /** + * 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. */ @@ -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 diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/mcp-servers.mdx index 6e2cb7be1..48b38442c 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,106 @@ 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 + +OpenCode automatically handles OAuth authentication for remote MCP servers. When a server requires authentication, OpenCode will: + +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 + +#### Automatic OAuth + +For most OAuth-enabled MCP servers, no special configuration is needed. Just configure the remote server: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "my-oauth-server": { + "type": "remote", + "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, you can configure them: + +```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" + } + } + } +} +``` + +#### 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 | +| -------------- | --------------- | -------- | -------------------------------------------------------------------------------- | +| `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 + +You can manually trigger authentication or manage credentials: + +```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