From c9f74dddf358138dea946e5a99b91369546a0a36 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 24 Oct 2026 23:33:17 -0400 Subject: [PATCH] fix(web): keep a failed connect's reason on the server card (#2783) The connect-failure toast auto-closes after a few seconds, after which the server card showed only a red "Failed" with no reason. The reason was already held in connectErrorMessage but only exposed as a data attribute for automation. Thread it to the errored card and render it under the header, in compact mode too, so a failure like the legacy SSE endpoint origin mismatch stays readable. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../groups/ServerCard/ServerCard.stories.tsx | 15 ++++++ .../groups/ServerCard/ServerCard.test.tsx | 48 +++++++++++++++++++ .../groups/ServerCard/ServerCard.tsx | 18 +++++++ .../ServerListScreen.test.tsx | 16 +++++++ .../ServerListScreen/ServerListScreen.tsx | 8 ++++ .../views/InspectorView/InspectorView.tsx | 1 + .../components/views/InspectorView/types.ts | 3 +- clients/web/src/theme/Text.ts | 14 ++++++ 8 files changed, 122 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx index 74e05e86e8..1a1fbbe5cd 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.stories.tsx @@ -102,6 +102,21 @@ export const ErroredBorder: Story = { }, }; +// The failure reason kept on the errored card (#2783), so it is still there +// after the toast that first reported it auto-closes. A long transport error +// with a URL in it wraps inside the card rather than running under the border. +export const ErroredWithReason: Story = { + args: { + id: "5e4d3c2b-1a0f-4e9d-8c7b-6a5f4e3d2c1b", + name: "Legacy SSE Server", + config: { type: "sse", url: "https://mcp.example.com/sse" }, + connection: disconnected, + errored: true, + errorMessage: + 'Remote connect failed (500): {"error":"Failed to start transport: Endpoint origin does not match connection origin: http://10.0.0.5:8000"}', + }, +}; + // The green border flagging the server that just connected (#1682) — the // success mirror of ErroredBorder. It persists while connected and clears on // disconnect or a new connection attempt. diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx index 49d89ff6ff..7d9787f69c 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.test.tsx @@ -364,6 +364,54 @@ describe("ServerCard", () => { }); }); + describe("connection-failed reason (#2783)", () => { + const reason = + "Failed to start transport: Endpoint origin does not match connection origin: http://10.0.0.5:8000"; + + it("shows the failure reason on an errored card", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent(reason); + }); + + it("shows the failure reason in compact mode too", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("alert")).toHaveTextContent(reason); + }); + + it("hides a stale reason once the card is no longer errored", () => { + renderWithMantine( + , + ); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("renders no reason line for an errored card without a message", () => { + renderWithMantine( + , + ); + expect(screen.queryByRole("alert")).toBeNull(); + }); + }); + describe("connection-failed border (#1621)", () => { it("draws the red errored-variant border when errored", () => { const { container } = renderWithMantine( diff --git a/clients/web/src/components/groups/ServerCard/ServerCard.tsx b/clients/web/src/components/groups/ServerCard/ServerCard.tsx index ef62e272ef..8eac0dd472 100644 --- a/clients/web/src/components/groups/ServerCard/ServerCard.tsx +++ b/clients/web/src/components/groups/ServerCard/ServerCard.tsx @@ -45,6 +45,12 @@ export interface ServerCardProps extends ServerEntry { * is connected or a new connection is attempted. */ errored?: boolean; + /** + * Why the last connection attempt failed, shown under the header while + * `errored` is set (#2783). The failure toast auto-closes after a few + * seconds, which left a bare "Failed" with no reason once it had gone. + */ + errorMessage?: string; /** * When true, this server just connected successfully: the card draws the green * highlight border and scrolls itself into view once the monitoring sidebar @@ -108,6 +114,11 @@ const ProtocolText = Text.withProps({ ml: "auto", }); +const ConnectErrorText = Text.withProps({ + variant: "connectError", + role: "alert", +}); + const SubtleButton = Button.withProps({ variant: "subtle", size: "xs", @@ -173,6 +184,7 @@ export function ServerCard({ dragHandle, highlighted = false, errored = false, + errorMessage, justConnected = false, scrollOnHighlight = true, onClearHighlight, @@ -269,6 +281,12 @@ export function ServerCard({ + {/* Shown in compact mode too: the reason is the one thing a failed card + has to say, and compact hides everything else below the header. */} + {errored && errorMessage && ( + {errorMessage} + )} + {!compact && ( <> diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx index 2c72927a65..79ef64dfda 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.test.tsx @@ -162,6 +162,22 @@ describe("ServerListScreen", () => { container.querySelectorAll('[data-variant="errored"]'), ).toHaveLength(0); }); + + it("shows the failure reason only on the failed server's card (#2783)", () => { + renderWithMantine( + , + ); + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toHaveTextContent("Remote connect failed (500)"); + expect(alerts[0]!.closest('[data-variant="errored"]')).toHaveTextContent( + "Beta", + ); + }); }); describe("just-connected highlight (#1682)", () => { diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx index d66109a14d..5694cd2ea5 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.tsx @@ -48,6 +48,11 @@ export interface ServerListScreenProps { * draws a red border until another server is connected or attempted. */ erroredServerId?: string; + /** + * Why that server's connection attempt failed (#2783), shown on the + * `erroredServerId` card so the reason outlasts the failure toast. + */ + connectErrorMessage?: string; /** * Id of the server that just connected successfully (#1682). Its card draws * the green highlight border and scrolls into view — the success mirror of @@ -190,6 +195,7 @@ export function ServerListScreen({ writable = true, activeServer, erroredServerId, + connectErrorMessage, connectedServerId, onAddManually, onImportConfig, @@ -252,6 +258,8 @@ export function ServerListScreen({ onRemove, highlighted: highlightedServerIds?.includes(server.id) ?? false, errored: server.id === erroredServerId, + errorMessage: + server.id === erroredServerId ? connectErrorMessage : undefined, justConnected: server.id === connectedServerId, scrollOnHighlight: server.id === firstHighlightedId, onClearHighlight: onClearHighlight diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 048837a617..9688ed97db 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1174,6 +1174,7 @@ export function InspectorView({ writable={serverListWritable} activeServer={dimCardsAgainst} erroredServerId={erroredServerId} + connectErrorMessage={connectErrorMessage} connectedServerId={connectedServerId} onAddManually={onServerAdd} onImportConfig={onServerImportConfig} diff --git a/clients/web/src/components/views/InspectorView/types.ts b/clients/web/src/components/views/InspectorView/types.ts index 538e6734c5..7aef67b68e 100644 --- a/clients/web/src/components/views/InspectorView/types.ts +++ b/clients/web/src/components/views/InspectorView/types.ts @@ -144,7 +144,8 @@ export interface ConnectionProps { * Last connection-level error message (handshake failure, OAuth start * failure, deep-link automation failure). Surfaced as `data-error-message` * on the header's `connection-status` testid so an automated driver can read - * *why* a connect failed without scraping a transient toast. + * *why* a connect failed without scraping a transient toast, and — for the + * same reason, but for people — on the `erroredServerId` card (#2783). */ connectErrorMessage?: string; initializeResult?: InitializeResult; diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index 6d0052262b..c89cf0a19b 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -37,6 +37,20 @@ export const ThemeText = Text.extend({ }, }; } + // The reason a server card's last connect attempt failed (#2783), kept on + // the card after the toast that first reported it auto-closes. The text + // is often a raw transport error carrying a URL or a JSON body, so it has + // to wrap inside the card rather than run under its border. + if (props.variant === "connectError") { + return { + root: { + color: "var(--inspector-danger-text)", + fontSize: "var(--mantine-font-size-sm)", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }, + }; + } // Two small, unobtrusive labels that sit in the footer row (#1682): the // build version (left) and the copyright notice (right), positioned by the // footer's `space-between` Group. Both are grey, single-line, and out of the