Просмотр исходного кода

improve `mcp` CLI + ability to debug MCP oauth (#5980)

Matt Silverlock 2 месяцев назад
Родитель
Сommit
1a2b656c4d

+ 277 - 23
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")
+      },
+    })
+  },
+})

+ 17 - 0
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}>

+ 11 - 0
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
+  }
 }

+ 28 - 0
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"
+  }
 }