mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-22 22:53:27 +00:00
fix(tui): restore MCP sign-in flow (#43274)
This commit is contained in:
parent
b7402c264d
commit
594c395576
3 changed files with 162 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
126
packages/tui/test/cli/tui/dialog-mcp.test.tsx
Normal file
126
packages/tui/test/cli/tui/dialog-mcp.test.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue