diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 9ca4b3bff8b..b4ae8a37f7b 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,16 +1,41 @@ 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 os from "os" 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) => @@ -19,6 +44,7 @@ export const McpCommand = cmd({ .command(McpListCommand) .command(McpAuthCommand) .command(McpLogoutCommand) + .command(McpDebugCommand) .demandCommand(), async handler() {}, }) @@ -94,10 +120,12 @@ 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", - }), + yargs + .positional("name", { + describe: "name of the MCP server", + type: "string", + }) + .command(McpAuthListCommand), async handler(args) { await Instance.provide({ directory: process.cwd(), @@ -108,20 +136,19 @@ export const McpAuthCommand = cmd({ 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) + // 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-enabled MCP servers configured") - prompts.log.info("Add OAuth config to a remote MCP server in opencode.json:") + 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", - "oauth": { - "scope": "tools:read" - } + "url": "https://example.com/mcp" } }`) prompts.outro("Done") @@ -130,13 +157,24 @@ export const McpAuthCommand = cmd({ 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: oauthServers.map(([name, cfg]) => ({ - label: name, - value: name, - hint: cfg.type === "remote" ? cfg.url : undefined, - })), + options, }) if (prompts.isCancel(selected)) throw new UI.CancelledError() serverName = selected @@ -149,22 +187,24 @@ export const McpAuthCommand = cmd({ return } - if (serverConfig.type !== "remote" || !serverConfig.oauth) { - prompts.log.error(`MCP server ${serverName} does not have OAuth configured`) + 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 hasTokens = await MCP.hasStoredTokens(serverName) - if (hasTokens) { + const authStatus = await MCP.getAuthStatus(serverName) + if (authStatus === "authenticated") { const confirm = await prompts.confirm({ - message: `${serverName} already has stored credentials. Re-authenticate?`, + 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() @@ -207,6 +247,46 @@ export const McpAuthCommand = cmd({ }, }) +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", @@ -398,3 +478,177 @@ export const McpAddCommand = cmd({ 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") + }, + }) + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 6c74e04fac7..e09ad162255 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -29,6 +29,16 @@ export function Sidebar(props: { sessionID: string }) { // Sort MCP servers alphabetically for consistent display order const mcpEntries = createMemo(() => Object.entries(sync.data.mcp).sort(([a], [b]) => a.localeCompare(b))) + // Count connected and error MCP servers for collapsed header display + const connectedMcpCount = createMemo(() => mcpEntries().filter(([_, item]) => item.status === "connected").length) + const errorMcpCount = createMemo( + () => + mcpEntries().filter( + ([_, item]) => + item.status === "failed" || item.status === "needs_auth" || item.status === "needs_client_registration", + ).length, + ) + const cost = createMemo(() => { const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0) return new Intl.NumberFormat("en-US", { @@ -97,6 +107,13 @@ export function Sidebar(props: { sessionID: string }) { MCP + + + {" "} + ({connectedMcpCount()} active + {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""}) + + diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 6ebb95698d7..7f7dbd156cc 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -121,4 +121,15 @@ export namespace McpAuth { await set(mcpName, entry) } } + + /** + * Check if stored tokens are expired. + * Returns null if no tokens exist, false if no expiry or not expired, true if expired. + */ + export async function isTokenExpired(mcpName: string): Promise { + const entry = await get(mcpName) + if (!entry?.tokens) return null + if (!entry.tokens.expiresAt) return false + return entry.tokens.expiresAt < Date.now() / 1000 + } } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 2f8e4ace40e..40ee2556520 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -15,6 +15,8 @@ import { withTimeout } from "@/util/timeout" import { McpOAuthProvider } from "./oauth-provider" import { McpOAuthCallback } from "./oauth-callback" import { McpAuth } from "./auth" +import { Bus } from "@/bus" +import { TuiEvent } from "@/cli/cmd/tui/event" import open from "open" export namespace MCP { @@ -251,10 +253,24 @@ export namespace MCP { status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", } + // Show toast for needs_client_registration + Bus.publish(TuiEvent.ToastShow, { + title: "MCP Authentication Required", + message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`, + variant: "warning", + duration: 8000, + }).catch((e) => log.debug("failed to show toast", { error: e })) } else { // Store transport for later finishAuth call pendingOAuthTransports.set(key, transport) status = { status: "needs_auth" as const } + // Show toast for needs_auth + Bus.publish(TuiEvent.ToastShow, { + title: "MCP Authentication Required", + message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + variant: "warning", + duration: 8000, + }).catch((e) => log.debug("failed to show toast", { error: e })) } break } @@ -623,4 +639,16 @@ export namespace MCP { const entry = await McpAuth.get(mcpName) return !!entry?.tokens } + + export type AuthStatus = "authenticated" | "expired" | "not_authenticated" + + /** + * Get the authentication status for an MCP server. + */ + export async function getAuthStatus(mcpName: string): Promise { + const hasTokens = await hasStoredTokens(mcpName) + if (!hasTokens) return "not_authenticated" + const expired = await McpAuth.isTokenExpired(mcpName) + return expired ? "expired" : "authenticated" + } }