feat(app): show a sign in link for MCP servers that need auth

Needs-auth servers show "Sign in ›" in place of the switch, starting the
existing OAuth flow. A spinner shows while sign-in starts, and the row
returns to the switch once the server connects. The trailing slot is
widened to 60px so every row stays aligned.
This commit is contained in:
Dax Raad
2026-10-19 18:36:44 -04:00
Unverified
parent 87b396d2da
commit 526cf67230
3 changed files with 92 additions and 15 deletions
@@ -17,7 +17,10 @@ type Surface = "popover" | "dialog"
// A session in a worktree of the project. The harness answers the MCP list for each location directory and stores
// connect/disconnect results per directory; `requests` records every MCP request and the directory it targeted.
// The popover belongs to the summary extension; the dialog (Ctrl+;) is the app's own MCP toggle.
async function open(page: Page, input: { surface: Surface } & Pick<MockServerConfig, "mcp" | "onMcpAction">) {
async function open(
page: Page,
input: { surface: Surface } & Pick<MockServerConfig, "mcp" | "onMcpAction" | "integrations" | "onIntegrationOAuth">,
) {
const requests: { path: string; directory: string }[] = []
page.on("request", (request) => {
const url = new URL(request.url())
@@ -25,7 +28,8 @@ async function open(page: Page, input: { surface: Surface } & Pick<MockServerCon
if (request.method() === "OPTIONS" || !/^\/api\/(?:experimental\/)?mcp(?:\/|$)/.test(url.pathname)) return
requests.push({ path: url.pathname, directory: url.searchParams.get("location[directory]") || directory })
})
await mockWorkspace(page, {
const mock = await mockWorkspace(page, {
name: "mcp-workspace",
directory,
project: { sandboxes: [workspace] },
@@ -34,6 +38,8 @@ async function open(page: Page, input: { surface: Surface } & Pick<MockServerCon
seed: { settings: { keybinds: { "mcp.toggle": "ctrl+;" } } },
mcp: input.mcp,
onMcpAction: input.onMcpAction,
integrations: input.integrations,
onIntegrationOAuth: input.onIntegrationOAuth,
})
await page.goto(sessionHref(sessionID))
await expectSessionTitle(page, title)
@@ -49,7 +55,7 @@ async function open(page: Page, input: { surface: Surface } & Pick<MockServerCon
await show()
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
return { requests, panel, show, toggle: panel.getByRole("switch") }
return { requests, panel, show, push: mock.push, toggle: panel.getByRole("switch") }
}
for (const shared of [true, false]) {
@@ -183,3 +189,47 @@ for (const surface of ["popover", "dialog"] as const) {
await expect(toast).toBeHidden()
})
}
test("signs in to an auth-gated MCP from the dialog", async ({ page, context }) => {
const state = { signedIn: false }
const attempts: string[] = []
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
const view = await open(page, {
surface: "dialog",
mcp: () => [
{
name: "figma-desktop",
integrationID: "figma-oauth",
status: state.signedIn ? { status: "connected" } : { status: "needs_auth", error: "Sign in required" },
},
],
integrations: [{ id: "figma-oauth", name: "Figma", methods: [{ id: "oauth", type: "oauth" }], connections: [] }],
onIntegrationOAuth: (input) => {
attempts.push(input.directory)
return { url: "https://auth.example.test/authorize" }
},
})
// The switch would only start sign-in, so the dialog offers that action in its place.
await expect(view.toggle).toHaveCount(0)
const popup = page.waitForEvent("popup")
await view.panel.getByRole("button", { name: "Sign in to figma-desktop", exact: true }).click()
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
expect(attempts).toEqual([workspace])
// Completing sign-in reconnects the server, which reports the change.
state.signedIn = true
await view.push([
{
id: "evt_mcp_signed_in",
created: 1,
type: "mcp.status.changed",
location: { directory: workspace },
data: { server: "figma-desktop" },
},
])
await expect(view.toggle).toBeChecked()
await expect(view.panel.getByRole("button", { name: "Sign in to figma-desktop", exact: true })).toHaveCount(0)
})
@@ -7,6 +7,7 @@ import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui
import { useFilteredList } from "@opencode/ui/hooks"
import { Icon } from "@opencode/ui/icon"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Spinner } from "@opencode/ui/spinner"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
@@ -15,6 +16,9 @@ import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
import "@/shell/search-list.css"
const link =
"inline-flex cursor-pointer items-center gap-0.5 rounded-sm underline-offset-3 hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
@@ -189,12 +193,17 @@ export const DialogSelectMcp: Component = () => {
<div role="listbox" class="search-list-results">
<For each={list.flat()}>
{(item) => {
const status = () => servers().find((server) => server.name === item.name)?.status
const server = () => servers().find((entry) => entry.name === item.name)
const status = () => server()?.status
// The switch would start sign-in anyway, so an auth-gated server shows that action instead.
const signIn = () => status()?.status === "needs_auth" && !!server()?.integrationID
const busy = () => toggle.isPending && toggle.variables === item.name
const detail = () => {
const current = status()
if (!current || current.status === "pending" || current.status === "failed") return
if (!current || current.status === "pending" || current.status === "failed" || signIn())
return
return language.t(statusLabels[current.status])
}
@@ -223,27 +232,43 @@ export const DialogSelectMcp: Component = () => {
<span class="search-list-detail">{detail()}</span>
</Show>
</div>
{/* Sized for the failed link so every row's text truncates at the same edge. */}
{/* Sized for the failed and sign-in links so every row's text truncates at the same edge. */}
<div
class="search-list-trailing min-w-14 justify-end"
class="search-list-trailing min-w-15 justify-end"
onClick={(event) => event.stopPropagation()}
>
<Show
when={status()?.status === "failed"}
fallback={
<Switch
checked={status()?.status === "connected"}
disabled={
status()?.status === "pending" ||
(toggle.isPending && toggle.variables === item.name)
<Show
when={signIn()}
fallback={
<Switch
checked={status()?.status === "connected"}
disabled={status()?.status === "pending" || busy()}
onChange={() => toggleServer(item.name)}
/>
}
onChange={() => toggleServer(item.name)}
/>
>
<button
type="button"
class={`${link} text-v2-text-text-accent hover:text-v2-text-text-accent-hover disabled:cursor-default disabled:no-underline`}
aria-label={language.t("dialog.mcp.signIn.open", { name: item.name })}
aria-busy={busy()}
disabled={busy()}
onClick={() => toggleServer(item.name)}
>
{language.t("dialog.mcp.signIn")}
<Show when={busy()} fallback={<Icon name="chevron-right" size="small" />}>
<Spinner class="size-3.5" />
</Show>
</button>
</Show>
}
>
<button
type="button"
class="inline-flex cursor-pointer items-center gap-0.5 rounded-sm text-v2-state-fg-danger underline-offset-3 hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus"
class={`${link} text-v2-state-fg-danger`}
aria-label={language.t("dialog.mcp.error.open", { name: item.name })}
onClick={() => select(item.name)}
>
+2
View File
@@ -326,6 +326,8 @@ export const dict = {
"dialog.mcp.error.copyFailed": "Couldn't copy error details",
"dialog.mcp.error.open": "{{name}} failed, view error",
"dialog.mcp.error.disable": "Disable",
"dialog.mcp.signIn": "Sign in",
"dialog.mcp.signIn.open": "Sign in to {{name}}",
"dialog.mcp.error.details":
"MCP server: {{name}}\nStatus: failed\nConfiguration: mcp.servers.{{name}}\nError: {{error}}",
"dialog.fork.search.empty": "No messages found",