diff --git a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts index 0a6e867a81b..97a8bb1e10e 100644 --- a/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts +++ b/packages/app/e2e/regression/mcp-workspace-toggle.spec.ts @@ -85,3 +85,94 @@ for (const shared of [true, false]) { expect(requests.every((request) => request.directory === workspace)).toBe(true) }) } + +for (const surface of ["popover", "dialog"] as const) { + test(`shows connection failures from the MCP ${surface} and allows reconnecting`, async ({ page }, testInfo) => { + const error = "Streamable HTTP error: Error POSTing to endpoint: 404 Not Found" + const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" } + const requests: { path: string; directory: string }[] = [] + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } })) + }) + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "mcp-workspace", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [workspace], + }, + provider: { all: [], connected: [], default: {} }, + sessions: [{ id: sessionID, projectID, directory: workspace, title }], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/mcp**", async (route) => { + if (route.request().method() === "OPTIONS") return route.fallback() + const url = new URL(route.request().url()) + const target = url.searchParams.get("location[directory]") ?? directory + requests.push({ path: url.pathname, directory: target }) + if (url.pathname === "/api/mcp/figma-desktop/connect") { + state.status = state.fail ? "failed" : "connected" + // Connection failures are reported by the refreshed status, not the HTTP response. + return route.fulfill({ status: 204 }) + } + return route.fulfill({ + json: { + location: { directory: target }, + data: + url.pathname === "/api/mcp/resource" + ? { resources: [], templates: [] } + : [ + { + name: "figma-desktop", + status: { status: target === workspace ? state.status : "connected", error }, + }, + ], + }, + }) + }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable() + if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click() + if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;") + const panel = + surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true }) + const toggle = panel.getByRole("switch") + await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible() + await expect(toggle).not.toBeChecked() + await expect(toggle).toBeEnabled() + requests.length = 0 + + await panel.locator('[data-slot="switch-control"]').click() + const toast = page + .getByRole("listitem", { includeHidden: true }) + .filter({ has: page.getByText("Request failed", { exact: true }) }) + await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible() + await expect(toggle).not.toBeChecked() + await expect(toggle).toBeEnabled() + expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([ + { path: "/api/mcp/figma-desktop/connect", directory: workspace }, + ]) + expect(requests.every((request) => request.directory === workspace)).toBe(true) + await expect(toast).toHaveCSS("opacity", "1") + await testInfo.attach("mcp-connection-error", { body: await page.screenshot(), contentType: "image/png" }) + + if (surface === "popover") await page.keyboard.press("Escape") + if (surface === "dialog") await panel.getByRole("button", { name: "Close", exact: true }).click() + await expect(panel).toBeHidden() + await toast.getByRole("button", { name: "Dismiss", exact: true }).click() + await expect(toast).toBeHidden() + state.fail = false + if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click() + if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;") + await expect(toggle).toBeEnabled() + await panel.locator('[data-slot="switch-control"]').click() + await expect(toggle).toBeChecked() + await expect(toggle).toBeEnabled() + await expect(toast).toBeHidden() + }) +} diff --git a/packages/app/src/providers/connect/mcp.ts b/packages/app/src/providers/connect/mcp.ts index 5e0906f301c..f3f4a55ba47 100644 --- a/packages/app/src/providers/connect/mcp.ts +++ b/packages/app/src/providers/connect/mcp.ts @@ -40,6 +40,9 @@ export function useMcpToggle(directory?: Accessor, onSuccess data.location.mcp.server.invalidate(ref) data.location.mcp.resource.invalidate(ref) await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()]) + // A successful HTTP response can still leave the MCP connection in a failed state. + const status = data.location.mcp.server.list(ref)?.find((item) => item.name === name)?.status + if (status?.status === "failed") throw new Error(`${name}: ${status.error}`) }, onError: (error) => showToast({