summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndré Cruz <[email protected]>2025-12-07 20:47:27 +0000
committerGitHub <[email protected]>2025-12-07 15:47:27 -0500
commit509e43d6f8f20413f7afceed753270f42bb1e702 (patch)
tree031d087ba2d47eacfedcc9ccadca7881eb2be99b
parente693192e0632504a2a3fb80e3f84a9670dc77efd (diff)
downloadopencode-509e43d6f8f20413f7afceed753270f42bb1e702.tar.gz
opencode-509e43d6f8f20413f7afceed753270f42bb1e702.zip
feat(mcp): add OAuth authentication support for remote MCP servers (#5014)
-rw-r--r--bun.lock4
-rw-r--r--package.json2
-rw-r--r--packages/opencode/src/cli/cmd/mcp.ts334
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx22
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx22
-rw-r--r--packages/opencode/src/config/config.ts21
-rw-r--r--packages/opencode/src/mcp/auth.ts82
-rw-r--r--packages/opencode/src/mcp/index.ts286
-rw-r--r--packages/opencode/src/mcp/oauth-callback.ts203
-rw-r--r--packages/opencode/src/mcp/oauth-provider.ts132
-rw-r--r--packages/opencode/src/server/server.ts111
-rw-r--r--packages/sdk/js/src/gen/sdk.gen.ts91
-rw-r--r--packages/sdk/js/src/gen/types.gen.ts175
-rw-r--r--packages/web/src/content/docs/mcp-servers.mdx100
14 files changed, 1511 insertions, 74 deletions
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": ["[email protected]", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
- "hono-openapi": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "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() {
<text
flexShrink={0}
style={{
- fg: {
- connected: theme.success,
- failed: theme.error,
- disabled: theme.textMuted,
- }[item.status],
+ fg: (
+ {
+ connected: theme.success,
+ failed: theme.error,
+ disabled: theme.textMuted,
+ needs_auth: theme.warning,
+ needs_client_registration: theme.error,
+ } as Record<string, typeof theme.success>
+ )[item.status],
}}
>
@@ -40,10 +44,16 @@ export function DialogStatus() {
<text fg={theme.text} wrapMode="word">
<b>{key}</b>{" "}
<span style={{ fg: theme.textMuted }}>
- <Switch>
+ <Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
+ <Match when={(item.status as string) === "needs_auth"}>
+ Needs authentication (run: opencode mcp auth {key})
+ </Match>
+ <Match when={(item.status as string) === "needs_client_registration" && item}>
+ {(val) => (val() as { error: string }).error}
+ </Match>
</Switch>
</span>
</text>
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 }) {
<text
flexShrink={0}
style={{
- fg: {
- connected: theme.success,
- failed: theme.error,
- disabled: theme.textMuted,
- }[item.status],
+ fg: (
+ {
+ connected: theme.success,
+ failed: theme.error,
+ disabled: theme.textMuted,
+ needs_auth: theme.warning,
+ needs_client_registration: theme.error,
+ } as Record<string, typeof theme.success>
+ )[item.status],
}}
>
@@ -116,10 +120,14 @@ export function Sidebar(props: { sessionID: string }) {
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
- <Switch>
+ <Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
- <Match when={item.status === "disabled"}>Disabled in configuration</Match>
+ <Match when={item.status === "disabled"}>Disabled</Match>
+ <Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
+ <Match when={(item.status as string) === "needs_client_registration"}>
+ Needs client ID
+ </Match>
</Switch>
</span>
</text>
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<typeof McpOAuth>
+
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<typeof Tokens>
+
+ export const ClientInfo = z.object({
+ clientId: z.string(),
+ clientSecret: z.string().optional(),
+ clientIdIssuedAt: z.number().optional(),
+ clientSecretExpiresAt: z.number().optional(),
+ })
+ export type ClientInfo = z.infer<typeof ClientInfo>
+
+ export const Entry = z.object({
+ tokens: Tokens.optional(),
+ clientInfo: ClientInfo.optional(),
+ codeVerifier: z.string().optional(),
+ })
+ export type Entry = z.infer<typeof Entry>
+
+ const filepath = path.join(Global.Path.data, "mcp-auth.json")
+
+ export async function get(mcpName: string): Promise<Entry | undefined> {
+ const data = await all()
+ return data[mcpName]
+ }
+
+ export async function all(): Promise<Record<string, Entry>> {
+ const file = Bun.file(filepath)
+ return file.json().catch(() => ({}))
+ }
+
+ export async function set(mcpName: string, entry: Entry): Promise<void> {
+ const file = Bun.file(filepath)
+ const data = await all()
+ await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2))
+ await fs.chmod(file.name!, 0o600)
+ }
+
+ export async function remove(mcpName: string): Promise<void> {
+ const file = Bun.file(filepath)
+ const data = await all()
+ delete data[mcpName]
+ await Bun.write(file, JSON.stringify(data, null, 2))
+ await fs.chmod(file.name!, 0o600)
+ }
+
+ export async function updateTokens(mcpName: string, tokens: Tokens): Promise<void> {
+ const entry = (await get(mcpName)) ?? {}
+ entry.tokens = tokens
+ await set(mcpName, entry)
+ }
+
+ export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo): Promise<void> {
+ const entry = (await get(mcpName)) ?? {}
+ entry.clientInfo = clientInfo
+ await set(mcpName, entry)
+ }
+
+ export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
+ const entry = (await get(mcpName)) ?? {}
+ entry.codeVerifier = codeVerifier
+ await set(mcpName, entry)
+ }
+
+ export async function clearCodeVerifier(mcpName: string): Promise<void> {
+ const entry = await get(mcpName)
+ if (entry) {
+ delete entry.codeVerifier
+ await set(mcpName, entry)
+ }
+ }
+}
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<typeof Status>
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
+ // Store transports for OAuth servers to allow finishing auth
+ type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
+ const pendingOAuthTransports = new Map<string, TransportWithAuth>()
+
const state = Instance.state(
async () => {
const cfg = await Config.get()
@@ -87,6 +111,7 @@ export namespace MCP {
}),
),
)
+ pendingOAuthTransports.clear()
},
)
@@ -120,58 +145,98 @@ export namespace MCP {
async function create(key: string, mcp: Config.Mcp) {
if (mcp.enabled === false) {
log.info("mcp server disabled", { key })
- return
+ return {
+ mcpClient: undefined,
+ status: { status: "disabled" as const },
+ }
}
log.info("found", { key, type: mcp.type })
let mcpClient: MCPClient | undefined
let status: Status | undefined = undefined
if (mcp.type === "remote") {
- const transports = [
+ // OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
+ const oauthDisabled = mcp.oauth === false
+ const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
+ let authProvider: McpOAuthProvider | undefined
+
+ if (!oauthDisabled) {
+ authProvider = new McpOAuthProvider(
+ key,
+ mcp.url,
+ {
+ clientId: oauthConfig?.clientId,
+ clientSecret: oauthConfig?.clientSecret,
+ scope: oauthConfig?.scope,
+ },
+ {
+ onRedirect: async (url) => {
+ log.info("oauth redirect requested", { key, url: url.toString() })
+ // Store the URL - actual browser opening is handled by startAuth
+ },
+ },
+ )
+ }
+
+ const transports: Array<{ name: string; transport: TransportWithAuth }> = [
{
name: "StreamableHTTP",
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
- requestInit: {
- headers: mcp.headers,
- },
+ authProvider,
+ requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
{
name: "SSE",
transport: new SSEClientTransport(new URL(mcp.url), {
- requestInit: {
- headers: mcp.headers,
- },
+ authProvider,
+ requestInit: oauthDisabled && mcp.headers ? { headers: mcp.headers } : undefined,
}),
},
]
+
let lastError: Error | undefined
for (const { name, transport } of transports) {
- const result = await experimental_createMCPClient({
- name: "opencode",
- transport,
- })
- .then((client) => {
- log.info("connected", { key, transport: name })
- mcpClient = client
- status = { status: "connected" }
- return true
+ try {
+ mcpClient = await experimental_createMCPClient({
+ name: "opencode",
+ transport,
})
- .catch((error) => {
- lastError = error instanceof Error ? error : new Error(String(error))
- log.debug("transport connection failed", {
- key,
- transport: name,
- url: mcp.url,
- error: lastError.message,
- })
- status = {
- status: "failed" as const,
- error: lastError.message,
+ log.info("connected", { key, transport: name })
+ status = { status: "connected" }
+ break
+ } catch (error) {
+ lastError = error instanceof Error ? error : new Error(String(error))
+
+ // Handle OAuth-specific errors
+ if (error instanceof UnauthorizedError) {
+ log.info("mcp server requires authentication", { key, transport: name })
+
+ // Check if this is a "needs registration" error
+ if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
+ status = {
+ status: "needs_client_registration" as const,
+ error: "Server does not support dynamic client registration. Please provide clientId in config.",
+ }
+ } else {
+ // Store transport for later finishAuth call
+ pendingOAuthTransports.set(key, transport)
+ status = { status: "needs_auth" as const }
}
- return false
+ break
+ }
+
+ log.debug("transport connection failed", {
+ key,
+ transport: name,
+ url: mcp.url,
+ error: lastError.message,
})
- if (result) break
+ status = {
+ status: "failed" as const,
+ error: lastError.message,
+ }
+ }
}
}
@@ -286,4 +351,165 @@ export namespace MCP {
}
return result
}
+
+ /**
+ * Start OAuth authentication flow for an MCP server.
+ * Returns the authorization URL that should be opened in a browser.
+ */
+ export async function startAuth(mcpName: string): Promise<{ authorizationUrl: string }> {
+ const cfg = await Config.get()
+ const mcpConfig = cfg.mcp?.[mcpName]
+
+ if (!mcpConfig) {
+ throw new Error(`MCP server not found: ${mcpName}`)
+ }
+
+ if (mcpConfig.type !== "remote") {
+ throw new Error(`MCP server ${mcpName} is not a remote server`)
+ }
+
+ if (mcpConfig.oauth === false) {
+ throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
+ }
+
+ // Start the callback server
+ await McpOAuthCallback.ensureRunning()
+
+ // Create a new auth provider for this flow
+ // OAuth config is optional - if not provided, we'll use auto-discovery
+ const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
+ let capturedUrl: URL | undefined
+ const authProvider = new McpOAuthProvider(
+ mcpName,
+ mcpConfig.url,
+ {
+ clientId: oauthConfig?.clientId,
+ clientSecret: oauthConfig?.clientSecret,
+ scope: oauthConfig?.scope,
+ },
+ {
+ onRedirect: async (url) => {
+ capturedUrl = url
+ },
+ },
+ )
+
+ // Create transport with auth provider
+ const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), {
+ authProvider,
+ })
+
+ // Try to connect - this will trigger the OAuth flow
+ try {
+ await experimental_createMCPClient({
+ name: "opencode",
+ transport,
+ })
+ // If we get here, we're already authenticated
+ return { authorizationUrl: "" }
+ } catch (error) {
+ if (error instanceof UnauthorizedError && capturedUrl) {
+ // Store transport for finishAuth
+ pendingOAuthTransports.set(mcpName, transport)
+ return { authorizationUrl: capturedUrl.toString() }
+ }
+ throw error
+ }
+ }
+
+ /**
+ * Complete OAuth authentication after user authorizes in browser.
+ * Opens the browser and waits for callback.
+ */
+ export async function authenticate(mcpName: string): Promise<Status> {
+ const { authorizationUrl } = await startAuth(mcpName)
+
+ if (!authorizationUrl) {
+ // Already authenticated
+ const s = await state()
+ return s.status[mcpName] ?? { status: "connected" }
+ }
+
+ // Extract state from authorization URL to use as callback key
+ // If no state parameter, use mcpName as fallback
+ const authUrl = new URL(authorizationUrl)
+ const oauthState = authUrl.searchParams.get("state") ?? mcpName
+
+ // Open browser
+ log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
+ await open(authorizationUrl)
+
+ // Wait for callback using the OAuth state parameter (or mcpName as fallback)
+ const code = await McpOAuthCallback.waitForCallback(oauthState)
+
+ // Finish auth
+ return finishAuth(mcpName, code)
+ }
+
+ /**
+ * Complete OAuth authentication with the authorization code.
+ */
+ export async function finishAuth(mcpName: string, authorizationCode: string): Promise<Status> {
+ const transport = pendingOAuthTransports.get(mcpName)
+
+ if (!transport) {
+ throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
+ }
+
+ try {
+ // Call finishAuth on the transport
+ await transport.finishAuth(authorizationCode)
+
+ // Clear the code verifier after successful auth
+ await McpAuth.clearCodeVerifier(mcpName)
+
+ // Now try to reconnect
+ const cfg = await Config.get()
+ const mcpConfig = cfg.mcp?.[mcpName]
+
+ if (!mcpConfig) {
+ throw new Error(`MCP server not found: ${mcpName}`)
+ }
+
+ // Re-add the MCP server to establish connection
+ pendingOAuthTransports.delete(mcpName)
+ const result = await add(mcpName, mcpConfig)
+
+ const statusRecord = result.status as Record<string, Status>
+ return statusRecord[mcpName] ?? { status: "failed", error: "Unknown error after auth" }
+ } catch (error) {
+ log.error("failed to finish oauth", { mcpName, error })
+ return {
+ status: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ }
+ }
+ }
+
+ /**
+ * Remove OAuth credentials for an MCP server.
+ */
+ export async function removeAuth(mcpName: string): Promise<void> {
+ await McpAuth.remove(mcpName)
+ McpOAuthCallback.cancelPending(mcpName)
+ pendingOAuthTransports.delete(mcpName)
+ log.info("removed oauth credentials", { mcpName })
+ }
+
+ /**
+ * Check if an MCP server supports OAuth (remote servers support OAuth by default unless explicitly disabled).
+ */
+ export async function supportsOAuth(mcpName: string): Promise<boolean> {
+ const cfg = await Config.get()
+ const mcpConfig = cfg.mcp?.[mcpName]
+ return mcpConfig?.type === "remote" && mcpConfig.oauth !== false
+ }
+
+ /**
+ * Check if an MCP server has stored OAuth tokens.
+ */
+ export async function hasStoredTokens(mcpName: string): Promise<boolean> {
+ const entry = await McpAuth.get(mcpName)
+ return !!entry?.tokens
+ }
}
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 = `<!DOCTYPE html>
+<html>
+<head>
+ <title>OpenCode - Authorization Successful</title>
+ <style>
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
+ .container { text-align: center; padding: 2rem; }
+ h1 { color: #4ade80; margin-bottom: 1rem; }
+ p { color: #aaa; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <h1>Authorization Successful</h1>
+ <p>You can close this window and return to OpenCode.</p>
+ </div>
+ <script>setTimeout(() => window.close(), 2000);</script>
+</body>
+</html>`
+
+const HTML_ERROR = (error: string) => `<!DOCTYPE html>
+<html>
+<head>
+ <title>OpenCode - Authorization Failed</title>
+ <style>
+ body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
+ .container { text-align: center; padding: 2rem; }
+ h1 { color: #f87171; margin-bottom: 1rem; }
+ p { color: #aaa; }
+ .error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <h1>Authorization Failed</h1>
+ <p>An error occurred during authorization.</p>
+ <div class="error">${error}</div>
+ </div>
+</body>
+</html>`
+
+interface PendingAuth {
+ resolve: (code: string) => void
+ reject: (error: Error) => void
+ timeout: ReturnType<typeof setTimeout>
+}
+
+export namespace McpOAuthCallback {
+ let server: ReturnType<typeof Bun.serve> | undefined
+ const pendingAuths = new Map<string, PendingAuth>()
+
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
+
+ export async function ensureRunning(): Promise<void> {
+ if (server) return
+
+ const running = await isPortInUse()
+ if (running) {
+ log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
+ return
+ }
+
+ server = Bun.serve({
+ port: OAUTH_CALLBACK_PORT,
+ fetch(req) {
+ const url = new URL(req.url)
+
+ if (url.pathname !== OAUTH_CALLBACK_PATH) {
+ return new Response("Not found", { status: 404 })
+ }
+
+ const code = url.searchParams.get("code")
+ const state = url.searchParams.get("state")
+ const error = url.searchParams.get("error")
+ const errorDescription = url.searchParams.get("error_description")
+
+ log.info("received oauth callback", { hasCode: !!code, state, error })
+
+ if (error) {
+ const errorMsg = errorDescription || error
+ if (state && pendingAuths.has(state)) {
+ const pending = pendingAuths.get(state)!
+ clearTimeout(pending.timeout)
+ pendingAuths.delete(state)
+ pending.reject(new Error(errorMsg))
+ }
+ return new Response(HTML_ERROR(errorMsg), {
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ if (!code) {
+ return new Response(HTML_ERROR("No authorization code provided"), {
+ status: 400,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ // Try to find the pending auth by state parameter, or if no state, use the single pending auth
+ let pending: PendingAuth | undefined
+ let pendingKey: string | undefined
+
+ if (state && pendingAuths.has(state)) {
+ pending = pendingAuths.get(state)!
+ pendingKey = state
+ } else if (!state && pendingAuths.size === 1) {
+ // No state parameter but only one pending auth - use it
+ const [key, value] = pendingAuths.entries().next().value as [string, PendingAuth]
+ pending = value
+ pendingKey = key
+ log.info("no state parameter, using single pending auth", { key })
+ }
+
+ if (!pending || !pendingKey) {
+ const errorMsg = !state
+ ? "No state parameter provided and multiple pending authorizations"
+ : "Unknown or expired authorization request"
+ return new Response(HTML_ERROR(errorMsg), {
+ status: 400,
+ headers: { "Content-Type": "text/html" },
+ })
+ }
+
+ clearTimeout(pending.timeout)
+ pendingAuths.delete(pendingKey)
+ pending.resolve(code)
+
+ return new Response(HTML_SUCCESS, {
+ headers: { "Content-Type": "text/html" },
+ })
+ },
+ })
+
+ log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
+ }
+
+ export function waitForCallback(mcpName: string): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ if (pendingAuths.has(mcpName)) {
+ pendingAuths.delete(mcpName)
+ reject(new Error("OAuth callback timeout - authorization took too long"))
+ }
+ }, CALLBACK_TIMEOUT_MS)
+
+ pendingAuths.set(mcpName, { resolve, reject, timeout })
+ })
+ }
+
+ export function cancelPending(mcpName: string): void {
+ const pending = pendingAuths.get(mcpName)
+ if (pending) {
+ clearTimeout(pending.timeout)
+ pendingAuths.delete(mcpName)
+ pending.reject(new Error("Authorization cancelled"))
+ }
+ }
+
+ export async function isPortInUse(): Promise<boolean> {
+ return new Promise((resolve) => {
+ Bun.connect({
+ hostname: "127.0.0.1",
+ port: OAUTH_CALLBACK_PORT,
+ socket: {
+ open(socket) {
+ socket.end()
+ resolve(true)
+ },
+ error() {
+ resolve(false)
+ },
+ data() {},
+ close() {},
+ },
+ }).catch(() => {
+ resolve(false)
+ })
+ })
+ }
+
+ export async function stop(): Promise<void> {
+ if (server) {
+ server.stop()
+ server = undefined
+ log.info("oauth callback server stopped")
+ }
+
+ for (const [name, pending] of pendingAuths) {
+ clearTimeout(pending.timeout)
+ pending.reject(new Error("OAuth callback server stopped"))
+ }
+ pendingAuths.clear()
+ }
+
+ export function isRunning(): boolean {
+ return server !== undefined
+ }
+}
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<void>
+}
+
+export class McpOAuthProvider implements OAuthClientProvider {
+ constructor(
+ private mcpName: string,
+ private serverUrl: string,
+ private config: McpOAuthConfig,
+ private callbacks: McpOAuthCallbacks,
+ ) {}
+
+ get redirectUrl(): string {
+ return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
+ }
+
+ get clientMetadata(): OAuthClientMetadata {
+ return {
+ redirect_uris: [this.redirectUrl],
+ client_name: "OpenCode",
+ client_uri: "https://opencode.ai",
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
+ }
+ }
+
+ async clientInformation(): Promise<OAuthClientInformation | undefined> {
+ // Check config first (pre-registered client)
+ if (this.config.clientId) {
+ return {
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ }
+ }
+
+ // Check stored client info (from dynamic registration)
+ const entry = await McpAuth.get(this.mcpName)
+ if (entry?.clientInfo) {
+ // Check if client secret has expired
+ if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
+ log.info("client secret expired, need to re-register", { mcpName: this.mcpName })
+ return undefined
+ }
+ return {
+ client_id: entry.clientInfo.clientId,
+ client_secret: entry.clientInfo.clientSecret,
+ }
+ }
+
+ // No client info - will trigger dynamic registration
+ return undefined
+ }
+
+ async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
+ await McpAuth.updateClientInfo(this.mcpName, {
+ clientId: info.client_id,
+ clientSecret: info.client_secret,
+ clientIdIssuedAt: info.client_id_issued_at,
+ clientSecretExpiresAt: info.client_secret_expires_at,
+ })
+ log.info("saved dynamically registered client", {
+ mcpName: this.mcpName,
+ clientId: info.client_id,
+ })
+ }
+
+ async tokens(): Promise<OAuthTokens | undefined> {
+ const entry = await McpAuth.get(this.mcpName)
+ if (!entry?.tokens) return undefined
+
+ return {
+ access_token: entry.tokens.accessToken,
+ token_type: "Bearer",
+ refresh_token: entry.tokens.refreshToken,
+ expires_in: entry.tokens.expiresAt
+ ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
+ : undefined,
+ scope: entry.tokens.scope,
+ }
+ }
+
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
+ await McpAuth.updateTokens(this.mcpName, {
+ accessToken: tokens.access_token,
+ refreshToken: tokens.refresh_token,
+ expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
+ scope: tokens.scope,
+ })
+ log.info("saved oauth tokens", { mcpName: this.mcpName })
+ }
+
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
+ log.info("redirecting to authorization", { mcpName: this.mcpName, url: authorizationUrl.toString() })
+ await this.callbacks.onRedirect(authorizationUrl)
+ }
+
+ async saveCodeVerifier(codeVerifier: string): Promise<void> {
+ await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
+ }
+
+ async codeVerifier(): Promise<string> {
+ const entry = await McpAuth.get(this.mcpName)
+ if (!entry?.codeVerifier) {
+ throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
+ }
+ return entry.codeVerifier
+ }
+}
+
+export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
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<ThrowOnError extends boolean = false>(options: Options<McpAuthRemoveData, ThrowOnError>) {
+ return (options.client ?? this._client).delete<McpAuthRemoveResponses, McpAuthRemoveErrors, ThrowOnError>({
+ url: "/mcp/{name}/auth",
+ ...options,
+ })
+ }
+
+ /**
+ * Start OAuth authentication flow for an MCP server
+ */
+ public start<ThrowOnError extends boolean = false>(options: Options<McpAuthStartData, ThrowOnError>) {
+ return (options.client ?? this._client).post<McpAuthStartResponses, McpAuthStartErrors, ThrowOnError>({
+ url: "/mcp/{name}/auth",
+ ...options,
+ })
+ }
+
+ /**
+ * Complete OAuth authentication with authorization code
+ */
+ public callback<ThrowOnError extends boolean = false>(options: Options<McpAuthCallbackData, ThrowOnError>) {
+ return (options.client ?? this._client).post<McpAuthCallbackResponses, McpAuthCallbackErrors, ThrowOnError>({
+ url: "/mcp/{name}/auth/callback",
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...options.headers,
+ },
+ })
+ }
+
+ /**
+ * Start OAuth flow and wait for callback (opens browser)
+ */
+ public authenticate<ThrowOnError extends boolean = false>(options: Options<McpAuthAuthenticateData, ThrowOnError>) {
+ return (options.client ?? this._client).post<McpAuthAuthenticateResponses, McpAuthAuthenticateErrors, ThrowOnError>(
+ {
+ url: "/mcp/{name}/auth/authenticate",
+ ...options,
+ },
+ )
+ }
+
+ /**
+ * Set authentication credentials
+ */
+ public set<ThrowOnError extends boolean = false>(options: Options<AuthSetData, ThrowOnError>) {
+ return (options.client ?? this._client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
+ 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<ThrowOnError extends boolean = false>(options: Options<AuthSetData, ThrowOnError>) {
- return (options.client ?? this._client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
- 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
@@ -1123,6 +1138,10 @@ export type McpRemoteConfig = {
[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.
*/
timeout?: number
@@ -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