fix(web): render the connected header when a modern server omits serverInfo (#1773)
A modern-era server/discover makes serverInfo optional (SHOULD, not MUST); a conforming modern server that omits it left initializeResult undefined, collapsing the entire connected header + tab bar so the server was uninspectable. Gate initializeResult on connectionStatus alone with a catalog-name fallback, and thread a serverInfoReported flag so the Connection Info modal reports the omission truthfully instead of presenting the inferred name as server-sent. Closes #1772.
This commit is contained in:
parent
da37692485
commit
820d38c438
6 changed files with 288 additions and 21 deletions
|
|
@ -3,7 +3,11 @@ import type {
|
|||
InitializeResult,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ConnectionInfoContent } from "./ConnectionInfoContent";
|
||||
import { expect, within } from "storybook/test";
|
||||
import {
|
||||
ConnectionInfoContent,
|
||||
SERVER_INFO_NOT_REPORTED_LABEL,
|
||||
} from "./ConnectionInfoContent";
|
||||
|
||||
const fullResult: InitializeResult = {
|
||||
protocolVersion: "2025-03-26",
|
||||
|
|
@ -75,6 +79,38 @@ export const ModernEra: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
// A modern server that omitted the optional `_meta` serverInfo stamp (#1772).
|
||||
// `initializeResult.serverInfo` here is App's client-side catalog fallback, and
|
||||
// `serverInfoReported: false` tells the modal not to present it as server-sent —
|
||||
// Name and Version read "— (not reported by server)".
|
||||
export const ServerInfoNotReported: Story = {
|
||||
args: {
|
||||
initializeResult: {
|
||||
protocolVersion: "2026-07-28",
|
||||
// The catalog name App synthesizes; must NOT surface as the reported name.
|
||||
serverInfo: { name: "my-catalog-name", version: "" },
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
},
|
||||
serverInfoReported: false,
|
||||
clientCapabilities: { roots: { listChanged: true } },
|
||||
transport: "streamable-http",
|
||||
protocolEra: "modern",
|
||||
// A real modern connection that skipped the `_meta` serverInfo stamp still
|
||||
// has a discover result — with `supportedVersions` + capabilities, just no
|
||||
// `serverInfo`. Including it makes the story accurate and shows the Discovery
|
||||
// section doesn't leak a name either.
|
||||
discoverResult: {
|
||||
supportedVersions: ["2026-07-28", "2025-11-25"],
|
||||
capabilities: { tools: { listChanged: true } },
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument();
|
||||
expect(canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength(2);
|
||||
},
|
||||
};
|
||||
|
||||
export const MinimalCapabilities: Story = {
|
||||
args: {
|
||||
initializeResult: {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|||
import {
|
||||
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||
ConnectionInfoContent,
|
||||
SERVER_INFO_NOT_REPORTED_LABEL,
|
||||
} from "./ConnectionInfoContent";
|
||||
|
||||
const fullResult: InitializeResult = {
|
||||
|
|
@ -62,6 +63,72 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getAllByText("—")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders an em-dash when the reported version is an empty string", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
serverInfo: { name: "Empty Version Server", version: "" },
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="stdio"
|
||||
/>,
|
||||
);
|
||||
// An empty version reads as unknown ("—"), not a blank row (#1772).
|
||||
expect(screen.getByText("Empty Version Server")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("—")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders an em-dash when the reported name is an empty string", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
serverInfo: { name: "", version: "1.0.0" },
|
||||
}}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="stdio"
|
||||
/>,
|
||||
);
|
||||
// `initialize` mandates the name field, not a non-empty value, so an empty
|
||||
// reported name also reads as unknown ("—") — symmetric with version.
|
||||
expect(screen.getByText("1.0.0")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("—")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows 'not reported' for name and version when serverInfo was not reported", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{
|
||||
...fullResult,
|
||||
// App synthesizes a catalog-name fallback here when a modern server
|
||||
// omits serverInfo; the modal must not present it as server-reported.
|
||||
serverInfo: { name: "my-catalog-name", version: "" },
|
||||
}}
|
||||
serverInfoReported={false}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
/>,
|
||||
);
|
||||
// The inferred catalog name is NOT shown as the server's reported name...
|
||||
expect(screen.queryByText("my-catalog-name")).not.toBeInTheDocument();
|
||||
// ...both Name and Version read as not reported instead.
|
||||
expect(screen.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders an em-dash when the protocol version is empty", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={{ ...fullResult, protocolVersion: "" }}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="stdio"
|
||||
/>,
|
||||
);
|
||||
// App supplies `protocolVersion ?? ""`; an empty one reads as unknown ("—"),
|
||||
// symmetric with Name/Version (#1772).
|
||||
expect(screen.getAllByText("—")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("renders server and client capability sections", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
|
|
|
|||
|
|
@ -40,6 +40,16 @@ export interface OAuthDetails {
|
|||
|
||||
export interface ConnectionInfoContentProps {
|
||||
initializeResult: InitializeResult;
|
||||
/**
|
||||
* Whether the server actually reported `serverInfo`. When false (a modern
|
||||
* server that omitted the optional `_meta` stamp), `initializeResult`'s name is
|
||||
* a client-side catalog fallback, so the Server Implementation section renders
|
||||
* "not reported" rather than presenting the inferred name as server-sent — the
|
||||
* exact fact a user opens this modal to check (#1772). Defaults to `true`
|
||||
* (server-reported), which is the norm and what fixtures with a real
|
||||
* `serverInfo` want.
|
||||
*/
|
||||
serverInfoReported?: boolean;
|
||||
clientCapabilities: ClientCapabilities;
|
||||
transport: ServerType;
|
||||
/**
|
||||
|
|
@ -63,6 +73,12 @@ const ValueText = Text.withProps({
|
|||
fw: 600,
|
||||
});
|
||||
|
||||
// Shown for Name/Version when the server didn't report `serverInfo` — an em dash
|
||||
// plus an explicit note so the client-side catalog fallback is never mistaken
|
||||
// for a value the server sent. Exported so tests/stories assert against it
|
||||
// rather than re-typing the copy.
|
||||
export const SERVER_INFO_NOT_REPORTED_LABEL = "— (not reported by server)";
|
||||
|
||||
const SectionHeading = Title.withProps({
|
||||
// `order: 3` (not 5) keeps the heading level one below the modal's `h2`
|
||||
// `Modal.Title`, so the outline doesn't skip a level (axe `heading-order`);
|
||||
|
|
@ -178,6 +194,7 @@ function getCapabilityEntries(
|
|||
|
||||
export function ConnectionInfoContent({
|
||||
initializeResult,
|
||||
serverInfoReported = true,
|
||||
clientCapabilities,
|
||||
transport,
|
||||
protocolEra,
|
||||
|
|
@ -188,6 +205,17 @@ export function ConnectionInfoContent({
|
|||
const { serverInfo, protocolVersion, capabilities, instructions } =
|
||||
initializeResult;
|
||||
|
||||
// Only trust `serverInfo` when the server actually reported it; otherwise the
|
||||
// name is a catalog fallback. Both rows use `||` (not `??`) so a reported-but-
|
||||
// empty name/version reads as unknown ("—") rather than a blank row —
|
||||
// `initialize` mandates the fields, not non-empty values.
|
||||
const displayName = serverInfoReported
|
||||
? serverInfo.name || "—"
|
||||
: SERVER_INFO_NOT_REPORTED_LABEL;
|
||||
const displayVersion = serverInfoReported
|
||||
? serverInfo.version || "—"
|
||||
: SERVER_INFO_NOT_REPORTED_LABEL;
|
||||
|
||||
const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS);
|
||||
const clientCaps = getCapabilityEntries(
|
||||
clientCapabilities,
|
||||
|
|
@ -200,13 +228,13 @@ export function ConnectionInfoContent({
|
|||
<SectionHeading>Server Implementation</SectionHeading>
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Name</Text>
|
||||
<ValueText>{serverInfo.name}</ValueText>
|
||||
<ValueText>{displayName}</ValueText>
|
||||
|
||||
<Text size="sm">Version</Text>
|
||||
<ValueText>{serverInfo.version ?? "—"}</ValueText>
|
||||
<ValueText>{displayVersion}</ValueText>
|
||||
|
||||
<Text size="sm">Protocol</Text>
|
||||
<ValueText>{protocolVersion}</ValueText>
|
||||
<ValueText>{protocolVersion || "—"}</ValueText>
|
||||
|
||||
<Text size="sm">Transport</Text>
|
||||
<Badge variant="outline">{transport}</Badge>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const meta: Meta<typeof ConnectionInfoModal> = {
|
|||
args: {
|
||||
opened: true,
|
||||
onClose: fn(),
|
||||
serverInfoReported: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ describe("ConnectionInfoModal", () => {
|
|||
opened={false}
|
||||
onClose={vi.fn()}
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport="stdio"
|
||||
/>,
|
||||
|
|
@ -42,6 +43,7 @@ describe("ConnectionInfoModal", () => {
|
|||
opened
|
||||
onClose={vi.fn()}
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport="stdio"
|
||||
/>,
|
||||
|
|
@ -60,6 +62,7 @@ describe("ConnectionInfoModal", () => {
|
|||
opened
|
||||
onClose={vi.fn()}
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
|
|
@ -87,6 +90,7 @@ describe("ConnectionInfoModal", () => {
|
|||
opened
|
||||
onClose={onClose}
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport="stdio"
|
||||
/>,
|
||||
|
|
@ -103,6 +107,7 @@ describe("ConnectionInfoModal", () => {
|
|||
opened
|
||||
onClose={onClose}
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport="stdio"
|
||||
/>,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ export interface ConnectionInfoModalProps {
|
|||
opened: boolean;
|
||||
onClose: () => void;
|
||||
initializeResult: InitializeResult;
|
||||
/**
|
||||
* Whether the server actually reported `serverInfo`. False for a modern server
|
||||
* that omitted the optional `_meta` stamp — in which case `initializeResult`'s
|
||||
* name is a client-side catalog fallback, and the modal shows "not reported"
|
||||
* rather than passing it off as server-sent (#1772).
|
||||
*/
|
||||
serverInfoReported: boolean;
|
||||
clientCapabilities: ClientCapabilities;
|
||||
transport: ServerType;
|
||||
protocolEra?: ProtocolEra;
|
||||
|
|
@ -39,6 +46,7 @@ export function ConnectionInfoModal({
|
|||
opened,
|
||||
onClose,
|
||||
initializeResult,
|
||||
serverInfoReported,
|
||||
clientCapabilities,
|
||||
transport,
|
||||
protocolEra,
|
||||
|
|
@ -66,6 +74,7 @@ export function ConnectionInfoModal({
|
|||
<Modal.Body>
|
||||
<ConnectionInfoContent
|
||||
initializeResult={initializeResult}
|
||||
serverInfoReported={serverInfoReported}
|
||||
clientCapabilities={clientCapabilities}
|
||||
transport={transport}
|
||||
protocolEra={protocolEra}
|
||||
|
|
|
|||
Loading…
Reference in a new issue