feat(web): show the Inspector version in the lower-right corner (#1639)
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BrihGWcrM9JGRyu41nzZYw
This commit is contained in:
parent
9324f9f21c
commit
650e5bd020
10 changed files with 397 additions and 1 deletions
|
|
@ -14,8 +14,14 @@ import {
|
||||||
LEGACY_AUTH_TOKEN_ENV,
|
LEGACY_AUTH_TOKEN_ENV,
|
||||||
} from "../../../core/mcp/remote/constants.ts";
|
} from "../../../core/mcp/remote/constants.ts";
|
||||||
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts";
|
||||||
|
import { readInspectorVersion } from "../../../core/node/version.ts";
|
||||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
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 {
|
export interface WebServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
hostname: string;
|
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(
|
export function webServerConfigToInitialPayload(
|
||||||
config: WebServerConfig,
|
config: WebServerConfig,
|
||||||
): InitialConfigPayload {
|
): 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 mc = config.initialMcpConfig;
|
||||||
const defaultEnvironment = defaultEnvironmentFromProcess(
|
const defaultEnvironment = defaultEnvironmentFromProcess(
|
||||||
mc && "env" in mc && mc.env ? mc.env : undefined,
|
mc && "env" in mc && mc.env ? mc.env : undefined,
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,10 @@ import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"
|
||||||
import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
|
import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
|
||||||
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
|
import { useSandboxUrl } from "@inspector/core/react/useSandboxUrl.js";
|
||||||
import { useServerListWritable } from "@inspector/core/react/useServerListWritable.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 { usePendingClientRequests } from "@inspector/core/react/usePendingClientRequests.js";
|
||||||
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
import { InspectorView } from "./components/views/InspectorView/InspectorView";
|
||||||
|
import { VersionBadge } from "./components/elements/VersionBadge/VersionBadge";
|
||||||
import type {
|
import type {
|
||||||
ToolCallState,
|
ToolCallState,
|
||||||
ToolsUiState,
|
ToolsUiState,
|
||||||
|
|
@ -669,6 +671,12 @@ function App() {
|
||||||
baseUrl: configBaseUrl,
|
baseUrl: configBaseUrl,
|
||||||
authToken: getAuthToken(),
|
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>({});
|
const [clientConfig, setClientConfig] = useState<ClientConfig>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -3816,6 +3824,7 @@ function App() {
|
||||||
onRefreshApps={onRefreshTools}
|
onRefreshApps={onRefreshTools}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<VersionBadge version={inspectorVersion} />
|
||||||
<ServerConfigModal
|
<ServerConfigModal
|
||||||
opened={configModal !== null}
|
opened={configModal !== null}
|
||||||
mode={configModal?.mode ?? "add"}
|
mode={configModal?.mode ?? "add"}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 (
|
||||||
|
<VersionText aria-label={`Inspector version ${version}`}>
|
||||||
|
v{version}
|
||||||
|
</VersionText>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
clients/web/src/test/core/react/useInspectorVersion.test.tsx
Normal file
181
clients/web/src/test/core/react/useInspectorVersion.test.tsx
Normal file
|
|
@ -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<Response>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -260,6 +260,12 @@ describe("webServerConfigToInitialPayload", () => {
|
||||||
expect(payload.defaultTransport).toBeUndefined();
|
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'", () => {
|
it("emits stdio defaults when initialMcpConfig.type === 'stdio'", () => {
|
||||||
const cfg = baseConfig();
|
const cfg = baseConfig();
|
||||||
cfg.initialMcpConfig = {
|
cfg.initialMcpConfig = {
|
||||||
|
|
|
||||||
|
|
@ -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: {} };
|
return { root: {} };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@ export interface InitialConfigPayload {
|
||||||
* shows catalog CRUD affordances.
|
* shows catalog CRUD affordances.
|
||||||
*/
|
*/
|
||||||
writable?: boolean;
|
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 {
|
export interface RemoteServerOptions {
|
||||||
|
|
|
||||||
83
core/react/useInspectorVersion.ts
Normal file
83
core/react/useInspectorVersion.ts
Normal file
|
|
@ -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<string | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (isCancelled: () => boolean): Promise<void> => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue