Skip to content
17 changes: 16 additions & 1 deletion clients/web/server/web-server-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -3816,6 +3825,8 @@ function App() {
onRefreshApps={onRefreshTools}
/>
</Box>
<CopyrightBadge />
<VersionBadge version={inspectorVersion} />
<ServerConfigModal
opened={configModal !== null}
mode={configModal?.mode ?? "add"}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
};
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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>;
}
13 changes: 10 additions & 3 deletions clients/web/src/components/elements/ListToggle/ListToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
61 changes: 61 additions & 0 deletions clients/web/src/components/elements/ScreenStage/ScreenStage.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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();
},
};
Original file line number Diff line number Diff line change
@@ -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();
});
});
25 changes: 25 additions & 0 deletions clients/web/src/components/elements/VersionBadge/VersionBadge.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading
Loading