From e4280a4599fa7156dfa45616853bfd7b2ef3ef6a Mon Sep 17 00:00:00 2001 From: Marius Svechla Date: Fri, 15 May 2026 11:02:28 +0200 Subject: [PATCH] fix(mcp): authenticate on toggle in TUI MCP picker Two related changes that make the in-TUI MCP picker actually usable when an MCP needs OAuth: 1. dialog-mcp.tsx: when the user toggles a 'needs_auth' MCP via the picker (space), call mcp.auth.authenticate instead of mcp.connect. Previously, toggle re-ran connect, which re-failed with UnauthorizedError, set the row back to needs_auth, and emitted a toast pointing at the shell. There was no in-TUI path that actually completed OAuth. This now opens the browser, awaits the callback, exchanges the code, and rebuilds the transport. A second loading kind ('authenticating') is rendered in the row footer while the long-blocking call sits open. 2. mcp/index.ts: update the startup 'requires authentication' toast to mention /mcps first, and the shell command as a fallback. Now that the in-TUI flow works, users in a running TUI can recover without dropping to a shell. Related: #16893, #21702. --- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 84 ++++++++++++++++--- packages/opencode/src/mcp/index.ts | 2 +- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index c577d493294d..14612afec85c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -4,11 +4,18 @@ import { useSync } from "@tui/context/sync" import { map, pipe, entries, sortBy } from "remeda" import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "@tui/ui/dialog-select" import { useTheme } from "../context/theme" +import { useToast } from "@tui/ui/toast" import { TextAttributes } from "@opentui/core" import { useSDK } from "@tui/context/sdk" -function Status(props: { enabled: boolean; loading: boolean }) { +type LoadingKind = "toggle" | "authenticating" +type LoadingState = { name: string; kind: LoadingKind } + +function Status(props: { enabled: boolean; loading: LoadingKind | null }) { const { theme } = useTheme() + if (props.loading === "authenticating") { + return ⋯ Authenticating (complete in browser) + } if (props.loading) { return ⋯ Loading } @@ -22,13 +29,14 @@ export function DialogMcp() { const local = useLocal() const sync = useSync() const sdk = useSDK() + const toast = useToast() const [, setRef] = createSignal>() - const [loading, setLoading] = createSignal(null) + const [loading, setLoading] = createSignal(null) const options = createMemo(() => { // Track sync data and loading state to trigger re-render when they change const mcpData = sync.data.mcp - const loadingMcp = loading() + const loadingState = loading() return pipe( mcpData ?? {}, @@ -38,12 +46,27 @@ export function DialogMcp() { value: name, title: name, description: status.status === "failed" ? "failed" : status.status, - footer: , + footer: ( + + ), category: undefined, })), ) }) + // Refresh MCP status from server and apply to sync store. + const refreshStatus = async () => { + const status = await sdk.client.mcp.status() + if (status.data) { + sync.set("mcp", status.data) + } else { + console.error("Failed to refresh MCP status: no data returned") + } + } + const actions = createMemo(() => [ { command: "dialog.mcp.toggle", @@ -52,16 +75,51 @@ export function DialogMcp() { // Prevent toggling while an operation is already in progress if (loading() !== null) return - setLoading(option.value) - try { - await local.mcp.toggle(option.value) - // Refresh MCP status from server - const status = await sdk.client.mcp.status() - if (status.data) { - sync.set("mcp", status.data) - } else { - console.error("Failed to refresh MCP status: no data returned") + const name = option.value + const status = sync.data.mcp[name] + + // For MCPs that need OAuth, run the full authenticate flow instead of + // re-running connect (which would only re-trigger the same + // UnauthorizedError loop). The server opens the browser, starts the + // OAuth callback listener, waits for the redirect, exchanges the + // code, and rebuilds the transport. + if (status?.status === "needs_auth") { + setLoading({ name, kind: "authenticating" }) + try { + // This call blocks for up to 5 minutes (the callback timeout) + // while the user completes the OAuth flow in their browser. If + // the browser fails to open (headless / SSH / no `open` + // binary), the server publishes a `BrowserOpenFailed` event + // that surfaces as a toast carrying the URL for manual + // opening. + const result = await sdk.client.mcp.auth.authenticate({ name }) + await refreshStatus() + if (result.data?.status === "failed") { + toast.show({ + variant: "error", + title: "Authentication failed", + message: result.data.error, + duration: 8000, + }) + } + } catch (error) { + console.error("Failed to authenticate MCP:", error) + toast.show({ + variant: "error", + title: "Authentication failed", + message: error instanceof Error ? error.message : String(error), + duration: 8000, + }) + } finally { + setLoading(null) } + return + } + + setLoading({ name, kind: "toggle" }) + try { + await local.mcp.toggle(name) + await refreshStatus() } catch (error) { console.error("Failed to toggle MCP:", error) } finally { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 832811b281a5..b3e69ab8f67d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -377,7 +377,7 @@ export const layer = Layer.effect( return bus .publish(TuiEvent.ToastShow, { title: "MCP Authentication Required", - message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`, + message: `Server "${key}" requires authentication. Run /mcps or opencode mcp auth ${key} to authenticate`, variant: "warning", duration: 8000, })