mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
feat(mcp): add OAuth authentication support for remote MCP servers (#5014)
This commit is contained in:
parent
e693192e06
commit
509e43d6f8
14 changed files with 1511 additions and 74 deletions
4
bun.lock
4
bun.lock
|
|
@ -473,7 +473,7 @@
|
|||
"diff": "8.0.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.1",
|
||||
"hono-openapi": "1.1.2",
|
||||
"luxon": "3.6.1",
|
||||
"remeda": "2.26.0",
|
||||
"solid-js": "1.9.10",
|
||||
|
|
@ -2537,7 +2537,7 @@
|
|||
|
||||
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
|
||||
|
||||
"hono-openapi": ["hono-openapi@1.1.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.1.2", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.8", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-AC3HNhZYPHhnZdSy2Je7GDoTTNxPos6rKRQKVDBbSilY3cWJPqsxRnN6zA4pU7tfxmQEMTqkiLXbw6sAaemB8Q=="],
|
||||
"hono-openapi": ["hono-openapi@1.1.2", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.8.3", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
packages/opencode/src/mcp/auth.ts
Normal file
82
packages/opencode/src/mcp/auth.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
packages/opencode/src/mcp/oauth-callback.ts
Normal file
203
packages/opencode/src/mcp/oauth-callback.ts
Normal 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
|
||||
}
|
||||
}
|
||||
132
packages/opencode/src/mcp/oauth-provider.ts
Normal file
132
packages/opencode/src/mcp/oauth-provider.ts
Normal 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 }
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue