import { cmd } from "./cmd" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.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 { Instance } from "../../project/instance" import { Installation } from "../../installation" import path from "path" import { Global } from "../../global" 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" } } export const McpCommand = cmd({ command: "mcp", builder: (yargs) => yargs .command(McpAddCommand) .command(McpListCommand) .command(McpAuthCommand) .command(McpLogoutCommand) .command(McpDebugCommand) .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", }) .command(McpAuthListCommand), 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-capable servers (remote servers with oauth not explicitly disabled) const oauthServers = Object.entries(mcpServers).filter( ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, ) if (oauthServers.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 = await Promise.all( oauthServers.map(async ([name, cfg]) => { const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) const url = cfg.type === "remote" ? cfg.url : "" return { label: `${icon} ${name} (${statusText})`, value: name, hint: url, } }), ) const selected = await 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 (serverConfig.type !== "remote" || serverConfig.oauth === false) { prompts.log.error(`MCP server ${serverName} does not support OAuth (oauth is disabled)`) prompts.outro("Done") return } // Check if already authenticated const authStatus = await MCP.getAuthStatus(serverName) if (authStatus === "authenticated") { const confirm = await 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...") 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 McpAuthListCommand = cmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", async handler() { await Instance.provide({ directory: process.cwd(), async fn() { UI.empty() prompts.intro("MCP OAuth Status") const config = await Config.get() const mcpServers = config.mcp ?? {} // Get OAuth-capable servers const oauthServers = Object.entries(mcpServers).filter( ([_, cfg]) => cfg.type === "remote" && cfg.oauth !== false, ) if (oauthServers.length === 0) { prompts.log.warn("No OAuth-capable MCP servers configured") prompts.outro("Done") return } for (const [name, serverConfig] of oauthServers) { const authStatus = await MCP.getAuthStatus(name) const icon = getAuthStatusIcon(authStatus) const statusText = getAuthStatusText(authStatus) const url = serverConfig.type === "remote" ? serverConfig.url : "" prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) } prompts.outro(`${oauthServers.length} OAuth-capable server(s)`) }, }) }, }) 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", async handler() { UI.empty() prompts.intro("Add MCP server") 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() prompts.log.info(`Local MCP server "${name}" configured with command: ${command}`) 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() 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") }, }) export const McpDebugCommand = cmd({ 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, }), async handler(args) { await Instance.provide({ directory: process.cwd(), async fn() { UI.empty() prompts.intro("MCP OAuth Debug") const config = await Config.get() 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 (serverConfig.type !== "remote") { 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 const authStatus = await MCP.getAuthStatus(serverName) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) const entry = await McpAuth.get(serverName) 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: Installation.VERSION }, }, 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, }, { onRedirect: async () => {}, }, ) 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: Installation.VERSION, }) 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") }, }) }, })