Просмотр исходного кода

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

André Cruz 4 месяцев назад
Родитель
Сommit
509e43d6f8

+ 2 - 2
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].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": ["[email protected].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": ["[email protected]", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
 

+ 1 - 1
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",

+ 327 - 7
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")

+ 16 - 6
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>

+ 15 - 7
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>

+ 21 - 0
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()

+ 82 - 0
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)
+    }
+  }
+}

+ 256 - 30
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
+  }
 }

+ 203 - 0
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
+  }
+}

+ 132 - 0
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 }

+ 111 - 0
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({

+ 75 - 16
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

+ 174 - 1
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

+ 96 - 4
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