From 526cf672308dd3ffecc68fbb5d44bab6c79075e6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 19 Oct 2026 18:36:44 -0400 Subject: [PATCH] feat(app): show a sign in link for MCP servers that need auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../regression/mcp-workspace-toggle.spec.ts | 56 ++++++++++++++++++- .../app/src/providers/connect/mcp-dialog.tsx | 49 ++++++++++++---- packages/app/src/runtime/i18n/en.ts | 2 + 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index 53827d17a4b..f4e387780c1 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -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) { +async function open( + page: Page, + input: { surface: Surface } & Pick, +) { 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 { + 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) +}) diff --git a/packages/app/src/providers/connect/mcp-dialog.tsx b/packages/app/src/providers/connect/mcp-dialog.tsx index 977b7b607e2..6492c1255fd 100644 --- a/packages/app/src/providers/connect/mcp-dialog.tsx +++ b/packages/app/src/providers/connect/mcp-dialog.tsx @@ -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 = () => {
{(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 = () => { {detail()}
- {/* 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. */}
event.stopPropagation()} > toggleServer(item.name)} + /> } - onChange={() => toggleServer(item.name)} - /> + > + + } >