chore(web): move App.tsx's module-level block into files (#2127)

Phase 0 of #2126. Lines 239-668 of clients/web/src/App.tsx were already at
module scope — helpers, formatters, constants, and the toast subcomponents,
none of which close over component state — so the whole block moves verbatim
into files. App.tsx drops from 5,303 to 4,892 lines with no behavior change.

Placement follows the lib-vs-utils rule in AGENTS.md: pure computation to
utils/, anything touching the environment to lib/.

EMPTY_SETTINGS and the two loose toast ids deliberately do NOT land in a new
top-level src/constants.ts, which the issue suggested: the web coverage
include is a whitelist naming components/hooks/theme/lib/utils/server, so a
module there would have fallen out of the >=90 gate silently. They go under
utils/ instead.

ReAuthBannerBar is in the block but not in the issue's table — it was added
after the issue was written (#2108). It moves to components/groups/ReAuthBanner/,
beside the banner it wraps.

Every new module is at 100% on all four coverage dimensions. Two v8 ignores,
each justified in place: getAuthToken's typeof-window guard (a happy-dom
inherent path) and replayProtocolRequest's provably-dead default case.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 18:07:40 -04:00
commit b6ed5072f4
4 changed files with 1232 additions and 453 deletions

View file

@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
API_SERVER_ENV_VARS,
INSPECTOR_API_TOKEN_GLOBAL,
} from "@inspector/core/mcp/remote/constants.js";
import { OAUTH_CALLBACK_PATH } from "../utils/oauthFlow";
import { getAuthToken, redirectUrlProvider } from "./authToken";
const KEY = API_SERVER_ENV_VARS.AUTH_TOKEN;
// happy-dom won't let `window.location` be reassigned, so drive the query
// string through the History API instead.
function setSearch(search: string): void {
window.history.replaceState(null, "", `/${search}`);
}
// happy-dom hands back a fresh Storage wrapper per `window.sessionStorage`
// access, so a spy on the instance never sees the call. Replace the property
// itself to exercise the "storage is unavailable" branches (privacy mode, a
// sandboxed iframe).
const realSessionStorage = window.sessionStorage;
function stubSessionStorage(overrides: Partial<Storage>): void {
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: { ...realSessionStorage, ...overrides },
});
}
function restoreSessionStorage(): void {
Object.defineProperty(window, "sessionStorage", {
configurable: true,
value: realSessionStorage,
});
}
describe("redirectUrlProvider", () => {
it("points at the backend's OAuth callback on the current origin", () => {
expect(redirectUrlProvider.getRedirectUrl()).toBe(
`${window.location.origin}${OAUTH_CALLBACK_PATH}`,
);
});
});
describe("getAuthToken", () => {
beforeEach(() => {
setSearch("");
window.sessionStorage.clear();
delete (window as unknown as Record<string, unknown>)[
INSPECTOR_API_TOKEN_GLOBAL
];
});
afterEach(() => {
vi.restoreAllMocks();
restoreSessionStorage();
setSearch("");
window.sessionStorage.clear();
delete (window as unknown as Record<string, unknown>)[
INSPECTOR_API_TOKEN_GLOBAL
];
});
it("prefers the injected global and persists it", () => {
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
"from-global";
setSearch(`?${KEY}=from-url`);
window.sessionStorage.setItem(KEY, "from-storage");
expect(getAuthToken()).toBe("from-global");
expect(window.sessionStorage.getItem(KEY)).toBe("from-global");
});
it("ignores a non-string or empty global and falls through to the URL", () => {
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
"";
setSearch(`?${KEY}=from-url`);
expect(getAuthToken()).toBe("from-url");
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
123;
expect(getAuthToken()).toBe("from-url");
expect(window.sessionStorage.getItem(KEY)).toBe("from-url");
});
it("falls back to sessionStorage when neither source is present", () => {
window.sessionStorage.setItem(KEY, "from-storage");
expect(getAuthToken()).toBe("from-storage");
});
it("returns undefined when nothing carries a token", () => {
expect(getAuthToken()).toBeUndefined();
});
it("still resolves when sessionStorage writes throw", () => {
const setItem = vi.fn(() => {
throw new Error("blocked");
});
stubSessionStorage({ setItem });
(window as unknown as Record<string, unknown>)[INSPECTOR_API_TOKEN_GLOBAL] =
"from-global";
expect(getAuthToken()).toBe("from-global");
expect(setItem).toHaveBeenCalledWith(KEY, "from-global");
});
it("returns undefined when sessionStorage reads throw", () => {
const getItem = vi.fn(() => {
throw new Error("blocked");
});
stubSessionStorage({ getItem });
expect(getAuthToken()).toBeUndefined();
expect(getItem).toHaveBeenCalledWith(KEY);
});
});

View file

@ -0,0 +1,61 @@
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
import { toRecord } from "@inspector/core/json/jsonUtils.js";
import {
API_SERVER_ENV_VARS,
INSPECTOR_API_TOKEN_GLOBAL,
} from "@inspector/core/mcp/remote/constants.js";
import { OAUTH_CALLBACK_PATH } from "../utils/oauthFlow";
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
// handler. The InspectorClient only consults this when the active server
// requires OAuth; for stdio MCP servers it's never used. Created once and
// reused so `BrowserOAuthClientProvider` doesn't re-instantiate per render.
export const redirectUrlProvider: RedirectUrlProvider = {
getRedirectUrl: () => `${window.location.origin}${OAUTH_CALLBACK_PATH}`,
};
// Recover the backend's auth token. Every browser request to /api/* needs it
// in the `x-mcp-remote-auth: Bearer …` header or the Hono backend returns 401.
// Three sources, in priority order:
// 1. `window.__INSPECTOR_API_TOKEN__` — injected into index.html by the
// backend on every page load (dev Vite plugin + prod Hono server). This
// is the robust path: it survives a bare-URL reload, a bookmark, or a
// cleared sessionStorage, none of which carry the query string.
// 2. `?MCP_INSPECTOR_API_TOKEN=…` — the URL the launcher banner prints. Kept
// as a fallback for pasted full URLs and older integrations.
// 3. sessionStorage — backstop for SPA navigations / OAuth round-trips that
// land without either of the above.
// Both the injected global and the URL value are persisted to sessionStorage
// so a later navigation that drops them (e.g. a deep-link load that wasn't
// injected, or an iframe) still authenticates from the backstop.
export function getAuthToken(): string | undefined {
/* v8 ignore next -- happy-dom always defines `window`; this is the SSR/no-DOM guard */
if (typeof window === "undefined") return undefined;
const STORAGE_KEY = API_SERVER_ENV_VARS.AUTH_TOKEN;
// Best-effort persistence — sessionStorage may be unavailable (privacy
// mode, iframe sandboxing, etc.); the resolved value still works for the
// current page load regardless.
const persist = (token: string): void => {
try {
window.sessionStorage.setItem(STORAGE_KEY, token);
} catch {
// ignore — see note above
}
};
const fromGlobal = toRecord(window)[INSPECTOR_API_TOKEN_GLOBAL];
if (typeof fromGlobal === "string" && fromGlobal) {
persist(fromGlobal);
return fromGlobal;
}
const params = new URLSearchParams(window.location.search);
const fromUrl = params.get(API_SERVER_ENV_VARS.AUTH_TOKEN);
if (fromUrl) {
persist(fromUrl);
return fromUrl;
}
try {
return window.sessionStorage.getItem(STORAGE_KEY) ?? undefined;
} catch {
return undefined;
}
}

View file

@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import type { Tool } from "@modelcontextprotocol/client";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import { messagesToLogEntries, replayProtocolRequest } from "./protocolReplay";
describe("messagesToLogEntries", () => {
const notification = (method: string, params: unknown): MessageEntry =>
({
direction: "notification",
timestamp: 1000,
message: { jsonrpc: "2.0", method, params },
}) as unknown as MessageEntry;
it("keeps only notifications/message and carries the timestamp", () => {
const entries = messagesToLogEntries([
notification("notifications/message", { level: "info", data: "hello" }),
notification("notifications/progress", { progress: 1 }),
]);
expect(entries).toEqual([
{ receivedAt: 1000, params: { level: "info", data: "hello" } },
]);
});
it("skips requests and responses", () => {
const request = {
direction: "request",
timestamp: 1,
message: { jsonrpc: "2.0", id: 1, method: "notifications/message" },
} as unknown as MessageEntry;
const response = {
direction: "response",
timestamp: 2,
message: { jsonrpc: "2.0", id: 1, result: {} },
} as unknown as MessageEntry;
expect(messagesToLogEntries([request, response])).toEqual([]);
});
it("skips a notification carrying no method", () => {
const malformed = {
direction: "notification",
timestamp: 3,
message: { jsonrpc: "2.0", result: {} },
} as unknown as MessageEntry;
expect(messagesToLogEntries([malformed])).toEqual([]);
});
});
describe("replayProtocolRequest", () => {
const tool = { name: "echo", inputSchema: { type: "object" } } as Tool;
function makeClient() {
return {
callTool: vi.fn().mockResolvedValue({}),
getPrompt: vi.fn().mockResolvedValue({}),
readResource: vi.fn().mockResolvedValue({}),
listTools: vi.fn().mockResolvedValue({}),
listPrompts: vi.fn().mockResolvedValue({}),
listResources: vi.fn().mockResolvedValue({}),
listResourceTemplates: vi.fn().mockResolvedValue({}),
listRequestorTasks: vi.fn().mockResolvedValue({}),
ping: vi.fn().mockResolvedValue({}),
};
}
const replay = (
client: ReturnType<typeof makeClient>,
method: string,
params?: Record<string, unknown>,
tools: Tool[] = [tool],
) =>
replayProtocolRequest(
client as unknown as InspectorClient,
method,
params,
tools,
);
it("refuses a method outside the shared replayable set", async () => {
const client = makeClient();
await expect(replay(client, "initialize")).resolves.toBe(
'Replay isn\'t supported for "initialize".',
);
});
it("replays tools/call with the recorded arguments", async () => {
const client = makeClient();
await expect(
replay(client, "tools/call", { name: "echo", arguments: { a: 1 } }),
).resolves.toBeNull();
expect(client.callTool).toHaveBeenCalledWith(tool, { a: 1 });
});
it("defaults tools/call arguments to an empty object", async () => {
const client = makeClient();
await expect(
replay(client, "tools/call", { name: "echo" }),
).resolves.toBeNull();
expect(client.callTool).toHaveBeenCalledWith(tool, {});
});
it("explains when the tool is gone, and when the name is missing", async () => {
const client = makeClient();
await expect(
replay(client, "tools/call", { name: "vanished" }),
).resolves.toBe('Tool "vanished" is no longer available to replay.');
await expect(replay(client, "tools/call", {})).resolves.toBe(
'Tool "?" is no longer available to replay.',
);
expect(client.callTool).not.toHaveBeenCalled();
});
it("replays prompts/get, and explains a missing name", async () => {
const client = makeClient();
await expect(
replay(client, "prompts/get", { name: "p", arguments: { x: 1 } }),
).resolves.toBeNull();
expect(client.getPrompt).toHaveBeenCalledWith("p", { x: 1 });
await expect(
replay(client, "prompts/get", { name: "p" }),
).resolves.toBeNull();
expect(client.getPrompt).toHaveBeenLastCalledWith("p", {});
await expect(replay(client, "prompts/get", {})).resolves.toBe(
"Prompt name is missing; cannot replay.",
);
});
it("replays resources/read, and explains a missing uri", async () => {
const client = makeClient();
await expect(
replay(client, "resources/read", { uri: "file:///a" }),
).resolves.toBeNull();
expect(client.readResource).toHaveBeenCalledWith("file:///a");
await expect(replay(client, "resources/read", {})).resolves.toBe(
"Resource URI is missing; cannot replay.",
);
});
it.each([
["tools/list", "listTools"],
["prompts/list", "listPrompts"],
["resources/list", "listResources"],
["resources/templates/list", "listResourceTemplates"],
["tasks/list", "listRequestorTasks"],
] as const)("replays %s on the recorded page", async (method, fn) => {
const client = makeClient();
await expect(
replay(client, method, { cursor: "page-2" }),
).resolves.toBeNull();
expect(client[fn]).toHaveBeenCalledWith("page-2");
});
it("passes no cursor when the entry carried none", async () => {
const client = makeClient();
await expect(replay(client, "tools/list", undefined)).resolves.toBeNull();
expect(client.listTools).toHaveBeenCalledWith(undefined);
await expect(
replay(client, "tools/list", { cursor: 7 }),
).resolves.toBeNull();
expect(client.listTools).toHaveBeenLastCalledWith(undefined);
});
it("replays ping", async () => {
const client = makeClient();
await expect(replay(client, "ping")).resolves.toBeNull();
expect(client.ping).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,108 @@
import type {
LoggingMessageNotification,
Tool,
} from "@modelcontextprotocol/client";
import type { InspectorClient } from "@inspector/core/mcp/index.js";
import type { JsonValue } from "@inspector/core/mcp/index.js";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import type { LogEntryData } from "../components/elements/LogEntry/LogEntry";
import { isReplayableProtocolMethod } from "../components/groups/protocolUtils.js";
// Derive `LogEntryData[]` from the MessageLog by filtering for the
// `notifications/message` notifications the server emits in response to
// `logging/setLevel`. The Logs screen renders these; we transform here
// rather than in the screen so the view stays prop-driven.
export function messagesToLogEntries(messages: MessageEntry[]): LogEntryData[] {
const out: LogEntryData[] = [];
for (const m of messages) {
if (m.direction !== "notification") continue;
// MessageEntry.message is a JSONRPC union; notifications have `method`
// but not `id`. Narrow with an `in` check, then confirm the method.
if (!("method" in m.message)) continue;
if (m.message.method !== "notifications/message") continue;
// The method check pins this to a logging notification; its `params` are
// only generically typed on the JSONRPC union, so cast just that value.
const params = m.message.params as LoggingMessageNotification["params"];
out.push({
receivedAt: m.timestamp,
params,
});
}
return out;
}
// Re-issue the original request behind a Protocol entry. The call goes through
// InspectorClient → tracked transport → message log, so the replayed
// request+response surface as a fresh Protocol entry (protocol-local) — it
// intentionally does NOT touch the Tools/Prompts/Resources panels. Returns a
// human-readable reason when the entry can't be replayed (unsupported method,
// or a tool that's no longer present), or null on a dispatched replay.
export async function replayProtocolRequest(
client: InspectorClient,
method: string,
params: Record<string, unknown> | undefined,
tools: Tool[],
): Promise<string | null> {
// Gate on the shared replayable-method set (the same one ProtocolEntry uses to
// show/hide the Replay button) so the two can't drift.
if (!isReplayableProtocolMethod(method)) {
return `Replay isn't supported for "${method}".`;
}
// Pagination cursor carried by the */list requests; replaying the same page
// reproduces the original call.
const cursor = typeof params?.cursor === "string" ? params.cursor : undefined;
switch (method) {
case "tools/call": {
const name = typeof params?.name === "string" ? params.name : undefined;
const tool = tools.find((t) => t.name === name);
if (!tool) {
return `Tool "${name ?? "?"}" is no longer available to replay.`;
}
await client.callTool(
tool,
(params?.arguments ?? {}) as Record<string, JsonValue>,
);
return null;
}
case "prompts/get": {
const name = typeof params?.name === "string" ? params.name : undefined;
if (!name) return "Prompt name is missing; cannot replay.";
await client.getPrompt(
name,
(params?.arguments ?? {}) as Record<string, JsonValue>,
);
return null;
}
case "resources/read": {
const uri = typeof params?.uri === "string" ? params.uri : undefined;
if (!uri) return "Resource URI is missing; cannot replay.";
await client.readResource(uri);
return null;
}
case "tools/list":
await client.listTools(cursor);
return null;
case "prompts/list":
await client.listPrompts(cursor);
return null;
case "resources/list":
await client.listResources(cursor);
return null;
case "resources/templates/list":
await client.listResourceTemplates(cursor);
return null;
case "tasks/list":
await client.listRequestorTasks(cursor);
return null;
case "ping":
await client.ping();
return null;
/* v8 ignore start -- unreachable: the guard above admits exactly the nine
methods this switch enumerates. Kept so a method added to
REPLAYABLE_PROTOCOL_METHODS without a case here reports a reason rather
than silently resolving as a dispatched replay. */
default:
return `Replay isn't supported for "${method}".`;
/* v8 ignore stop */
}
}