summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMatt Silverlock <[email protected]>2025-12-22 23:27:38 -0500
committerGitHub <[email protected]>2025-12-22 22:27:38 -0600
commit1a2b656c4d4b44d9a291ac86e7aa98282577fee0 (patch)
treee80612ea6b941c8f33b5946f940428c4c2c13675
parent161e9287a841bd8d7378c3364b6b5b3cc2c91234 (diff)
downloadopencode-1a2b656c4d4b44d9a291ac86e7aa98282577fee0.tar.gz
opencode-1a2b656c4d4b44d9a291ac86e7aa98282577fee0.zip
improve `mcp` CLI + ability to debug MCP oauth (#5980)
-rw-r--r--packages/opencode/src/cli/cmd/mcp.ts300
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx17
-rw-r--r--packages/opencode/src/mcp/auth.ts11
-rw-r--r--packages/opencode/src/mcp/index.ts28
4 files changed, 333 insertions, 23 deletions
diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts
index 9ca4b3bff..b4ae8a37f 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 <name>",
+ 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 c29057770..0bc4e860f 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", {
@@ -98,6 +108,13 @@ export function Sidebar(props: { sessionID: string }) {
</Show>
<text fg={theme.text}>
<b>MCP</b>
+ <Show when={!expanded.mcp}>
+ <span style={{ fg: theme.textMuted }}>
+ {" "}
+ ({connectedMcpCount()} active
+ {errorMcpCount() > 0 ? `, ${errorMcpCount()} error${errorMcpCount() > 1 ? "s" : ""}` : ""})
+ </span>
+ </Show>
</text>
</box>
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts
index 6ebb95698..7f7dbd156 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<boolean | null> {
+ 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 2f8e4ace4..40ee25565 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<AuthStatus> {
+ const hasTokens = await hasStoredTokens(mcpName)
+ if (!hasTokens) return "not_authenticated"
+ const expired = await McpAuth.isTokenExpired(mcpName)
+ return expired ? "expired" : "authenticated"
+ }
}