mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
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:
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user