diff --git a/packages/webui/src/client/components/PluginManagement.tsx b/packages/webui/src/client/components/PluginManagement.tsx
index e5ba74c1..0eda4f26 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 8d41f734..8e55639b 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 35503db4..63ee3217 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 ecc8b735..daef2263 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 00000000..b13cc42d
--- /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);
+});