Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/acceptance/mcp-refresh-status/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/acceptance/mcp-refresh-status/before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions web/src/components/extension-onboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ExtensionSettings client={new APIClient("read", "/api/v1", "control")}
selectedRunID="" selectedWorkspaceID="project-one" />);
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);
Expand Down
5 changes: 5 additions & 0 deletions web/src/components/shared-settings-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Loading