diff --git a/docs/acceptance/mcp-refresh-status/README.md b/docs/acceptance/mcp-refresh-status/README.md new file mode 100644 index 00000000..67be3dd4 --- /dev/null +++ b/docs/acceptance/mcp-refresh-status/README.md @@ -0,0 +1,65 @@ +# MCP rediscovery status refresh + +Verified on Windows with Chrome on 2026-10-22 (Asia/Hong_Kong). The three page +screenshots use the same 1480 × 1080 viewport; the error-detail image is an actual +element screenshot. + +The baseline frontend was built from `fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`. +Both frontends used the same production `cyberagent api serve` process and SQLite +store. The candidate was served from its production build using Vite preview's +existing API proxy. No application API responses, DOM content, or stored records +were replaced for the screenshots. + +The local Streamable HTTP MCP service implemented `initialize`, +`notifications/initialized`, and `tools/list`. Its socket was closed to reproduce +disconnection, then reopened at the same endpoint. The registration was created, +reviewed, discovered, and enabled through the real API: + +- Server: `local-mcp-recovery`, displayed as **Local MCP recovery**. +- Workspace: `ws-import-54354eafc3311a24284282b2`. +- Endpoint: `http://127.0.0.1:65044/mcp`. +- Discovered tool: `lookup_connection_note`. + +## Observed behavior + +| Step | Real API result | Visible result | +| --- | --- | --- | +| Baseline: stop the MCP service and click **重新发现** | POST refresh returns 503; a separate GET inventory confirms `unavailable` | Mutation error appears, but the card incorrectly retains `healthy` | +| Candidate: restore and rediscover, then stop the service and click **重新发现** | POST refresh returns 503, followed automatically by GET inventory 200 | Card becomes `unavailable`; mutation error remains visible | +| Candidate: make the MCP service return a JSON-RPC error containing a synthetic `token=` value | POST returns 503; automatic GET contains only `MCP request failed with remote code -32000` | Card displays the safe saved error; raw token text is absent from the API inventory and page | +| Candidate: restore the same MCP service and click **重新发现** | POST returns 202; automatic GET returns `healthy` with no health message | Card returns to `healthy`; mutation and saved health errors both disappear | + +The candidate browser request log showed POST/GET pairs `503/200`, `503/200`, and +`202/200`. Its only two console errors were the expected 503 responses. The MCP +service recorded a fresh initialization and `tools/list` after recovery. + +### Before: stale healthy status after failure + +![Baseline showing discovery failure and stale healthy status](before.png) + +### After: saved unavailable status, with the original error retained + +![Candidate showing discovery failure and unavailable status](after-failed.png) + +### After: safe error detail from the real inventory + +![MCP card showing the sanitized remote error](after-sanitized-error.png) + +### After: successful recovery clears the error + +![Candidate showing healthy status after recovery](after-recovered.png) + +## Focused checks + +- New regression failed on the baseline because the card never became + `unavailable`. +- `npx vitest run src/components/extension-onboarding.test.tsx`: 12 passed. The + regression checks scoped inventory refetch, the saved health/error, preservation + of the mutation error, and successful recovery clearing both errors. +- `npm run build`: passed, including TypeScript; existing large-chunk warning. +- `go build -o output/playwright/mcp-refresh-status/cyberagent.exe ./cmd/cyberagent`: + passed for the production API used by this acceptance run. + +The product change invalidates the existing extensions query only when MCP +rediscovery fails. Existing success invalidation and mutation error rendering +remain in use. diff --git a/docs/acceptance/mcp-refresh-status/after-failed.png b/docs/acceptance/mcp-refresh-status/after-failed.png new file mode 100644 index 00000000..d46d8e05 Binary files /dev/null and b/docs/acceptance/mcp-refresh-status/after-failed.png differ diff --git a/docs/acceptance/mcp-refresh-status/after-recovered.png b/docs/acceptance/mcp-refresh-status/after-recovered.png new file mode 100644 index 00000000..2a64f8cd Binary files /dev/null and b/docs/acceptance/mcp-refresh-status/after-recovered.png differ diff --git a/docs/acceptance/mcp-refresh-status/after-sanitized-error.png b/docs/acceptance/mcp-refresh-status/after-sanitized-error.png new file mode 100644 index 00000000..89b76c14 Binary files /dev/null and b/docs/acceptance/mcp-refresh-status/after-sanitized-error.png differ diff --git a/docs/acceptance/mcp-refresh-status/before.png b/docs/acceptance/mcp-refresh-status/before.png new file mode 100644 index 00000000..7c5533e6 Binary files /dev/null and b/docs/acceptance/mcp-refresh-status/before.png differ diff --git a/web/src/components/extension-onboarding.test.tsx b/web/src/components/extension-onboarding.test.tsx index e0ee9ef9..9a6ac66a 100644 --- a/web/src/components/extension-onboarding.test.tsx +++ b/web/src/components/extension-onboarding.test.tsx @@ -152,6 +152,53 @@ it("registers MCP only on submit, reviews each pinned stage, and reads actual ca expect(screen.getByText(/在任务消息和证据中查看具体返回内容/)).toBeInTheDocument(); }); +it("refreshes saved MCP health after rediscovery fails and clears errors after recovery", async () => { + const user = userEvent.setup(); + const current = inventory(); + current.mcp_servers = [mcpServer("enabled")]; + const healthMessage = "Connection failed: token=[REDACTED]"; + let inventoryReads = 0; + let rediscoveries = 0; + vi.stubGlobal("fetch", vi.fn(async (url: string, init?: RequestInit) => { + const path = url.split("?")[0]; + if (path.endsWith("/code-intel")) return respond({ ...emptyLSP, workspace_id: "project-one" }); + if (init?.method !== "POST") { + expect(path).toBe("/api/v1/extensions"); + expect(new URL(url, "http://localhost").searchParams.get("workspace_id")).toBe("project-one"); + inventoryReads++; + return respond(current); + } + expect(path).toBe("/api/v1/extensions/mcp/first-mcp/refresh"); + expect(JSON.parse(String(init.body))).toEqual({ version: "extension-control.v1" }); + rediscoveries++; + if (rediscoveries === 1) { + current.mcp_servers = [{ ...mcpServer("enabled"), health: "unavailable", health_message: healthMessage }]; + return new Response(JSON.stringify({ version: "api.v1", request_id: "refresh-failed", + error: { code: "UNAVAILABLE", message: "MCP server discovery failed" }, + }), { status: 503, headers: { "Content-Type": "application/json" } }); + } + current.mcp_servers = [mcpServer("enabled")]; + return respond(current.mcp_servers[0]); + })); + mount(); + const card = (await screen.findByText("First MCP")).closest("article")!; + expect(within(card).getByText("healthy")).toBeInTheDocument(); + await user.click(within(card).getByRole("button", { name: "重新发现" })); + expect(await within(card).findByText("unavailable")).toBeInTheDocument(); + expect(within(card).getByText(healthMessage)).toBeInTheDocument(); + expect(screen.getByText("MCP server discovery failed")).toBeInTheDocument(); + expect(inventoryReads).toBe(2); + expect(rediscoveries).toBe(1); + + await user.click(within(card).getByRole("button", { name: "重新发现" })); + expect(await within(card).findByText("healthy")).toBeInTheDocument(); + expect(screen.queryByText(healthMessage)).not.toBeInTheDocument(); + expect(screen.queryByText("MCP server discovery failed")).not.toBeInTheDocument(); + expect(inventoryReads).toBe(3); + expect(rediscoveries).toBe(2); +}); + it("preserves the Plugin file and exact upload on failure, then requires separate untrusted review and enable", async () => { const user = userEvent.setup(); vi.stubGlobal("crypto", webcrypto); diff --git a/web/src/components/shared-settings-panels.tsx b/web/src/components/shared-settings-panels.tsx index ac70cd34..1c356499 100644 --- a/web/src/components/shared-settings-panels.tsx +++ b/web/src/components/shared-settings-panels.tsx @@ -142,6 +142,11 @@ export function ExtensionSettings({ client, selectedRunID, selectedWorkspaceID = }); }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["extensions"] }), + onError: (_error, value) => { + if (value.kind === "refresh-mcp") { + return queryClient.invalidateQueries({ queryKey: ["extensions"] }); + } + }, }); const qualificationOnly = codeIntel.data?.qualifications.filter((qualification) => !codeIntel.data.servers.some((server) => server.workspace_id === qualification.workspace_id &&