From 650e5bd020b268019cfd377a5c8ac92b44e54017 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 10 Jul 2026 19:07:31 -0400 Subject: [PATCH] feat(web): show the Inspector version in the lower-right corner (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display the single-source version (root package.json) in the web UI's lower-right corner, in grey — matching the CLI and TUI. The browser can't read the filesystem, so the version flows backend → browser via GET /api/config: - Add `version?: string` to InitialConfigPayload; the web backend (webServerConfigToInitialPayload) reads it once via the shared readInspectorVersion() and tags it onto the payload. - Add a `useInspectorVersion` hook mirroring useSandboxUrl/useServerListWritable. - Add a presentational `VersionBadge` element rendered fixed lower-right, grey, non-interactive; styling lives in a `versionBadge` Text theme variant (no inline styles, --inspector-* tokens). - Wire it into App.tsx. Verified live: /api/config serves `version: "2.0.0"` and the badge renders `v2.0.0` fixed at the lower-right in grey. Tests: useInspectorVersion (full branch coverage), VersionBadge (unit + Storybook), web-server-config payload. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw --- clients/web/server/web-server-config.ts | 15 +- clients/web/src/App.tsx | 9 + .../VersionBadge/VersionBadge.stories.tsx | 34 ++++ .../VersionBadge/VersionBadge.test.tsx | 22 +++ .../elements/VersionBadge/VersionBadge.tsx | 25 +++ .../core/react/useInspectorVersion.test.tsx | 181 ++++++++++++++++++ .../server/web-server-config.test.ts | 6 + clients/web/src/theme/Text.ts | 17 ++ core/mcp/remote/node/server.ts | 6 + core/react/useInspectorVersion.ts | 83 ++++++++ 10 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/components/elements/VersionBadge/VersionBadge.stories.tsx create mode 100644 clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx create mode 100644 clients/web/src/components/elements/VersionBadge/VersionBadge.tsx create mode 100644 clients/web/src/test/core/react/useInspectorVersion.test.tsx create mode 100644 core/react/useInspectorVersion.ts diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 3dc833ac..bc34588e 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -14,8 +14,14 @@ import { LEGACY_AUTH_TOKEN_ENV, } from "../../../core/mcp/remote/constants.ts"; import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts"; +import { readInspectorVersion } 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. +const inspectorVersion = readInspectorVersion(import.meta.url); + export interface WebServerConfig { port: number; hostname: string; @@ -92,11 +98,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, diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index c5fc9935..5e005100 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -98,8 +98,10 @@ 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 type { ToolCallState, ToolsUiState, @@ -669,6 +671,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({}); useEffect(() => { @@ -3816,6 +3824,7 @@ function App() { onRefreshApps={onRefreshTools} /> + = { + title: "Elements/VersionBadge", + component: VersionBadge, + args: { version: "2.0.0" }, +}; + +export default meta; +type Story = StoryObj; + +// 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(); + }, +}; diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx new file mode 100644 index 00000000..3382d34c --- /dev/null +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.test.tsx @@ -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(); + 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(); + expect(screen.queryByText(/^v/)).toBeNull(); + }); + + it("renders nothing when the version is an empty string", () => { + renderWithMantine(); + expect(screen.queryByText(/^v/)).toBeNull(); + }); +}); diff --git a/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx new file mode 100644 index 00000000..db7468f5 --- /dev/null +++ b/clients/web/src/components/elements/VersionBadge/VersionBadge.tsx @@ -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-right +// corner in grey and makes it non-interactive. +const VersionText = Text.withProps({ variant: "versionBadge" }); + +/** + * A small, unobtrusive build-version label fixed to the lower-right 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 ( + + v{version} + + ); +} diff --git a/clients/web/src/test/core/react/useInspectorVersion.test.tsx b/clients/web/src/test/core/react/useInspectorVersion.test.tsx new file mode 100644 index 00000000..4d1f8492 --- /dev/null +++ b/clients/web/src/test/core/react/useInspectorVersion.test.tsx @@ -0,0 +1,181 @@ +/** + * Tests for useInspectorVersion — runs in happy-dom under the unit project. Uses + * a controlled fake `fetch` so each branch (present / absent / non-string / HTTP + * error / network throw / post-unmount guards) and the auth header are asserted + * directly. Mirrors useSandboxUrl's test (same `/api/config` fetch shape). + */ + +import { describe, it, expect, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useInspectorVersion } from "@inspector/core/react/useInspectorVersion"; + +function jsonResponse(body: unknown, ok = true): Response { + return { + ok, + status: ok ? 200 : 401, + json: async () => body, + } as unknown as Response; +} + +describe("useInspectorVersion", () => { + it("starts loading, then resolves the version from the config payload", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ version: "2.0.0" })); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + expect(result.current.loading).toBe(true); + expect(result.current.version).toBeUndefined(); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBe("2.0.0"); + }); + + it("sends the bearer auth header when a token is provided", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); + + renderHook(() => + useInspectorVersion({ + baseUrl: "http://test.local/", + authToken: "secret-token", + fetchFn, + }), + ); + + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + const [url, init] = fetchFn.mock.calls[0]; + // Trailing slash on baseUrl is normalized away. + expect(url).toBe("http://test.local/api/config"); + expect(init.method).toBe("GET"); + expect(init.headers["x-mcp-remote-auth"]).toBe("Bearer secret-token"); + }); + + it("omits the auth header when no token is provided", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); + + renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + const [, init] = fetchFn.mock.calls[0]; + expect(init.headers["x-mcp-remote-auth"]).toBeUndefined(); + }); + + it("leaves version undefined when the payload omits it", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ defaultEnvironment: {} })); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("leaves version undefined when the field is not a usable string", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({ version: "" })); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("leaves version undefined on a non-ok response", async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ version: "9.9.9" }, false)); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("leaves version undefined when the fetch throws", async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error("network down")); + + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.version).toBeUndefined(); + }); + + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { + const globalFetch = vi + .fn() + .mockResolvedValue(jsonResponse({ version: "3.1.4" })); + const original = globalThis.fetch; + globalThis.fetch = globalFetch as unknown as typeof fetch; + try { + const { result } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local" }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(globalFetch).toHaveBeenCalledWith( + "http://test.local/api/config", + expect.objectContaining({ method: "GET" }), + ); + expect(result.current.version).toBe("3.1.4"); + } finally { + globalThis.fetch = original; + } + }); + + it("drops a response that resolves after unmount (no state update)", async () => { + let resolveFetch: ((r: Response) => void) | undefined; + const fetchFn = vi.fn().mockReturnValue( + new Promise((r) => { + resolveFetch = r; + }), + ); + + const { result, unmount } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + expect(result.current.loading).toBe(true); + + unmount(); + resolveFetch?.(jsonResponse({ version: "2.0.0" })); + await Promise.resolve(); + await Promise.resolve(); + expect(result.current.version).toBeUndefined(); + }); + + it("drops a response whose json resolves after unmount", async () => { + let resolveJson: ((v: unknown) => void) | undefined; + const res = { + ok: true, + status: 200, + json: () => + new Promise((r) => { + resolveJson = r; + }), + } as unknown as Response; + const fetchFn = vi.fn().mockResolvedValue(res); + + const { result, unmount } = renderHook(() => + useInspectorVersion({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(fetchFn).toHaveBeenCalled()); + await Promise.resolve(); + + unmount(); + resolveJson?.({ version: "2.0.0" }); + await Promise.resolve(); + await Promise.resolve(); + expect(result.current.version).toBeUndefined(); + }); +}); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index a9d7839b..2b407118 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -260,6 +260,12 @@ describe("webServerConfigToInitialPayload", () => { expect(payload.defaultTransport).toBeUndefined(); }); + it("tags the payload with the single-source Inspector version", () => { + // Read from the root package.json via readInspectorVersion() at module load. + const payload = webServerConfigToInitialPayload(baseConfig()); + expect(payload.version).toMatch(/^\d+\.\d+\.\d+/); + }); + it("emits stdio defaults when initialMcpConfig.type === 'stdio'", () => { const cfg = baseConfig(); cfg.initialMcpConfig = { diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index 2ae8af76..1a635108 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -37,6 +37,23 @@ export const ThemeText = Text.extend({ }, }; } + // A small, unobtrusive build-version label pinned to the lower-right corner + // of the screen (#1639): grey, non-interactive (clicks pass through), and + // out of the tab/selection flow so it never interferes with the UI beneath. + if (props.variant === "versionBadge") { + return { + root: { + position: "fixed", + bottom: "0.35rem", + right: "0.6rem", + zIndex: 100, + color: "var(--inspector-text-secondary)", + fontSize: "var(--mantine-font-size-xs)", + pointerEvents: "none", + userSelect: "none", + }, + }; + } return { root: {} }; }, }); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 77804a09..bbfaf198 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -91,6 +91,12 @@ export interface InitialConfigPayload { * shows catalog CRUD affordances. */ writable?: boolean; + /** + * The Inspector version (from the root `package.json`), so the browser — which + * can't read the filesystem the way the CLI/TUI do — can display it. Absent on + * a legacy backend that predates the field; the UI just shows nothing then. + */ + version?: string; } export interface RemoteServerOptions { diff --git a/core/react/useInspectorVersion.ts b/core/react/useInspectorVersion.ts new file mode 100644 index 00000000..81300bfc --- /dev/null +++ b/core/react/useInspectorVersion.ts @@ -0,0 +1,83 @@ +/** + * React hook over the `GET /api/config` endpoint that recovers the Inspector + * `version` the backend reads from the root `package.json`. The browser can't + * read the filesystem the way the CLI/TUI do, so the version is delivered on the + * config payload (see `webServerConfigToInitialPayload`) and surfaced here. + * + * Mirrors {@link useSandboxUrl} / {@link useServerListWritable} (same + * `/api/config` source, same fetch shape). `version` is `undefined` until the + * fetch resolves, and stays `undefined` if the backend omits it (a legacy + * backend that predates the field) — the UI simply renders nothing then. + */ + +import { useCallback, useEffect, useState } from "react"; + +export interface UseInspectorVersionOptions { + /** Base URL of the remote server (typically `window.location.origin`). */ + baseUrl: string; + /** Optional auth token for the `x-mcp-remote-auth` header. */ + authToken?: string; + /** Fetch function to use (default: globalThis.fetch). Useful in tests. */ + fetchFn?: typeof fetch; +} + +export interface UseInspectorVersionResult { + /** The Inspector version, or undefined when unavailable / not yet loaded. */ + version: string | undefined; + /** True while the initial fetch is in flight. */ + loading: boolean; +} + +/** Minimal shape we read from the `/api/config` payload. */ +interface ConfigPayload { + version?: unknown; +} + +export function useInspectorVersion( + opts: UseInspectorVersionOptions, +): UseInspectorVersionResult { + const { baseUrl, authToken, fetchFn } = opts; + const doFetch = fetchFn ?? globalThis.fetch; + const base = baseUrl.replace(/\/$/, ""); + + const [version, setVersion] = useState(undefined); + const [loading, setLoading] = useState(true); + + const load = useCallback( + async (isCancelled: () => boolean): Promise => { + const headers: Record = {}; + if (authToken) headers["x-mcp-remote-auth"] = `Bearer ${authToken}`; + try { + const res = await doFetch(`${base}/api/config`, { + method: "GET", + headers, + }); + if (isCancelled() || !res.ok) return; + const body = (await res.json()) as ConfigPayload; + if (isCancelled()) return; + // Tolerate a missing/non-string field — a legacy backend that omits it + // leaves the version hidden rather than showing a bogus value. + setVersion( + typeof body.version === "string" && body.version + ? body.version + : undefined, + ); + } catch { + // Network error / aborted fetch: leave version undefined (hidden). + } finally { + if (!isCancelled()) setLoading(false); + } + }, + [base, authToken, doFetch], + ); + + useEffect(() => { + let cancelled = false; + void load(() => cancelled); + return () => { + cancelled = true; + }; + }, [load]); + + return { version, loading }; +}