Merge pull request #1644 from modelcontextprotocol/1639-web-version-display

Web UI polish: footer (version/copyright), toolbar + monitoring column tweaks
This commit is contained in:
Cliff Hall 2026-07-10 23:11:12 -04:00 • committed by GitHub
commit 5a86bd11ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 747 additions and 71 deletions

View file

@ -14,8 +14,16 @@ import {
LEGACY_AUTH_TOKEN_ENV,
} from "../../../core/mcp/remote/constants.ts";
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
import { resolveSandboxPort } from "./sandbox-controller.js";
// The single-source Inspector version (root package.json), read once at load.
// The browser can't read the filesystem the way the CLI/TUI do, so the backend
// reads it here and hands it to the client via GET /api/config. Uses the
// non-throwing read: the version is cosmetic, so a resolution failure hides the
// badge (version omitted from the payload) rather than crashing the backend.
const inspectorVersion = readInspectorVersionSafe(import.meta.url);
export interface WebServerConfig {
port: number;
hostname: string;
@ -92,11 +100,18 @@ function defaultEnvironmentFromProcess(
}
/**
* Convert WebServerConfig.initialMcpConfig to the shape expected by GET /api/config.
* Convert WebServerConfig.initialMcpConfig to the shape expected by GET
* /api/config, tagging on the single-source Inspector `version` so the browser
* can display it.
*/
export function webServerConfigToInitialPayload(
config: WebServerConfig,
): InitialConfigPayload {
return { ...transportDefaults(config), version: inspectorVersion };
}
/** The transport-specific defaults half of the `/api/config` payload. */
function transportDefaults(config: WebServerConfig): InitialConfigPayload {
const mc = config.initialMcpConfig;
const defaultEnvironment = defaultEnvironmentFromProcess(
mc && "env" in mc && mc.env ? mc.env : undefined,

View file

@ -98,8 +98,11 @@ import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"
import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
import { useServerListWritable } from "@inspector/core/react/useServerListWritable.js";
import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion.js";
import { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
import { InspectorView } from "./components/views/InspectorView/InspectorView";
import { VersionBadge } from "./components/elements/VersionBadge/VersionBadge";
import { CopyrightBadge } from "./components/elements/CopyrightBadge/CopyrightBadge";
import type {
ToolCallState,
ToolsUiState,
@ -669,6 +672,12 @@ function App() {
baseUrl: configBaseUrl,
authToken: getAuthToken(),
});
// The Inspector version (root package.json), shown in the lower-right corner.
// The browser can't read it off disk, so the backend sends it via /api/config.
const { version: inspectorVersion } = useInspectorVersion({
baseUrl: configBaseUrl,
authToken: getAuthToken(),
});
const [clientConfig, setClientConfig] = useState<ClientConfig>({});
useEffect(() => {
@ -3816,6 +3825,8 @@ function App() {
onRefreshApps={onRefreshTools}
/>
</Box>
<CopyrightBadge />
<VersionBadge version={inspectorVersion} />
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}

View file

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { CopyrightBadge, COPYRIGHT_NOTICE } from "./CopyrightBadge";
const meta: Meta<typeof CopyrightBadge> = {
title: "Elements/CopyrightBadge",
component: CopyrightBadge,
};
export default meta;
type Story = StoryObj<typeof CopyrightBadge>;
// The grey copyright notice pinned to the lower-left corner of the viewport.
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText(COPYRIGHT_NOTICE)).toBeInTheDocument();
},
};

View file

@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CopyrightBadge, COPYRIGHT_NOTICE } from "./CopyrightBadge";
describe("CopyrightBadge", () => {
it("renders the project copyright notice", () => {
renderWithMantine(<CopyrightBadge />);
expect(screen.getByText(COPYRIGHT_NOTICE)).toBeInTheDocument();
});
it("names the Model Context Protocol and LF Projects", () => {
renderWithMantine(<CopyrightBadge />);
expect(
screen.getByText(/Model Context Protocol.*Series of LF Projects, LLC\./),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,18 @@
import { Text } from "@mantine/core";
/** The project's copyright notice. */
export const COPYRIGHT_NOTICE =
"Copyright © Model Context Protocol a Series of LF Projects, LLC.";
// The `copyrightBadge` variant (src/theme/Text.ts) pins it to the lower-right
// corner in grey, on the same row as the version badge, and non-interactive.
const CopyrightText = Text.withProps({ variant: "copyrightBadge" });
/**
* The project copyright notice, fixed to the lower-right corner of the screen
* (#1639) — the grey, non-interactive twin of the lower-left version badge,
* sharing its row.
*/
export function CopyrightBadge() {
return <CopyrightText>{COPYRIGHT_NOTICE}</CopyrightText>;
}

View file

@ -1,4 +1,4 @@
import { ActionIcon, Button } from "@mantine/core";
import { ActionIcon } from "@mantine/core";
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
export interface ListToggleProps {
@ -29,9 +29,16 @@ export function ListToggle({
);
}
// `size={36}` matches the header's theme / client-settings ActionIcons so the
// toolbar's toggle reads as the same size icon button.
return (
<Button size="sm" variant="subtle" aria-label={label} onClick={onToggle}>
<ActionIcon
variant="subtle"
size={36}
aria-label={label}
onClick={onToggle}
>
<Icon size={20} />
</Button>
</ActionIcon>
);
}

View file

@ -18,4 +18,14 @@ describe("PinColumnButton", () => {
await user.click(screen.getByRole("button", { name: "Pin as column" }));
expect(onPin).toHaveBeenCalledTimes(1);
});
it("uses a custom accessible label when provided", () => {
renderWithMantine(
<PinColumnButton onPin={vi.fn()} label="Open monitoring column" />,
);
expect(
screen.getByRole("button", { name: "Open monitoring column" }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
});
});

View file

@ -1,27 +1,29 @@
import { Button } from "@mantine/core";
import { ActionIcon } from "@mantine/core";
import { TbLayoutSidebarRightExpand } from "react-icons/tb";
export interface PinColumnButtonProps {
/** Pin this screen into the monitoring column to the right. */
onPin: () => void;
/** Accessible label. Defaults to "Pin as column" (its monitor-screen use). */
label?: string;
}
/**
* Toolbar button that pins the owning monitor screen (Logs / Protocol / Network)
* into the resizable column on the right of the InspectorView (#1616). Distinct
* from `PinToggle` (which pins individual history entries) — this one opens a
* side column, so it uses a right-sidebar glyph and an "as column" label. Styled
* to match the panel's expand/collapse `ListToggle` (subtle icon button).
* Toolbar button that opens the resizable monitoring column on the right of the
* InspectorView (#1616). On a monitor screen (Logs / Protocol / Network) it pins
* that screen in as a column; on the server list it just opens the column (a
* different `label`). Distinct from `PinToggle` (which pins individual history
* entries) — this one opens a side column, so it uses a right-sidebar glyph.
* `size={36}` matches the header's theme / client-settings ActionIcons and the
* toolbar's `ListToggle`, so all these icon buttons share one width.
*/
export function PinColumnButton({ onPin }: PinColumnButtonProps) {
export function PinColumnButton({
onPin,
label = "Pin as column",
}: PinColumnButtonProps) {
return (
<Button
size="sm"
variant="subtle"
aria-label="Pin as column"
onClick={onPin}
>
<ActionIcon variant="subtle" size={36} aria-label={label} onClick={onPin}>
<TbLayoutSidebarRightExpand size={20} />
</Button>
</ActionIcon>
);
}

View file

@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ScreenStage } from "./ScreenStage";
describe("ScreenStage", () => {
it("renders its screen when active", () => {
renderWithMantine(
<ScreenStage active>
<div>active screen</div>
</ScreenStage>,
);
expect(screen.getByText("active screen")).toBeInTheDocument();
});
it("renders nothing when inactive (outgoing screen unmounts)", () => {
renderWithMantine(
<ScreenStage active={false}>
<div>inactive screen</div>
</ScreenStage>,
);
expect(screen.queryByText("inactive screen")).toBeNull();
});
it("still renders its screen in the fill variant", () => {
renderWithMantine(
<ScreenStage active fill>
<div>filled screen</div>
</ScreenStage>,
);
expect(screen.getByText("filled screen")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import { Box, Transition } from "@mantine/core";
/** Screen enter / exit durations for the shared `fade-up` stage transition. */
export const SCREEN_ENTER_MS = 350;
export const SCREEN_EXIT_MS = 250;
export interface ScreenStageProps {
/** True when this stage's screen is the active one. */
active: boolean;
children: ReactNode;
/**
* Stretch the stage to fill its relative-positioned parent (adds `bottom: 0`).
* Needed where the screen relies on the parent for height (e.g. an inner
* ScrollArea in the monitoring column). Off by default so callers whose
* screens size themselves keep the top/left/right anchoring.
*/
fill?: boolean;
}
/**
* Wraps a screen in a Mantine `fade-up` Transition so that, on switch, the
* incoming screen slides up and fades in while the outgoing one fades down and
* out — both mounted at once via absolute positioning. With Transition's default
* (`keepMounted={false}`) the outgoing screen unmounts after its exit animation,
* resetting any local screen state (search filters, scroll, expanded sections).
*
* Shared by the primary InspectorView pane and the pinned monitoring column so
* both use identical enter/exit motion (#1639-follow-up). Must be rendered
* inside a `position: relative` container.
*/
export function ScreenStage({
active,
children,
fill = false,
}: ScreenStageProps) {
return (
<Transition
mounted={active}
transition="fade-up"
duration={SCREEN_ENTER_MS}
exitDuration={SCREEN_EXIT_MS}
timingFunction="ease"
>
{(styles) => (
// `style={styles}` is the runtime transition state from Mantine's
// Transition API — interpolated values, not static styling.
<Box
style={styles}
pos="absolute"
top={0}
left={0}
right={0}
bottom={fill ? 0 : undefined}
>
{children}
</Box>
)}
</Transition>
);
}

View file

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { VersionBadge } from "./VersionBadge";
const meta: Meta<typeof VersionBadge> = {
title: "Elements/VersionBadge",
component: VersionBadge,
args: { version: "2.0.0" },
};
export default meta;
type Story = StoryObj<typeof VersionBadge>;
// Default: a grey `v2.0.0` pinned to the lower-right corner of the viewport.
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const badge = await canvas.findByText("v2.0.0");
await expect(badge).toBeInTheDocument();
await expect(badge).toHaveAttribute(
"aria-label",
"Inspector version 2.0.0",
);
},
};
// No version yet (initial load / legacy backend): renders nothing.
export const Hidden: Story = {
args: { version: undefined },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByText(/^v/)).toBeNull();
},
};

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { VersionBadge } from "./VersionBadge";
describe("VersionBadge", () => {
it("renders the version with a `v` prefix and an accessible label", () => {
renderWithMantine(<VersionBadge version="2.0.0" />);
const badge = screen.getByText("v2.0.0");
expect(badge).toBeInTheDocument();
expect(badge).toHaveAttribute("aria-label", "Inspector version 2.0.0");
});
it("renders nothing when the version is undefined", () => {
renderWithMantine(<VersionBadge version={undefined} />);
expect(screen.queryByText(/^v/)).toBeNull();
});
it("renders nothing when the version is an empty string", () => {
renderWithMantine(<VersionBadge version="" />);
expect(screen.queryByText(/^v/)).toBeNull();
});
});

View file

@ -0,0 +1,25 @@
import { Text } from "@mantine/core";
export interface VersionBadgeProps {
/** The Inspector version to show, e.g. `"2.0.0"`. Renders nothing when absent. */
version?: string;
}
// The `versionBadge` variant (src/theme/Text.ts) pins it to the lower-left
// corner in grey and makes it non-interactive.
const VersionText = Text.withProps({ variant: "versionBadge" });
/**
* A small, unobtrusive build-version label fixed to the lower-left corner of
* the screen (#1639). Sourced from the root `package.json` via the backend's
* `GET /api/config`; renders nothing until the version is known (or on a legacy
* backend that omits it).
*/
export function VersionBadge({ version }: VersionBadgeProps) {
if (!version) return null;
return (
<VersionText aria-label={`Inspector version ${version}`}>
v{version}
</VersionText>
);
}

View file

@ -71,8 +71,7 @@ export function MonitoringControls({
/>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
size={36}
aria-label="Close monitoring column"
onClick={onClose}
>

View file

@ -1,11 +1,13 @@
import type { ReactNode } from "react";
import { Divider, Stack } from "@mantine/core";
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
export interface MonitoringScreenProps {
/** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */
tabs: string[];
/** Active monitor tab; keys into `screens` to pick what renders below. */
/** Active monitor tab; the matching screen is the mounted one below (the rest
* are cross-faded out via `ScreenStage`). */
value: string;
onChange: (tab: string) => void;
/** Search text for the active screen; wired by the caller to its filter state. */
@ -26,7 +28,10 @@ const ColumnLayout = Stack.withProps({
gap: 0,
});
// Relative-positioned host so each tab's `ScreenStage` (absolutely positioned)
// can cross-fade over it. `mih: 0` lets the inner ScrollArea bound its height.
const ScreenSlot = Stack.withProps({
pos: "relative",
flex: 1,
mih: 0,
gap: 0,
@ -34,8 +39,9 @@ const ScreenSlot = Stack.withProps({
/**
* The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
* over the currently-selected monitor screen. Layout-only — it renders whichever
* embedded screen node the caller supplies for the active tab.
* over the selected monitor screen. Layout-only — it wraps each supplied screen
* node in a `ScreenStage`, so switching tabs cross-fades (only the active tab's
* screen is mounted) the same way the primary pane does.
*/
export function MonitoringScreen({
tabs,
@ -57,7 +63,13 @@ export function MonitoringScreen({
onClose={onClose}
/>
<Divider />
<ScreenSlot>{screens[value]}</ScreenSlot>
<ScreenSlot>
{tabs.map((tab) => (
<ScreenStage key={tab} active={tab === value} fill>
{screens[tab]}
</ScreenStage>
))}
</ScreenSlot>
</ColumnLayout>
);
}

View file

@ -24,6 +24,31 @@ describe("ServerListControls", () => {
).toBeInTheDocument();
});
it("hides the open-monitor button unless onOpenMonitor is provided", () => {
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
expect(
screen.queryByRole("button", { name: "Open monitoring column" }),
).toBeNull();
});
it("shows and wires the open-monitor button when onOpenMonitor is provided", async () => {
const user = userEvent.setup();
const onOpenMonitor = vi.fn();
renderWithMantine(
<ServerListControls
{...baseProps}
serverCount={2}
onOpenMonitor={onOpenMonitor}
/>,
);
const openBtn = screen.getByRole("button", {
name: "Open monitoring column",
});
expect(openBtn).toBeInTheDocument();
await user.click(openBtn);
expect(onOpenMonitor).toHaveBeenCalledTimes(1);
});
it("shows the list toggle alongside Export + Add Servers when servers exist", () => {
renderWithMantine(<ServerListControls {...baseProps} serverCount={2} />);
expect(screen.getAllByRole("button")).toHaveLength(3);
@ -39,8 +64,9 @@ describe("ServerListControls", () => {
onToggleList={onToggleList}
/>,
);
const buttons = screen.getAllByRole("button");
await user.click(buttons[0]);
await user.click(
screen.getByRole("button", { name: /Expand all|Collapse all/ }),
);
expect(onToggleList).toHaveBeenCalledTimes(1);
});

View file

@ -1,5 +1,6 @@
import { Button, Group } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
import {
ServerAddMenu,
type AddServerMenuProps,
@ -13,6 +14,12 @@ export interface ServerListControlsProps extends AddServerMenuProps {
onExport: () => void;
/** When false (read-only session), the Add menu is hidden. Defaults to true. */
writable?: boolean;
/**
* Open the monitoring column. Provided (by the caller) only when a server is
* connected, the column can open, and it isn't already shown — otherwise
* omitted so the open-sidebar affordance is hidden.
*/
onOpenMonitor?: () => void;
}
export function ServerListControls({
@ -24,12 +31,12 @@ export function ServerListControls({
onImportServerJson,
onExport,
writable = true,
onOpenMonitor,
}: ServerListControlsProps) {
return (
<Group justify="flex-end">
{serverCount > 0 && (
<ListToggle compact={compact} onToggle={onToggleList} />
)}
// `gap="sm"` matches the header's control spacing (its RightSection group),
// so these buttons sit the same distance apart as the header icons.
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onExport} disabled={serverCount === 0}>
Export
</Button>
@ -40,6 +47,12 @@ export function ServerListControls({
onImportServerJson={onImportServerJson}
/>
)}
{serverCount > 0 && (
<ListToggle compact={compact} onToggle={onToggleList} />
)}
{onOpenMonitor && (
<PinColumnButton onPin={onOpenMonitor} label="Open monitoring column" />
)}
</Group>
);
}

View file

@ -169,13 +169,12 @@ describe("TaskListPanel", () => {
screen.getAllByRole("button", { name: "Collapse" }).length,
).toBeGreaterThan(0);
const buttons = screen.getAllByRole("button");
const toggle = buttons.find(
(b) =>
b.textContent === "" && b.classList.contains("mantine-Button-root"),
);
expect(toggle).toBeDefined();
await user.click(toggle!);
// The ListToggle is labelled "Collapse all" / "Expand all" — distinct from
// the per-entry "Collapse" / "Expand" buttons.
const toggle = screen.getByRole("button", {
name: /Collapse all|Expand all/,
});
await user.click(toggle);
// After toggle, entries collapsed — they show Expand
expect(

View file

@ -67,6 +67,12 @@ export interface ServerListScreenProps {
onClearHighlight?: (id: string) => void;
compact: boolean;
onToggleCompact: () => void;
/**
* Open the monitoring column. Provided only when a server is connected, the
* column can open, and it isn't already shown; forwarded to the controls to
* reveal an open-sidebar button beside the list toggle.
*/
onOpenMonitor?: () => void;
}
const PageContainer = Stack.withProps({
@ -99,6 +105,7 @@ export function ServerListScreen({
onClearHighlight,
compact,
onToggleCompact,
onOpenMonitor,
}: ServerListScreenProps) {
const sensors = useSensors(
useSensor(PointerSensor),
@ -196,6 +203,7 @@ export function ServerListScreen({
onImportConfig={onImportConfig}
onImportServerJson={onImportServerJson}
onExport={onExport}
onOpenMonitor={onOpenMonitor}
/>
<ScrollArea.Autosize

View file

@ -8,7 +8,6 @@ import {
} from "react";
import {
AppShell,
Box,
Flex,
Group,
Stack,
@ -84,6 +83,7 @@ import {
type ConsoleUiState,
} from "../../screens/ConsoleScreen/ConsoleScreen";
import type { SortDirection } from "../../elements/SortToggle/SortToggle";
import { ScreenStage } from "../../elements/ScreenStage/ScreenStage";
import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen";
import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle";
import { getServerType } from "@inspector/core/mcp/config.js";
@ -241,9 +241,6 @@ function serializeMonitorWidth(value: number): string {
return String(value);
}
const SCREEN_ENTER_MS = 350;
const SCREEN_EXIT_MS = 250;
// Relative-positioned wrapper for the absolutely-positioned `ScreenStage`
// children. `mih: "100%"` requires `AppShell.Main` to provide a definite
// height for the stack itself to fill — the absolute children render
@ -306,36 +303,6 @@ const MonitorColumnGroup = Group.withProps({
flex: "0 0 auto",
});
// Wraps each screen in a Mantine Transition. With Transition's default
// (`keepMounted={false}`), the outgoing screen unmounts after its exit
// animation — any local screen state (search filters, scroll position,
// expanded sections) is reset on tab switch.
function ScreenStage({
active,
children,
}: {
active: boolean;
children: ReactNode;
}) {
return (
<Transition
mounted={active}
transition="fade-up"
duration={SCREEN_ENTER_MS}
exitDuration={SCREEN_EXIT_MS}
timingFunction="ease"
>
{(styles) => (
// `style={styles}` is the runtime transition state from Mantine's
// Transition API — these are interpolated values, not static styling.
<Box style={styles} pos="absolute" top={0} left={0} right={0}>
{children}
</Box>
)}
</Transition>
);
}
export interface InspectorViewProps {
// Server list (static config; runtime connection state comes from the
// separate fields below and is merged into each card by this component).
@ -883,6 +850,10 @@ export function InspectorView({
// column. Gated by `canPin` so the pin affordance only appears when a click
// would actually take effect (wide + connected).
const canPin = !!isWide && connected;
// Offer an "open monitoring column" affordance on the server list when a
// connected server has monitor screens to show but the column isn't open yet.
const canOpenMonitor =
canPin && monitorAvailable.length > 0 && !effectivePinned;
function pinMonitor(tab: MonitorTab) {
setMonitorTab(tab);
setMonitorPinned(true);
@ -1101,6 +1072,11 @@ export function InspectorView({
onClearHighlight={onClearHighlight}
compact={serversCompact}
onToggleCompact={() => setServersCompact((c) => !c)}
onOpenMonitor={
canOpenMonitor
? () => pinMonitor(effectiveMonitorTab)
: undefined
}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Tools"}>

Some files were not shown because too many files have changed in this diff Show more