From 594c3955769de818cffc05f8db3db74023f26e3d Mon Sep 17 00:00:00 2001
From: Filip <34747899+neriousy@users.noreply.github.com>
Date: Tue, 18 Aug 2026 20:02:10 +0200
Subject: [PATCH] fix(tui): restore MCP sign-in flow (#43274)
---
.../tui/src/component/dialog-integration.tsx | 27 +++-
packages/tui/src/component/dialog-mcp.tsx | 20 ++-
packages/tui/test/cli/tui/dialog-mcp.test.tsx | 126 ++++++++++++++++++
3 files changed, 162 insertions(+), 11 deletions(-)
create mode 100644 packages/tui/test/cli/tui/dialog-mcp.test.tsx
diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx
index 81f5db9320a..382cde9fe60 100644
--- a/packages/tui/src/component/dialog-integration.tsx
+++ b/packages/tui/src/component/dialog-integration.tsx
@@ -11,7 +11,7 @@ import type {
FormValue,
} from "@opencode-ai/client"
import open from "open"
-import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
import { useData } from "../context/data"
import { useClient } from "../context/client"
@@ -70,18 +70,33 @@ export function connectionSummary(integration: IntegrationInfo) {
}
export function DialogIntegration(
- props: { onConnected?: OnIntegrationConnected; integrationID?: string; connectionOnly?: boolean } = {},
+ props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {},
) {
const data = useData()
const dialog = useDialog()
const theme = useTheme("elevated")
+ const integrations = createMemo(() =>
+ integrationOptions(data.location.integration.list() ?? []).filter(
+ (integration) => props.integrationID === undefined || integration.id === props.integrationID,
+ ),
+ )
+
+ createEffect(() => {
+ if (!props.autoConnect) return
+ const integration = integrations()[0]
+ if (!integration) return
+ const methods = connectMethods(integration)
+ if (credentialConnections(integration).length) {
+ manageConnections(integration, methods, dialog, props.onConnected)
+ return
+ }
+ selectMethod(integration, methods, dialog, props.onConnected)
+ })
+
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
- const integrations = integrationOptions(data.location.integration.list() ?? []).filter(
- (integration) => props.integrationID === undefined || integration.id === props.integrationID,
- )
- return integrations.map((integration) => {
+ return integrations().map((integration) => {
const methods = connectMethods(integration)
const provider = providersByID.get(integration.id)
const credentials = credentialConnections(integration)
diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx
index 9cf92156db8..298b5fd33c6 100644
--- a/packages/tui/src/component/dialog-mcp.tsx
+++ b/packages/tui/src/component/dialog-mcp.tsx
@@ -10,6 +10,7 @@ import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useToast } from "../ui/toast"
import { DialogErrorDetails } from "./dialog-error-details"
+import { DialogIntegration } from "./dialog-integration"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
@@ -90,18 +91,27 @@ export function DialogMcp() {
return server ? statusError(server.status) : undefined
})
- const open = (name: string | undefined) => {
+ const select = (name: string | undefined) => {
const server = servers().find((entry) => entry.name === name)
- if (!server || !statusError(server.status)) return
+ if (!server) return
+ if (server.status.status === "needs_auth" && server.integrationID) {
+ dialog.replace(() => )
+ return
+ }
+ if (!statusError(server.status)) return
setDetail(server)
}
- // Connected servers disconnect; everything else (disabled, failed, needs_auth) retries a
- // connection. The mcp.status.changed event refreshes the list, so no manual sync is needed.
+ // Auth-gated servers enter the integration flow; other inactive states retry the connection.
+ // The mcp.status.changed event refreshes the list, so no manual sync is needed.
const toggle = (name: string) => {
if (loading() !== null) return
const server = servers().find((entry) => entry.name === name)
if (!server || server.status.status === "pending") return
+ if (server.status.status === "needs_auth" && server.integrationID) {
+ select(name)
+ return
+ }
setLoading(name)
const current = data.location.default()
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
@@ -119,7 +129,7 @@ export function DialogMcp() {
options={options()}
preserveSelection
onMove={(option) => setFocused(option.value as string)}
- onSelect={(option) => open(option.value as string)}
+ onSelect={(option) => select(option.value as string)}
actions={[
{
title: toggleTitle(),
diff --git a/packages/tui/test/cli/tui/dialog-mcp.test.tsx b/packages/tui/test/cli/tui/dialog-mcp.test.tsx
new file mode 100644
index 00000000000..f280e46efdf
--- /dev/null
+++ b/packages/tui/test/cli/tui/dialog-mcp.test.tsx
@@ -0,0 +1,126 @@
+/** @jsxImportSource @opentui/solid */
+import { testRender } from "@opentui/solid"
+import { expect, test } from "bun:test"
+import { onMount } from "solid-js"
+import { DialogMcp } from "../../../src/component/dialog-mcp"
+import { ConfigProvider } from "../../../src/config"
+import { ClientProvider } from "../../../src/context/client"
+import { DataProvider, useData } from "../../../src/context/data"
+import { Keymap } from "../../../src/context/keymap"
+import { ThemeProvider } from "../../../src/context/theme"
+import { DialogProvider, useDialog } from "../../../src/ui/dialog"
+import { ToastProvider } from "../../../src/ui/toast"
+import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
+import { emptyThemeSource } from "../../fixture/fixture"
+import { TestTuiContexts } from "../../fixture/tui-environment"
+import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
+
+test.each(["enter", "space"])("starts OAuth with %s for an MCP server requiring authentication", async (key) => {
+ const fixture = await renderMcp()
+
+ try {
+ await fixture.app.waitForFrame((frame) => frame.includes("Sign in required"))
+ if (key === "enter") fixture.app.mockInput.pressEnter()
+ else fixture.app.mockInput.pressKey(" ")
+ await fixture.app.waitForFrame((frame) => frame.includes("Waiting for authorization"))
+
+ expect(fixture.oauth).toBe(1)
+ expect(fixture.connect).toBe(0)
+ } finally {
+ fixture.app.renderer.destroy()
+ }
+})
+
+async function renderMcp() {
+ const events = createEventStream()
+ let oauth = 0
+ let connect = 0
+ const calls = createFetch((url, request) => {
+ const location = {
+ directory: process.cwd(),
+ project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() },
+ }
+ if (url.pathname === "/api/mcp")
+ return json({
+ location,
+ data: [{ name: "linear", status: { status: "needs_auth" }, integrationID: "mcp_linear" }],
+ })
+ if (url.pathname === "/api/integration")
+ return json({
+ location,
+ data: [
+ {
+ id: "mcp_linear",
+ name: "linear",
+ methods: [{ type: "oauth", id: "mcp_linear", label: "linear" }],
+ connections: [],
+ },
+ ],
+ })
+ if (url.pathname === "/api/integration/mcp_linear/connect/oauth" && request.method === "POST") {
+ oauth++
+ return json({
+ location,
+ data: {
+ attemptID: "attempt_linear",
+ mode: "auto",
+ url: "https://linear.example.com/oauth",
+ instructions: "Authorize linear in your browser.",
+ },
+ })
+ }
+ if (url.pathname === "/api/integration/mcp_linear/connect/oauth/attempt_linear") {
+ if (request.method === "DELETE") return new Response(null, { status: 204 })
+ return json({ location, data: { status: "pending" } })
+ }
+ if (url.pathname === "/api/mcp/linear/connect" && request.method === "POST") {
+ connect++
+ return new Response(null, { status: 204 })
+ }
+ return undefined
+ }, events)
+
+ function Probe() {
+ const data = useData()
+ const dialog = useDialog()
+ onMount(() => {
+ void Promise.all([data.location.mcp.server.sync(), data.location.integration.sync()]).then(() =>
+ dialog.replace(() => ),
+ )
+ })
+ return null
+ }
+
+ const app = await testRender(
+ () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ { width: 100, height: 30, kittyKeyboard: true },
+ )
+ app.renderer.start()
+ return {
+ app,
+ get oauth() {
+ return oauth
+ },
+ get connect() {
+ return connect
+ },
+ }
+}