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