fix(tui): restore MCP sign-in flow (#43274)

This commit is contained in:
Filip 2026-08-18 20:02:10 +02:00 committed by GitHub
parent b7402c264d
commit 594c395576
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 162 additions and 11 deletions

View file

@ -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)

View file

@ -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(() => <DialogIntegration integrationID={server.integrationID} autoConnect />)
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(),

View file

@ -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(() => <DialogMcp />),
)
})
return null
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ToastProvider>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<DialogProvider>
<Probe />
</DialogProvider>
</ThemeProvider>
</DataProvider>
</ClientProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
app.renderer.start()
return {
app,
get oauth() {
return oauth
},
get connect() {
return connect
},
}
}