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,
})