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
8 changed files with 288 additions and 21 deletions
|
|
@ -220,8 +220,10 @@ vi.mock("@inspector/core/react/useInspectorClient.js", () => ({
|
||||||
status: "connected",
|
status: "connected",
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
clientCapabilities: {},
|
clientCapabilities: {},
|
||||||
// Left undefined so `initializeResult` stays undefined and the
|
// Undefined models a modern server that omitted the optional serverInfo. As
|
||||||
// ConnectionInfoModal (gated on it) never mounts during the test.
|
// of #1772 `initializeResult` is still built when connected (with a
|
||||||
|
// catalog-name fallback), so this exercises that path — see the "#1772"
|
||||||
|
// describe block.
|
||||||
serverInfo: undefined,
|
serverInfo: undefined,
|
||||||
instructions: undefined,
|
instructions: undefined,
|
||||||
})),
|
})),
|
||||||
|
|
@ -358,7 +360,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
currentLogLevel?: string;
|
currentLogLevel?: string;
|
||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
erroredServerId?: string;
|
erroredServerId?: string;
|
||||||
|
initializeResult?: { serverInfo: { name: string; version: string } };
|
||||||
onActiveTabChange: (tab: string) => void;
|
onActiveTabChange: (tab: string) => void;
|
||||||
|
onConnectionInfo: () => void;
|
||||||
onToggleConnection: (id: string) => void;
|
onToggleConnection: (id: string) => void;
|
||||||
onToolsUiChange: (next: {
|
onToolsUiChange: (next: {
|
||||||
selectedToolName?: string;
|
selectedToolName?: string;
|
||||||
|
|
@ -425,6 +429,11 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
</span>
|
</span>
|
||||||
<span data-testid="log-level">{props.currentLogLevel}</span>
|
<span data-testid="log-level">{props.currentLogLevel}</span>
|
||||||
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
|
<span data-testid="active-tab">{props.activeTab ?? "none"}</span>
|
||||||
|
<span data-testid="init-result">
|
||||||
|
{props.initializeResult
|
||||||
|
? `name:${props.initializeResult.serverInfo.name || "(empty)"}`
|
||||||
|
: "none"}
|
||||||
|
</span>
|
||||||
<span data-testid="errored-server">
|
<span data-testid="errored-server">
|
||||||
{props.erroredServerId ?? "none"}
|
{props.erroredServerId ?? "none"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -432,6 +441,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
switch-servers-tab
|
switch-servers-tab
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => props.onToggleConnection("A")}>connect</button>
|
<button onClick={() => props.onToggleConnection("A")}>connect</button>
|
||||||
|
<button onClick={() => props.onConnectionInfo()}>
|
||||||
|
open-connection-info
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
props.onToolsUiChange({
|
props.onToolsUiChange({
|
||||||
|
|
@ -537,6 +549,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { SERVER_INFO_NOT_REPORTED_LABEL } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||||
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
|
import { OAUTH_CALLBACK_PATH } from "./utils/oauthFlow.js";
|
||||||
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
|
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -625,6 +638,94 @@ describe("App failed-connection card border (#1621)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("App initializeResult when connected without serverInfo (#1772)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clientInstances.length = 0;
|
||||||
|
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A modern-era `server/discover` makes `serverInfo` optional, so a conforming
|
||||||
|
// modern server can be `connected` with `serverInfo === undefined`. The header
|
||||||
|
// (and its whole tab bar) is gated on `initializeResult` downstream, so it must
|
||||||
|
// still be built in that case — otherwise the connected server shows no menu.
|
||||||
|
it("builds initializeResult when connected even though serverInfo is undefined", () => {
|
||||||
|
// DEFAULT_USE_INSPECTOR_CLIENT is exactly this case: connected + no serverInfo.
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
expect(screen.getByTestId("init-result")).not.toHaveTextContent("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the active server's catalog name when serverInfo is undefined", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
// Connect server "A" (PlotRocket) via the mocked InspectorView control so it
|
||||||
|
// becomes the active server; serverInfo is still undefined (default mock), so
|
||||||
|
// the synthesized name must come from the catalog entry.
|
||||||
|
await user.click(screen.getByText("connect"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("init-result")).toHaveTextContent(
|
||||||
|
"name:PlotRocket",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not build initializeResult while disconnected", () => {
|
||||||
|
vi.mocked(useInspectorClient).mockReturnValue({
|
||||||
|
...DEFAULT_USE_INSPECTOR_CLIENT,
|
||||||
|
status: "disconnected",
|
||||||
|
});
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
expect(screen.getByTestId("init-result")).toHaveTextContent("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the reported serverInfo name when present (legacy / stamped modern)", () => {
|
||||||
|
vi.mocked(useInspectorClient).mockReturnValue({
|
||||||
|
...DEFAULT_USE_INSPECTOR_CLIENT,
|
||||||
|
serverInfo: { name: "real-server", version: "2.0.0" },
|
||||||
|
});
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
expect(screen.getByTestId("init-result")).toHaveTextContent(
|
||||||
|
"name:real-server",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pins the App → ConnectionInfoModal wiring of `serverInfoReported`: without
|
||||||
|
// this, hard-coding it to `true` would keep the suite green and silently
|
||||||
|
// reintroduce the fidelity bug.
|
||||||
|
it("passes serverInfoReported=false to the modal, which shows 'not reported' (serverInfo omitted)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
await user.click(screen.getByText("connect")); // active server = A
|
||||||
|
await user.click(screen.getByText("open-connection-info"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength(
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The synthesized catalog name is not presented as the server's report.
|
||||||
|
// Exact-match query on purpose: the mocked InspectorView's `init-result` span
|
||||||
|
// prints "name:PlotRocket" (prefixed), so this only matches a bare "PlotRocket"
|
||||||
|
// — i.e. the modal's Name cell — not the harness span.
|
||||||
|
expect(screen.queryByText("PlotRocket")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes serverInfoReported=true to the modal, which shows the reported name", async () => {
|
||||||
|
vi.mocked(useInspectorClient).mockReturnValue({
|
||||||
|
...DEFAULT_USE_INSPECTOR_CLIENT,
|
||||||
|
serverInfo: { name: "real-server", version: "2.0.0" },
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
await user.click(screen.getByText("connect"));
|
||||||
|
await user.click(screen.getByText("open-connection-info"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("real-server")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(SERVER_INFO_NOT_REPORTED_LABEL),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("App session-scoped state reset on disconnect", () => {
|
describe("App session-scoped state reset on disconnect", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clientInstances.length = 0;
|
clientInstances.length = 0;
|
||||||
|
|
|
||||||
|
|
@ -1473,21 +1473,47 @@ function App() {
|
||||||
};
|
};
|
||||||
}, [inspectorClient]);
|
}, [inspectorClient]);
|
||||||
|
|
||||||
|
// The Server Info modal needs the active server's transport and (optional)
|
||||||
|
// OAuth details — both are co-located here so the modal opens against the
|
||||||
|
// same connection snapshot the header is reading. Also feeds the
|
||||||
|
// `initializeResult` serverInfo fallback below.
|
||||||
|
const activeServer = useMemo<ServerEntry | undefined>(
|
||||||
|
() => servers.find((s) => s.id === activeServerId),
|
||||||
|
[servers, activeServerId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Whether the server actually reported `serverInfo`, vs. the catalog-name
|
||||||
|
// fallback synthesized below. Threaded to the Connection Info modal so it can
|
||||||
|
// show "not reported" instead of an inferred name that looks server-sent.
|
||||||
|
const serverInfoReported = serverInfo !== undefined;
|
||||||
|
|
||||||
// Build the InitializeResult the connected ViewHeader / Connection Info
|
// Build the InitializeResult the connected ViewHeader / Connection Info
|
||||||
// modal expect from the hook's split fields. `protocolVersion` is the value
|
// modal expect from the hook's split fields. `protocolVersion` is the value
|
||||||
// the InspectorClient negotiated during initialize (#1324); it's dispatched
|
// the InspectorClient negotiated during initialize (#1324); it's dispatched
|
||||||
// alongside serverInfo, so in practice it's present whenever we're connected.
|
// alongside serverInfo, so in practice it's present whenever we're connected.
|
||||||
// We deliberately gate only on serverInfo (not protocolVersion): this object
|
// We gate only on `connectionStatus`, never on serverInfo or protocolVersion:
|
||||||
// also drives the connected header and Connection Info modal, so a
|
// this object also drives the connected header (and its whole tab bar) and the
|
||||||
// missing/edge-case version must not hide those. It flows through as the
|
// Connection Info modal, so a missing field must not hide those.
|
||||||
// optional field it is everywhere downstream (the ServerCard label and the
|
//
|
||||||
// modal value both tolerate an empty string), so "" reads as "unknown".
|
// A modern-era server's `server/discover` makes `serverInfo` OPTIONAL (SHOULD,
|
||||||
|
// not MUST — it's stamped in `_meta["io.modelcontextprotocol/serverInfo"]`), so
|
||||||
|
// a conforming modern server may omit it and `serverInfo` stays undefined even
|
||||||
|
// while connected. Falling back to the catalog name (rather than returning
|
||||||
|
// `undefined`) keeps the header + tabs rendered for those servers (#1772). It
|
||||||
|
// fires for such a modern server — and, harmlessly, in the batched instant on
|
||||||
|
// any connect (legacy included) between the `connected` status dispatch and the
|
||||||
|
// `serverInfo` dispatch, which land in a single React render. The modal uses
|
||||||
|
// `serverInfoReported` (above), not this name, to stay faithful.
|
||||||
const initializeResult = useMemo<InitializeResult | undefined>(() => {
|
const initializeResult = useMemo<InitializeResult | undefined>(() => {
|
||||||
if (connectionStatus !== "connected" || !serverInfo) return undefined;
|
if (connectionStatus !== "connected") return undefined;
|
||||||
|
const resolvedServerInfo = serverInfo ?? {
|
||||||
|
name: activeServer?.name ?? "",
|
||||||
|
version: "",
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
protocolVersion: protocolVersion ?? "",
|
protocolVersion: protocolVersion ?? "",
|
||||||
capabilities: capabilities ?? {},
|
capabilities: capabilities ?? {},
|
||||||
serverInfo,
|
serverInfo: resolvedServerInfo,
|
||||||
...(instructions ? { instructions } : {}),
|
...(instructions ? { instructions } : {}),
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
|
@ -1496,16 +1522,9 @@ function App() {
|
||||||
serverInfo,
|
serverInfo,
|
||||||
instructions,
|
instructions,
|
||||||
protocolVersion,
|
protocolVersion,
|
||||||
|
activeServer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// The Server Info modal needs the active server's transport and (optional)
|
|
||||||
// OAuth details — both are co-located here so the modal opens against the
|
|
||||||
// same connection snapshot the header is reading.
|
|
||||||
const activeServer = useMemo<ServerEntry | undefined>(
|
|
||||||
() => servers.find((s) => s.id === activeServerId),
|
|
||||||
[servers, activeServerId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mirror the active server's name into a ref so a mid-session failure toast
|
// Mirror the active server's name into a ref so a mid-session failure toast
|
||||||
// can still name the server: a transport crash dispatches `disconnect`,
|
// can still name the server: a transport crash dispatches `disconnect`,
|
||||||
// which clears `activeServerId` (and thus `activeServer`) before the
|
// which clears `activeServerId` (and thus `activeServer`) before the
|
||||||
|
|
@ -4448,6 +4467,7 @@ function App() {
|
||||||
opened={connectionInfoModalOpen}
|
opened={connectionInfoModalOpen}
|
||||||
onClose={() => setConnectionInfoModalOpen(false)}
|
onClose={() => setConnectionInfoModalOpen(false)}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported={serverInfoReported}
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport={connectionInfoTransport}
|
transport={connectionInfoTransport}
|
||||||
protocolEra={protocolEra}
|
protocolEra={protocolEra}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import type {
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
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 = {
|
const fullResult: InitializeResult = {
|
||||||
protocolVersion: "2025-03-26",
|
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 = {
|
export const MinimalCapabilities: Story = {
|
||||||
args: {
|
args: {
|
||||||
initializeResult: {
|
initializeResult: {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
import {
|
import {
|
||||||
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||||
ConnectionInfoContent,
|
ConnectionInfoContent,
|
||||||
|
SERVER_INFO_NOT_REPORTED_LABEL,
|
||||||
} from "./ConnectionInfoContent";
|
} from "./ConnectionInfoContent";
|
||||||
|
|
||||||
const fullResult: InitializeResult = {
|
const fullResult: InitializeResult = {
|
||||||
|
|
@ -62,6 +63,72 @@ describe("ConnectionInfoContent", () => {
|
||||||
expect(screen.getAllByText("—")).toHaveLength(3);
|
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", () => {
|
it("renders server and client capability sections", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<ConnectionInfoContent
|
<ConnectionInfoContent
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,16 @@ export interface OAuthDetails {
|
||||||
|
|
||||||
export interface ConnectionInfoContentProps {
|
export interface ConnectionInfoContentProps {
|
||||||
initializeResult: InitializeResult;
|
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;
|
clientCapabilities: ClientCapabilities;
|
||||||
transport: ServerType;
|
transport: ServerType;
|
||||||
/**
|
/**
|
||||||
|
|
@ -63,6 +73,12 @@ const ValueText = Text.withProps({
|
||||||
fw: 600,
|
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({
|
const SectionHeading = Title.withProps({
|
||||||
// `order: 3` (not 5) keeps the heading level one below the modal's `h2`
|
// `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`);
|
// `Modal.Title`, so the outline doesn't skip a level (axe `heading-order`);
|
||||||
|
|
@ -178,6 +194,7 @@ function getCapabilityEntries(
|
||||||
|
|
||||||
export function ConnectionInfoContent({
|
export function ConnectionInfoContent({
|
||||||
initializeResult,
|
initializeResult,
|
||||||
|
serverInfoReported = true,
|
||||||
clientCapabilities,
|
clientCapabilities,
|
||||||
transport,
|
transport,
|
||||||
protocolEra,
|
protocolEra,
|
||||||
|
|
@ -188,6 +205,17 @@ export function ConnectionInfoContent({
|
||||||
const { serverInfo, protocolVersion, capabilities, instructions } =
|
const { serverInfo, protocolVersion, capabilities, instructions } =
|
||||||
initializeResult;
|
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 serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS);
|
||||||
const clientCaps = getCapabilityEntries(
|
const clientCaps = getCapabilityEntries(
|
||||||
clientCapabilities,
|
clientCapabilities,
|
||||||
|
|
@ -200,13 +228,13 @@ export function ConnectionInfoContent({
|
||||||
<SectionHeading>Server Implementation</SectionHeading>
|
<SectionHeading>Server Implementation</SectionHeading>
|
||||||
<SimpleGrid cols={2}>
|
<SimpleGrid cols={2}>
|
||||||
<Text size="sm">Name</Text>
|
<Text size="sm">Name</Text>
|
||||||
<ValueText>{serverInfo.name}</ValueText>
|
<ValueText>{displayName}</ValueText>
|
||||||
|
|
||||||
<Text size="sm">Version</Text>
|
<Text size="sm">Version</Text>
|
||||||
<ValueText>{serverInfo.version ?? "—"}</ValueText>
|
<ValueText>{displayVersion}</ValueText>
|
||||||
|
|
||||||
<Text size="sm">Protocol</Text>
|
<Text size="sm">Protocol</Text>
|
||||||
<ValueText>{protocolVersion}</ValueText>
|
<ValueText>{protocolVersion || "—"}</ValueText>
|
||||||
|
|
||||||
<Text size="sm">Transport</Text>
|
<Text size="sm">Transport</Text>
|
||||||
<Badge variant="outline">{transport}</Badge>
|
<Badge variant="outline">{transport}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ const meta: Meta<typeof ConnectionInfoModal> = {
|
||||||
args: {
|
args: {
|
||||||
opened: true,
|
opened: true,
|
||||||
onClose: fn(),
|
onClose: fn(),
|
||||||
|
serverInfoReported: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ describe("ConnectionInfoModal", () => {
|
||||||
opened={false}
|
opened={false}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport="stdio"
|
transport="stdio"
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -42,6 +43,7 @@ describe("ConnectionInfoModal", () => {
|
||||||
opened
|
opened
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport="stdio"
|
transport="stdio"
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -60,6 +62,7 @@ describe("ConnectionInfoModal", () => {
|
||||||
opened
|
opened
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport="streamable-http"
|
transport="streamable-http"
|
||||||
oauth={{
|
oauth={{
|
||||||
|
|
@ -87,6 +90,7 @@ describe("ConnectionInfoModal", () => {
|
||||||
opened
|
opened
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport="stdio"
|
transport="stdio"
|
||||||
/>,
|
/>,
|
||||||
|
|
@ -103,6 +107,7 @@ describe("ConnectionInfoModal", () => {
|
||||||
opened
|
opened
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport="stdio"
|
transport="stdio"
|
||||||
/>,
|
/>,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,13 @@ export interface ConnectionInfoModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
initializeResult: InitializeResult;
|
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;
|
clientCapabilities: ClientCapabilities;
|
||||||
transport: ServerType;
|
transport: ServerType;
|
||||||
protocolEra?: ProtocolEra;
|
protocolEra?: ProtocolEra;
|
||||||
|
|
@ -39,6 +46,7 @@ export function ConnectionInfoModal({
|
||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
initializeResult,
|
initializeResult,
|
||||||
|
serverInfoReported,
|
||||||
clientCapabilities,
|
clientCapabilities,
|
||||||
transport,
|
transport,
|
||||||
protocolEra,
|
protocolEra,
|
||||||
|
|
@ -66,6 +74,7 @@ export function ConnectionInfoModal({
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<ConnectionInfoContent
|
<ConnectionInfoContent
|
||||||
initializeResult={initializeResult}
|
initializeResult={initializeResult}
|
||||||
|
serverInfoReported={serverInfoReported}
|
||||||
clientCapabilities={clientCapabilities}
|
clientCapabilities={clientCapabilities}
|
||||||
transport={transport}
|
transport={transport}
|
||||||
protocolEra={protocolEra}
|
protocolEra={protocolEra}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue