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