Files
anomalyco_opencode/packages/opencode/src/cli/cmd/mcp.ts
T
2026-06-01 04:34:11 +00:00

1036 lines
34 KiB
TypeScript

import { cmd } from "./cmd"
import { effectCmd, fail } from "../effect-cmd"
import { Cause } from "effect"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "@/config/config"
import { ConfigMCP } from "../../config/mcp"
import { InstanceRef } from "@/effect/instance-ref"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "@/util/filesystem"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
switch (status) {
case "authenticated":
return "✓"
case "expired":
return "⚠"
case "not_authenticated":
return "✗"
}
}
function getAuthStatusText(status: MCP.AuthStatus): string {
switch (status) {
case "authenticated":
return "authenticated"
case "expired":
return "expired"
case "not_authenticated":
return "not authenticated"
}
}
type McpEntry = NonNullable<Config.Info["mcp"]>[string]
type McpConfigured = ConfigMCP.Info
function isMcpConfigured(config: McpEntry): config is McpConfigured {
return typeof config === "object" && config !== null && "type" in config
}
type McpRemote = Extract<McpConfigured, { type: "remote" }>
function isMcpRemote(config: McpEntry): config is McpRemote {
return isMcpConfigured(config) && config.type === "remote"
}
type McpAddArgs = {
_?: Array<string | number>
"--"?: string[]
name?: string
args?: string[]
type?: "local" | "remote"
env?: string[]
header?: string[]
scope?: "project" | "global"
global?: boolean
enabled?: boolean
timeout?: number
oauth?: boolean
oauthClientId?: string
oauthClientSecret?: string
oauthScope?: string
oauthCallbackPort?: number
oauthRedirectUri?: string
}
function configuredServers(config: Config.Info) {
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
}
function oauthServers(config: Config.Info) {
return configuredServers(config).filter(
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
)
}
function listState() {
return Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const statuses = yield* mcp.status()
const stored = yield* Effect.all(
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
{ concurrency: "unbounded" },
)
return { config, statuses, stored }
})
}
function authState() {
return Effect.gen(function* () {
const cfg = yield* Config.Service
const mcp = yield* MCP.Service
const config = yield* cfg.get()
const auth = yield* Effect.all(
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
{ concurrency: "unbounded" },
)
return { config, auth }
})
}
export const McpCommand = cmd({
command: "mcp",
describe: "manage MCP (Model Context Protocol) servers",
builder: (yargs) =>
yargs
.command(McpAddCommand)
.command(McpListCommand)
.command(McpAuthCommand)
.command(McpLogoutCommand)
.command(McpDebugCommand)
.demandCommand(),
async handler() {},
})
export const McpListCommand = effectCmd({
command: "list",
aliases: ["ls"],
describe: "list MCP servers and their status",
handler: Effect.fn("Cli.mcp.list")(function* () {
UI.empty()
prompts.intro("MCP Servers")
const { config, statuses, stored } = yield* listState()
const servers = configuredServers(config)
if (servers.length === 0) {
prompts.log.warn("No MCP servers configured")
prompts.outro("Add servers with: opencode mcp add")
return
}
for (const [name, serverConfig] of servers) {
const status = statuses[name]
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
const hasStoredTokens = stored[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(`${servers.length} server(s)`)
}),
})
export const McpAuthCommand = effectCmd({
command: "auth [name]",
describe: "authenticate with an OAuth-enabled MCP server",
builder: (yargs) =>
yargs
.positional("name", {
describe: "name of the MCP server",
type: "string",
})
.command(McpAuthListCommand),
handler: Effect.fn("Cli.mcp.auth")(function* (args) {
UI.empty()
prompts.intro("MCP OAuth Authentication")
const { config, auth } = yield* authState()
const mcpServers = config.mcp ?? {}
const servers = oauthServers(config)
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
prompts.log.info(`
"mcp": {
"my-server": {
"type": "remote",
"url": "https://example.com/mcp"
}
}`)
prompts.outro("Done")
return
}
let serverName = args.name
if (!serverName) {
// Build options with auth status
const options = servers.map(([name, cfg]) => {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = cfg.url
return {
label: `${icon} ${name} (${statusText})`,
value: name,
hint: url,
}
})
const selected = yield* Effect.promise(() =>
prompts.select({
message: "Select MCP server to authenticate",
options,
}),
)
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 (!isMcpRemote(serverConfig) || serverConfig.oauth === false) {
prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`)
prompts.outro("Done")
return
}
// Check if already authenticated
const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))
if (authStatus === "authenticated") {
const confirm = yield* Effect.promise(() =>
prompts.confirm({
message: `${serverName} already has valid credentials. Re-authenticate?`,
}),
)
if (prompts.isCancel(confirm) || !confirm) {
prompts.outro("Cancelled")
return
}
} else if (authStatus === "expired") {
prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`)
}
const spinner = prompts.spinner()
spinner.start("Starting OAuth flow...")
// Subscribe to browser open failure events to show URL for manual opening
const events = yield* EventV2Bridge.Service
const unsubscribe = yield* events.listen((event) => {
if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void
const data = event.data as EventV2.Data<typeof MCP.BrowserOpenFailed>
if (data.mcpName === serverName) {
spinner.stop("Could not open browser automatically")
prompts.log.warn("Please open this URL in your browser to authenticate:")
prompts.log.info(data.url)
spinner.start("Waiting for authorization...")
}
return Effect.void
})
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
Effect.tap((status) =>
Effect.sync(() => {
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)
}
}),
),
Effect.catchCause((cause) =>
Effect.sync(() => {
spinner.stop("Authentication failed", 1)
const error = Cause.squash(cause)
prompts.log.error(error instanceof Error ? error.message : String(error))
}),
),
Effect.ensuring(unsubscribe),
)
prompts.outro("Done")
}),
})
export const McpAuthListCommand = effectCmd({
command: "list",
aliases: ["ls"],
describe: "list OAuth-capable MCP servers and their auth status",
handler: Effect.fn("Cli.mcp.auth.list")(function* () {
UI.empty()
prompts.intro("MCP OAuth Status")
const { config, auth } = yield* authState()
const servers = oauthServers(config)
if (servers.length === 0) {
prompts.log.warn("No OAuth-capable MCP servers configured")
prompts.outro("Done")
return
}
for (const [name, serverConfig] of servers) {
const authStatus = auth[name]
const icon = getAuthStatusIcon(authStatus)
const statusText = getAuthStatusText(authStatus)
const url = serverConfig.url
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
}
prompts.outro(`${servers.length} OAuth-capable server(s)`)
}),
})
export const McpLogoutCommand = effectCmd({
command: "logout [name]",
describe: "remove OAuth credentials for an MCP server",
builder: (yargs) =>
yargs.positional("name", {
describe: "name of the MCP server",
type: "string",
}),
handler: Effect.fn("Cli.mcp.logout")(function* (args) {
UI.empty()
prompts.intro("MCP OAuth Logout")
const credentials = yield* McpAuth.Service.use((auth) => auth.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 = yield* Effect.promise(() =>
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
}
yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName))
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
prompts.outro("Done")
}),
})
async function resolveConfigPath(baseDir: string, global = false) {
// Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too)
const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")]
if (!global) {
candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc"))
}
for (const candidate of candidates) {
if (await Filesystem.exists(candidate)) {
return candidate
}
}
// Default to opencode.json if none exist
return candidates[0]
}
async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
let text = "{}"
if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath)
}
// Use jsonc-parser to modify while preserving comments
const edits = modify(text, ["mcp", name], mcpConfig, {
formattingOptions: { tabSize: 2, insertSpaces: true },
})
const result = applyEdits(text, edits)
await Filesystem.write(configPath, result)
return configPath
}
export const McpAddCommand = effectCmd({
command: "add [name] [args..]",
describe: "add an MCP server",
builder: (yargs) =>
yargs
.parserConfiguration({ "unknown-options-as-args": true })
.positional("name", {
describe: "name of the MCP server",
type: "string",
})
.positional("args", {
describe: "URL for remote servers or command and arguments for local servers",
type: "string",
array: true,
default: [],
})
.option("type", {
describe: "server type: local or remote",
type: "string",
choices: ["local", "remote"] as const,
})
.option("env", {
describe: "environment variable for local servers (KEY=VALUE)",
type: "string",
array: true,
})
.option("header", {
describe: "HTTP header for remote servers (KEY=VALUE or 'KEY: VALUE')",
type: "string",
array: true,
})
.option("scope", {
describe: "where to save the server",
type: "string",
choices: ["project", "global"] as const,
})
.option("global", {
alias: ["g"],
describe: "save to global config",
type: "boolean",
})
.option("enabled", {
describe: "enable or disable the server on startup",
type: "boolean",
})
.option("timeout", {
describe: "timeout in milliseconds for MCP server requests",
type: "number",
})
.option("oauth", {
describe: "enable OAuth for remote servers, or use --no-oauth to disable auto-detection",
type: "boolean",
})
.option("oauth-client-id", {
describe: "OAuth client ID for remote servers",
type: "string",
})
.option("oauth-client-secret", {
describe: "OAuth client secret for remote servers",
type: "string",
})
.option("oauth-scope", {
describe: "OAuth scopes to request for remote servers",
type: "string",
})
.option("oauth-callback-port", {
describe: "OAuth local callback port for remote servers",
type: "number",
})
.option("oauth-redirect-uri", {
describe: "OAuth redirect URI for remote servers",
type: "string",
}).epilogue(`Usage:
opencode mcp add <name> -- <command> [args...] (local MCP server)
opencode mcp add <name> --env KEY=VALUE -- <command> [args...] (local MCP server with env vars)
opencode mcp add <name> <url> (remote MCP server)
opencode mcp add <name> --header KEY=VALUE <url> (remote MCP server with headers)
opencode mcp add <name> --scope project -- <command> [args...] (save to project config)
Examples:
opencode mcp add context7 -- npx -y @upstash/context7-mcp
opencode mcp add local-env --env FOO=bar -- node server.js
opencode mcp add sg --header Authorization=token https://sg.example/mcp
opencode mcp add hugging-face https://huggingface.co/mcp`),
handler: Effect.fn("Cli.mcp.add")(function* (input: McpAddArgs) {
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const inlineArgs = mcpAddArgs(input)
const inlineConfig = parseInlineMcpAdd(input, inlineArgs)
if (inlineConfig && "error" in inlineConfig) return yield* fail(inlineConfig.error)
if (input.global && input.scope === "project")
return yield* fail("--global cannot be combined with --scope project")
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add MCP server")
const project = ctx.project
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
const configPath = await (async () => {
if (input.global || input.scope === "global") return globalConfigPath
if (input.scope === "project") return projectConfigPath
if (inlineConfig) return project.vcs === "git" ? projectConfigPath : globalConfigPath
if (project.vcs !== "git") return globalConfigPath
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
return scopeResult
})()
if (inlineConfig) {
await addMcpToConfig(input.name!.trim(), inlineConfig.config, configPath)
prompts.log.success(`MCP server "${input.name!.trim()}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
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
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
} else {
mcpConfig = {
type: "remote",
url,
}
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
prompts.outro("MCP server added successfully")
})
}),
})
function mcpAddArgs(input: McpAddArgs) {
const addIndex = input._?.lastIndexOf("add") ?? -1
return [
...(input.args ?? []),
...(addIndex === -1 || !input._ ? [] : input._.slice(addIndex + 1).map(String)),
...(input["--"] ?? []),
]
}
function parseInlineMcpAdd(
input: McpAddArgs,
inlineArgs: string[],
): { config: ConfigMCP.Info } | { error: string } | undefined {
if (!hasInlineMcpAdd(input, inlineArgs)) return undefined
const name = input.name?.trim()
if (!name) return { error: "MCP server name is required" }
if (inlineArgs.length === 0) return { error: "URL or command is required" }
if (input.timeout !== undefined && (!Number.isInteger(input.timeout) || input.timeout <= 0)) {
return { error: "--timeout must be a positive integer" }
}
const type = input.type ?? (inlineArgs.length === 1 && URL.canParse(inlineArgs[0]) ? "remote" : "local")
if (type === "local") return parseInlineLocalMcp(input, inlineArgs)
return parseInlineRemoteMcp(input, inlineArgs)
}
function hasInlineMcpAdd(input: McpAddArgs, inlineArgs: string[]) {
return !!(
input.name ||
inlineArgs.length > 0 ||
input.type ||
input.env?.length ||
input.header?.length ||
input.enabled !== undefined ||
input.timeout !== undefined ||
input.oauth !== undefined ||
input.oauthClientId ||
input.oauthClientSecret ||
input.oauthScope ||
input.oauthCallbackPort !== undefined ||
input.oauthRedirectUri
)
}
function parseInlineLocalMcp(args: McpAddArgs, command: string[]): { config: ConfigMCP.Info } | { error: string } {
if (args.header?.length) return { error: "--header can only be used with --type remote" }
if (hasOAuthOptions(args)) return { error: "OAuth options can only be used with --type remote" }
const environment = parseEnv(args.env)
if ("error" in environment) return environment
return {
config: {
type: "local",
command,
...(environment.value && { environment: environment.value }),
...(args.enabled !== undefined && { enabled: args.enabled }),
...(args.timeout !== undefined && { timeout: args.timeout }),
},
}
}
function parseInlineRemoteMcp(args: McpAddArgs, url: string[]): { config: ConfigMCP.Info } | { error: string } {
if (url.length !== 1) return { error: "Remote MCP servers require exactly one URL" }
if (!URL.canParse(url[0])) return { error: "Remote MCP server URL is invalid" }
if (args.env?.length) return { error: "--env can only be used with --type local" }
if (
args.oauthCallbackPort !== undefined &&
(!Number.isInteger(args.oauthCallbackPort) || args.oauthCallbackPort < 1 || args.oauthCallbackPort > 65535)
) {
return { error: "--oauth-callback-port must be an integer between 1 and 65535" }
}
if (args.oauth === false && hasOAuthConfigOptions(args)) {
return { error: "--no-oauth cannot be combined with OAuth options" }
}
const headers = parseHeader(args.header)
if ("error" in headers) return headers
const oauth = parseOAuth(args)
return {
config: {
type: "remote",
url: url[0],
...(headers.value && { headers: headers.value }),
...(args.enabled !== undefined && { enabled: args.enabled }),
...(args.timeout !== undefined && { timeout: args.timeout }),
...(oauth !== undefined && { oauth }),
},
}
}
function parseEnv(entries?: string[]): { value?: Record<string, string> } | { error: string } {
if (!entries?.length) return {}
const parsed = entries.map((entry) => {
const index = entry.indexOf("=")
const key = entry.slice(0, index).trim()
if (index <= 0 || !key) return { error: "--env must be in KEY=VALUE format" }
return { key, value: entry.slice(index + 1) }
})
const invalid = parsed.find((entry): entry is { error: string } => "error" in entry)
if (invalid) return invalid
return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) }
}
function parseHeader(entries?: string[]): { value?: Record<string, string> } | { error: string } {
if (!entries?.length) return {}
const parsed = entries.map((entry) => {
const colon = entry.indexOf(":")
const equals = entry.indexOf("=")
const index = colon === -1 ? equals : equals === -1 ? colon : Math.min(colon, equals)
const key = entry.slice(0, index).trim()
if (index <= 0 || !key) return { error: "--header must be in KEY=VALUE or 'KEY: VALUE' format" }
return { key, value: entry.slice(index + 1).trim() }
})
const invalid = parsed.find((entry): entry is { error: string } => "error" in entry)
if (invalid) return invalid
return { value: Object.fromEntries(parsed.map((entry) => [entry.key, entry.value])) }
}
function hasOAuthOptions(args: McpAddArgs) {
return !!(args.oauth !== undefined || hasOAuthConfigOptions(args))
}
function hasOAuthConfigOptions(args: McpAddArgs) {
return !!(
args.oauthClientId ||
args.oauthClientSecret ||
args.oauthScope ||
args.oauthCallbackPort !== undefined ||
args.oauthRedirectUri
)
}
function parseOAuth(args: McpAddArgs): ConfigMCP.Remote["oauth"] | undefined {
if (args.oauth === false) return false
if (!hasOAuthOptions(args)) return undefined
return {
...(args.oauthClientId && { clientId: args.oauthClientId }),
...(args.oauthClientSecret && { clientSecret: args.oauthClientSecret }),
...(args.oauthScope && { scope: args.oauthScope }),
...(args.oauthCallbackPort !== undefined && { callbackPort: args.oauthCallbackPort }),
...(args.oauthRedirectUri && { redirectUri: args.oauthRedirectUri }),
}
}
export const McpDebugCommand = effectCmd({
command: "debug <name>",
describe: "debug OAuth connection for an MCP server",
builder: (yargs) =>
yargs.positional("name", {
describe: "name of the MCP server",
type: "string",
demandOption: true,
}),
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
const config = yield* Config.Service.use((cfg) => cfg.get())
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("MCP OAuth Debug")
const mcpServers = config.mcp ?? {}
const serverName = args.name
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
}
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
}
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status — services already in hand, run inline.
const { authStatus, entry } = await Effect.runPromise(
Effect.all({
authStatus: mcp.getAuthStatus(serverName),
entry: auth.get(serverName),
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
}
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
const spinner = prompts.spinner()
spinner.start("Testing connection...")
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
})
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
})
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.info("No client ID - dynamic registration will be attempted")
}
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
}
}
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
}
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
prompts.outro("Debug complete")
})
}),
})