From 98161c6ef4b9a14a1a251b83e82195ff6658bd40 Mon Sep 17 00:00:00 2001
From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com>
Date: Mon, 5 Oct 2026 20:31:33 +0800
Subject: [PATCH] fix(webui): label each MCP server's connection state on the
plugin row
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Roadmap D-3 (as transcribed): MCP 插件连不上时,界面上要看得见. The
runtime already classifies every server (LocalMcpPublicServerStatus:
available / configured / disabled / error / unavailable) and attaches
the failure's own reason to the two trouble states — the panel's MCP
rows rendered none of it, so a server that could not connect was
visually identical to a healthy one, and the only difference left on
the row was its tool count not appearing anywhere either.
The row now carries a status chip in the slot the apps area already
uses for its runtime state: 已连接 / 未连接 / 已停用 for the calm
states (configured is deliberately NOT trouble — enabled and resting
is a server that connects on use), 连接失败 in the danger tone and
不可用 in the warning tone for the trouble states, each with the
runtime's own reason string as visible inline text (ellipsis-clamped
with the full text on the title, so a long server error cannot push
the 编辑/删除/开关 strip out of the row). The mapping lives in an
exported pure function, describeWebuiMcpServerStatus, reading the
loose row shape the panel already uses.
The browser fixture gains setPluginManagementResult(action, result)
so a listing with every connection state can be staged for the
panel's reload.
Tests: plugin-mcp-status.test.tsx (6 — each state's label, reason
passthrough for error/unavailable including the errorMessage key,
trouble states do not share the neutral tone, unknown status claims
nothing, plus row-wiring source assertions) and
plugin-mcp-status.spec.mjs (2 browser cases walking the real path —
rail 插件 → 管理 → MCP — reading placed, loaded rows: failure states
labelled with their server's own reason, calm states labelled so
trouble has honest neighbours, and no invented reasons).
---
.../client/components/PluginManagement.tsx | 75 +++++++++++-
packages/webui/src/client/styles/shell.css | 19 +++
.../test/unit/plugin-mcp-status.test.tsx | 110 ++++++++++++++++++
release/public-source.json | 2 +
test/vitest-suites.json | 1 +
test/webui-browser/fixture.mjs | 12 ++
test/webui-browser/plugin-mcp-status.spec.mjs | 95 +++++++++++++++
7 files changed, 313 insertions(+), 1 deletion(-)
create mode 100644 packages/webui/test/unit/plugin-mcp-status.test.tsx
create mode 100644 test/webui-browser/plugin-mcp-status.spec.mjs
diff --git a/packages/webui/src/client/components/PluginManagement.tsx b/packages/webui/src/client/components/PluginManagement.tsx
index e5ba74c12..0eda4f26a 100644
--- a/packages/webui/src/client/components/PluginManagement.tsx
+++ b/packages/webui/src/client/components/PluginManagement.tsx
@@ -24,6 +24,51 @@ const sameSelection = (
left.view === right.view &&
left.query === right.query &&
left.category === right.category;
+
+/**
+ * One MCP server's connection state, as the row shows it.
+ *
+ * The runtime already classifies every server
+ * (`LocalMcpPublicServerStatus`: `available` / `configured` / `disabled` /
+ * `error` / `unavailable`) and attaches the failure's own reason to the two
+ * trouble states — the D-3 gap was that the row rendered none of it, so a
+ * server that could not connect was indistinguishable from a healthy one
+ * unless you already knew to look elsewhere. `configured` is deliberately
+ * NOT trouble: it means enabled and not currently connected, which is the
+ * resting state of a server that connects on use.
+ */
+export interface WebuiMcpServerStatusView {
+ readonly key:
+ | "available"
+ | "configured"
+ | "disabled"
+ | "error"
+ | "unavailable"
+ | "unknown";
+ readonly label: string;
+ readonly tone: string;
+ readonly reason?: string;
+}
+
+export function describeWebuiMcpServerStatus(item: Row): WebuiMcpServerStatusView {
+ const status = read(item, "status");
+ const reason = read(item, "error", "errorMessage") || undefined;
+ switch (status) {
+ case "available":
+ return { key: "available", label: "已连接", tone: "text-text_default_secondary" };
+ case "configured":
+ return { key: "configured", label: "未连接", tone: "text-text_default_tertiary" };
+ case "disabled":
+ return { key: "disabled", label: "已停用", tone: "text-text_default_tertiary" };
+ case "error":
+ return { key: "error", label: "连接失败", tone: "text-text_label_danger_secondary_default", reason };
+ case "unavailable":
+ return { key: "unavailable", label: "不可用", tone: "text-text_label_warning_secondary_default", reason };
+ default:
+ return { key: "unknown", label: "未知状态", tone: "text-text_default_tertiary" };
+ }
+}
+
const CATEGORIES: readonly { id: Area; label: string }[] = [
{ id: "plugins", label: "插件" },
{ id: "skills", label: "技能" },
@@ -1083,6 +1128,10 @@ export function PluginManagement({
const name = nameOf(item) || `item-${index}`;
const isOn = enabled(item);
const description = read(item, "description", "summary");
+ // Computed for every row, read only in the mcp branch: the loose
+ // read is side-effect-free, and narrowing `area` inside the JSX
+ // below cannot narrow this binding.
+ const mcpStatus = describeWebuiMcpServerStatus(item);
const action =
area === "plugins"
? view === "market"
@@ -1195,7 +1244,30 @@ export function PluginManagement({
{appStatus(item)}
) : area === "mcp" ? (
-
+ <>
+ {/* The status the runtime already knows, on the row
+ * itself: a server that cannot connect is labelled
+ * 连接失败 with its own reason inline — visible text, not
+ * a tooltip, because nobody hovers a row they believe
+ * is healthy. Mirrors the apps area's status slot. */}
+
{
+ it("labels each runtime state a user can act on", () => {
+ expect(describeWebuiMcpServerStatus({ status: "available" })).toMatchObject({
+ key: "available",
+ label: "已连接",
+ });
+ expect(describeWebuiMcpServerStatus({ status: "configured" })).toMatchObject({
+ key: "configured",
+ label: "未连接",
+ });
+ expect(describeWebuiMcpServerStatus({ status: "disabled" })).toMatchObject({
+ key: "disabled",
+ label: "已停用",
+ });
+ expect(describeWebuiMcpServerStatus({ status: "error" })).toMatchObject({
+ key: "error",
+ label: "连接失败",
+ });
+ expect(describeWebuiMcpServerStatus({ status: "unavailable" })).toMatchObject({
+ key: "unavailable",
+ label: "不可用",
+ });
+ });
+
+ it("carries the failure's own reason on the two trouble states", () => {
+ // The reason is the point of D-3: 连接失败 alone does not tell the user
+ // whether to fix a URL, a command, or a credential. The runtime's own
+ // string arrives verbatim.
+ expect(
+ describeWebuiMcpServerStatus({
+ status: "error",
+ error: "MCP_HANDSHAKE_FAILED: server sent no initialize response",
+ }).reason,
+ ).toBe("MCP_HANDSHAKE_FAILED: server sent no initialize response");
+ expect(
+ describeWebuiMcpServerStatus({
+ status: "unavailable",
+ errorMessage: "MCP_COMMAND_NOT_FOUND: ./missing-bin",
+ }).reason,
+ ).toBe("MCP_COMMAND_NOT_FOUND: ./missing-bin");
+ });
+
+ it("keeps the calm states calm and the trouble states coloured", () => {
+ // The two trouble states must not share the neutral tone, or the row
+ // reads as healthy at a glance — the exact failure D-3 describes.
+ const neutral = describeWebuiMcpServerStatus({ status: "available" }).tone;
+ expect(describeWebuiMcpServerStatus({ status: "configured" }).tone).toBe(
+ describeWebuiMcpServerStatus({ status: "disabled" }).tone,
+ );
+ expect(describeWebuiMcpServerStatus({ status: "error" }).tone).not.toBe(neutral);
+ expect(describeWebuiMcpServerStatus({ status: "unavailable" }).tone).not.toBe(neutral);
+ });
+
+ it("treats a missing or unrecognized status as unknown, without inventing a reason", () => {
+ expect(describeWebuiMcpServerStatus({})).toMatchObject({
+ key: "unknown",
+ label: "未知状态",
+ });
+ // An unknown status with an error string still shows no reason: the
+ // label 未知状态 claims nothing, and a reason under it would claim
+ // something the reader cannot verify.
+ expect(describeWebuiMcpServerStatus({ status: 7, error: "x" }).reason).toBeUndefined();
+ });
+});
+
+describe("the MCP row wiring", () => {
+ it("renders the status view on the row, reason included", () => {
+ const statusAt = SOURCE.indexOf('data-webui-mcp-status={mcpStatus.key}');
+ expect(statusAt, "the mcp row no longer renders the status chip").toBeGreaterThanOrEqual(0);
+ const row = SOURCE.slice(statusAt - 600, statusAt + 900);
+ expect(row).toContain('{mcpStatus.label}');
+ expect(row).toContain('{mcpStatus.reason}');
+ expect(row).toContain('data-webui-mcp-status-reason={mcpStatus.key}');
+ });
+
+ it("derives the view from the row item the list loads", () => {
+ const deriveAt = SOURCE.indexOf("describeWebuiMcpServerStatus(item)");
+ expect(deriveAt).toBeGreaterThanOrEqual(0);
+ // …and inside the row map, not somewhere orphaned from rendering.
+ const mapAt = SOURCE.indexOf(".map((item, index) => {");
+ expect(mapAt).toBeGreaterThan(0);
+ expect(deriveAt).toBeGreaterThan(mapAt);
+ expect(deriveAt - mapAt).toBeLessThan(2_000);
+ });
+});
diff --git a/release/public-source.json b/release/public-source.json
index 8d41f7340..8e55639bc 100644
--- a/release/public-source.json
+++ b/release/public-source.json
@@ -3696,6 +3696,7 @@
"packages/webui/test/unit/output-error.test.tsx",
"packages/webui/test/unit/outside-close.test.ts",
"packages/webui/test/unit/plugin-market-catalogue.test.tsx",
+ "packages/webui/test/unit/plugin-mcp-status.test.tsx",
"packages/webui/test/unit/projections.test.ts",
"packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx",
"packages/webui/test/unit/queue-panel.test.ts",
@@ -3829,6 +3830,7 @@
"test/webui-browser/connection-status.spec.mjs",
"test/webui-browser/fixture.mjs",
"test/webui-browser/harness.mjs",
+ "test/webui-browser/plugin-mcp-status.spec.mjs",
"test/webui-browser/questionnaire.spec.mjs",
"test/webui-browser/rail-context-menu.spec.mjs",
"test/webui-browser/rail-pin.spec.mjs",
diff --git a/test/vitest-suites.json b/test/vitest-suites.json
index 35503db4a..63ee3217c 100644
--- a/test/vitest-suites.json
+++ b/test/vitest-suites.json
@@ -286,6 +286,7 @@
"packages/webui/test/unit/session-transfer-route.test.ts",
"packages/webui/test/unit/outside-close.test.ts",
"packages/webui/test/unit/plugin-market-catalogue.test.tsx",
+ "packages/webui/test/unit/plugin-mcp-status.test.tsx",
"packages/webui/test/unit/webui-plan-mode.test.ts",
"packages/webui/test/unit/questionnaire-goal-autoreply.test.tsx",
"packages/webui/test/unit/transcript-scroll.test.ts",
diff --git a/test/webui-browser/fixture.mjs b/test/webui-browser/fixture.mjs
index ecc8b735a..daef22630 100644
--- a/test/webui-browser/fixture.mjs
+++ b/test/webui-browser/fixture.mjs
@@ -67,6 +67,11 @@ export function installFixtureTransport() {
// questionnaire has no other way to say what the server answers on the NEXT
// poll. `undefined` keeps the old "no pending questionnaire" answer.
let questionnaire;
+ // Per-action results for the plugin-management facade. The panel asks for
+ // one action at a time (`{action, input}`), so a test stages exactly the
+ // listing it wants (an MCP server list with connection states, say) and
+ // every other action keeps the empty default.
+ const pluginManagementResults = {};
let activeTurn;
const requests = [];
const sockets = new Set();
@@ -86,6 +91,10 @@ export function installFixtureTransport() {
if (operation === "listPendingPermissions") return { requests: [] };
if (operation === "getActiveTurn") return activeTurn;
if (operation === "getPendingQuestionnaire") return questionnaire ? { request: questionnaire } : {};
+ if (operation === "pluginManagement") {
+ const action = typeof body?.action === "string" ? body.action : "";
+ return pluginManagementResults[action] ?? {};
+ }
if (operation === "dismissQuestionnaire") return { ok: true };
if (operation === "replyQuestionnaire") return { ok: true };
if (operation === "listQueueMessages") return { items: [] };
@@ -228,6 +237,9 @@ export function installFixtureTransport() {
// is inert on purpose, so a staged questionnaire stays pending until the
// test answers, dismisses, or replaces it.
setQuestionnaire(request) { questionnaire = request ? clone(request) : undefined; },
+ setPluginManagementResult(action, result) {
+ pluginManagementResults[action] = result === undefined ? undefined : clone(result);
+ },
// Kill every live socket (the "server went away" moment) and/or decide
// whether new connections may form. Between a dropAll() and reopening
// the gate, the client is fully disconnected — with no turn running,
diff --git a/test/webui-browser/plugin-mcp-status.spec.mjs b/test/webui-browser/plugin-mcp-status.spec.mjs
new file mode 100644
index 000000000..b13cc42d3
--- /dev/null
+++ b/test/webui-browser/plugin-mcp-status.spec.mjs
@@ -0,0 +1,95 @@
+// The MCP servers' connection state on the plugin panel, end to end against
+// the built client.
+//
+// Roadmap D-3 (as transcribed): 「MCP 插件连不上时,界面上要看得见」. The
+// runtime classifies every server and attaches the failure's own reason to
+// the trouble states; the panel's MCP rows used to render none of it, so a
+// dead server was visually identical to a healthy one. This spec walks the
+// real path a user walks — rail 插件 → 管理 → MCP — and reads the placed,
+// loaded rows: every state labelled, the two failure states carrying their
+// server's own reason as visible text.
+
+import { expect } from "@playwright/test";
+
+import { openApp, test } from "./harness.mjs";
+
+test.beforeEach(async ({ page }) => {
+ page.on("pageerror", (error) => console.error("BROWSER_PAGE_ERROR", error.stack ?? error.message));
+ page.on("console", (message) => { if (message.type() === "error") console.error("BROWSER_CONSOLE_ERROR", message.text()); });
+});
+
+/** Stages the MCP listing before boot; the panel's reload picks it up. */
+async function stageMcpServers(page, servers) {
+ await page.addInitScript((value) => {
+ window.__WEBUI_FIXTURE_SETUP__ ??= [];
+ window.__WEBUI_FIXTURE_SETUP__.push(() =>
+ window.__fixture.setPluginManagementResult("listMcpServers", value),
+ );
+ }, { servers });
+}
+
+async function openMcpArea(page) {
+ await page.getByRole("button", { name: "插件" }).first().click();
+ await page.locator(".webui-plugin-manage-trigger").click();
+ await page.locator('.webui-plugin-category-filter button', { hasText: "MCP" }).click();
+}
+
+function rowOf(page, name) {
+ return page.locator(".webui-plugin-row", { hasText: name });
+}
+
+test("a server that cannot connect is labelled 连接失败 with its own reason", async ({ page }) => {
+ await stageMcpServers(page, [
+ {
+ name: "broken-http",
+ transport: "http",
+ enabled: true,
+ status: "error",
+ available: false,
+ error: "MCP_CONNECTION_TIMEOUT: 握手超时(10s)",
+ },
+ {
+ name: "gone-binary",
+ transport: "stdio",
+ enabled: true,
+ status: "unavailable",
+ available: false,
+ error: "MCP_COMMAND_NOT_FOUND: ./missing-bin",
+ },
+ ]);
+ await openApp(page, "#session=A");
+ await openMcpArea(page);
+
+ // The failure states come with their server's own reason as visible text —
+ // not a tooltip, because nobody hovers a row they believe is healthy.
+ const broken = rowOf(page, "broken-http").locator("[data-webui-mcp-status]");
+ await expect(broken).toHaveText("连接失败");
+ await expect(broken).toHaveAttribute("data-webui-mcp-status", "error");
+ await expect(rowOf(page, "broken-http").locator("[data-webui-mcp-status-reason]"))
+ .toHaveText("MCP_CONNECTION_TIMEOUT: 握手超时(10s)");
+
+ const gone = rowOf(page, "gone-binary").locator("[data-webui-mcp-status]");
+ await expect(gone).toHaveText("不可用");
+ await expect(gone).toHaveAttribute("data-webui-mcp-status", "unavailable");
+ await expect(rowOf(page, "gone-binary").locator("[data-webui-mcp-status-reason]"))
+ .toHaveText("MCP_COMMAND_NOT_FOUND: ./missing-bin");
+});
+
+test("the calm states are labelled too, so trouble has honest neighbours", async ({ page }) => {
+ // Without the quiet half, every label would read as an alarm: a panel that
+ // says 未连接 on a resting server and 连接失败 on a dead one is legible;
+ // one that says nothing except on failure is a trap.
+ await stageMcpServers(page, [
+ { name: "ok-stdio", transport: "stdio", enabled: true, status: "available", available: true },
+ { name: "idle", transport: "stdio", enabled: true, status: "configured", available: false },
+ { name: "off", transport: "http", enabled: false, status: "disabled", available: false },
+ ]);
+ await openApp(page, "#session=A");
+ await openMcpArea(page);
+
+ await expect(rowOf(page, "ok-stdio").locator("[data-webui-mcp-status]")).toHaveText("已连接");
+ await expect(rowOf(page, "idle").locator("[data-webui-mcp-status]")).toHaveText("未连接");
+ await expect(rowOf(page, "off").locator("[data-webui-mcp-status]")).toHaveText("已停用");
+ // And no row invents a reason where the runtime gave none.
+ await expect(page.locator("[data-webui-mcp-status-reason]")).toHaveCount(0);
+});