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[string] type McpConfigured = ConfigMCP.Info function isMcpConfigured(config: McpEntry): config is McpConfigured { return typeof config === "object" && config !== null && "type" in config } type McpRemote = Extract function isMcpRemote(config: McpEntry): config is McpRemote { return isMcpConfigured(config) && config.type === "remote" } type McpAddArgs = { _?: Array "--"?: 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 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 -- [args...] (local MCP server) opencode mcp add --env KEY=VALUE -- [args...] (local MCP server with env vars) opencode mcp add (remote MCP server) opencode mcp add --header KEY=VALUE (remote MCP server with headers) opencode mcp add --scope project -- [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 } | { 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 } | { 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 ", 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") }) }), })