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

This commit is contained in:
André Cruz 2025-12-07 20:47:27 +00:00 committed by GitHub
parent e693192e06
commit 509e43d6f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1511 additions and 74 deletions

View file

@ -473,7 +473,7 @@
"diff": "8.0.2",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.1",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"remeda": "2.26.0",
"solid-js": "1.9.10",
@ -2537,7 +2537,7 @@
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
"hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],

View file

@ -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",

View file

@ -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")

View file

@ -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>

View file

@ -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>

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

@ -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

View file

@ -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

View file

@ -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