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
12 changed files with 1232 additions and 453 deletions

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { errorCodeOf, errorMessage, formatErrorDetails } from "./errorFormat";
describe("errorMessage", () => {
it("returns an Error's message", () => {
expect(errorMessage(new Error("boom"))).toBe("boom");
});
it("stringifies a non-Error", () => {
expect(errorMessage("plain string")).toBe("plain string");
expect(errorMessage(undefined)).toBe("undefined");
expect(errorMessage(42)).toBe("42");
});
});
describe("errorCodeOf", () => {
it("reads a numeric `code` off a protocol error", () => {
expect(
errorCodeOf(Object.assign(new Error("nope"), { code: -32602 })),
).toBe(-32602);
});
it("returns undefined for a plain Error", () => {
expect(errorCodeOf(new Error("plain"))).toBeUndefined();
});
it("returns undefined when `code` is not a number", () => {
expect(errorCodeOf({ code: "-32602" })).toBeUndefined();
});
it("returns undefined for non-objects", () => {
expect(errorCodeOf(null)).toBeUndefined();
expect(errorCodeOf("boom")).toBeUndefined();
});
});
describe("formatErrorDetails", () => {
it("pretty-prints code/message/data when a code is present", () => {
const err = Object.assign(new Error("bad params"), {
code: -32602,
data: { field: "uri" },
});
expect(JSON.parse(formatErrorDetails(err))).toEqual({
code: -32602,
message: "bad params",
data: { field: "uri" },
});
});
it("pretty-prints when only `data` is present", () => {
expect(
JSON.parse(formatErrorDetails({ data: [1, 2], message: "m" })),
).toEqual({ message: "m", data: [1, 2] });
});
it("falls back to the plain message for a bare Error", () => {
expect(formatErrorDetails(new Error("just a message"))).toBe(
"just a message",
);
});
it("falls back to String() for a non-object", () => {
expect(formatErrorDetails("boom")).toBe("boom");
expect(formatErrorDetails(null)).toBe("null");
});
});

View file

@ -0,0 +1,37 @@
// Error-shaping helpers shared by the toast bodies and the detail modals.
// Deliberately duck-typed rather than coupled to the SDK's error classes: the
// values reaching here come off a `catch`, so their only guaranteed type is
// `unknown`.
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// The numeric JSON-RPC code of a thrown protocol error (e.g. a `ProtocolError`
// carrying `-32602`), or undefined for a plain Error. Duck-typed like
// `formatErrorDetails` so we don't couple to the SDK's error class here — the
// only consumer is the Tools error panel's unknown-tool (`-32602`) hint (#1632).
export function errorCodeOf(err: unknown): number | undefined {
if (err && typeof err === "object") {
const code = (err as { code?: unknown }).code;
if (typeof code === "number") return code;
}
return undefined;
}
// Pretty-print a thrown error for the URL-elicitation details modal: a ProtocolError
// carries a `code`/`data` worth showing alongside the message, so include them
// when present; otherwise fall back to the plain message.
export function formatErrorDetails(err: unknown): string {
if (err && typeof err === "object") {
const e = err as { code?: unknown; message?: unknown; data?: unknown };
if (e.code !== undefined || e.data !== undefined) {
return JSON.stringify(
{ code: e.code, message: e.message, data: e.data },
null,
2,
);
}
}
return errorMessage(err);
}

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
} from "@inspector/core/mcp/types.js";
import { EMPTY_SETTINGS } from "./serverSettingsDefaults";
describe("EMPTY_SETTINGS", () => {
it("is an empty draft carrying the shared defaults", () => {
expect(EMPTY_SETTINGS).toEqual({
headers: [],
env: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
autoRefreshOnListChanged: false,
paginatedLists: false,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
roots: [],
});
});
it("keeps one object identity so React sees a stable reference", async () => {
const again = await import("./serverSettingsDefaults");
expect(again.EMPTY_SETTINGS).toBe(EMPTY_SETTINGS);
});
});

View file

@ -0,0 +1,24 @@
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
} from "@inspector/core/mcp/types.js";
// Stable empty-shell for `InspectorServerSettings`. Used both as the
// initial draft for a server entry that hasn't been touched yet, and as
// the fallback the settings modal renders against when it's closed
// (Mantine renders the dialog shell regardless of `opened`). Hoisted to
// module scope so both call sites share the same object identity and so
// React doesn't re-allocate on every render.
export const EMPTY_SETTINGS: InspectorServerSettings = {
headers: [],
env: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
autoRefreshOnListChanged: false,
paginatedLists: false,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
roots: [],
};

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { isEmaStepUp, isStepUpConfirmation } from "./stepUp";
const challenge = (
reason: AuthChallenge["reason"] = "insufficient_scope",
): AuthChallenge => ({ reason });
// Only `settings.enterpriseManaged` is read, so the rest of a ServerEntry is
// irrelevant here — build the narrow shape the adapters project from.
const server = (enterpriseManaged?: boolean): ServerEntry =>
({ settings: { enterpriseManaged } }) as ServerEntry;
describe("isEmaStepUp", () => {
it("is true for insufficient_scope on an enterprise-managed server", () => {
expect(isEmaStepUp(challenge(), server(true))).toBe(true);
});
it("is false when the server is not enterprise-managed", () => {
expect(isEmaStepUp(challenge(), server(false))).toBe(false);
});
it("is false when there is no server at all", () => {
expect(isEmaStepUp(challenge(), undefined)).toBe(false);
});
it("is false for a challenge that is not a scope step-up", () => {
expect(isEmaStepUp(challenge("unauthorized"), server(true))).toBe(false);
});
});
describe("isStepUpConfirmation", () => {
it("covers both the standard and the enterprise-managed step-up", () => {
expect(isStepUpConfirmation(challenge(), server(true))).toBe(true);
expect(isStepUpConfirmation(challenge(), server(false))).toBe(true);
expect(isStepUpConfirmation(challenge(), undefined)).toBe(true);
});
it("is false for a challenge that is not a scope step-up", () => {
expect(isStepUpConfirmation(challenge("unauthorized"), server(true))).toBe(
false,
);
});
});

View file

@ -0,0 +1,31 @@
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
import {
isEmaStepUp as isCoreEmaStepUp,
isStepUpConfirmation as isCoreStepUpConfirmation,
} from "@inspector/core/auth/oauthUx.js";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
// Thin adapters over core's step-up predicates: the web client holds a
// `ServerEntry` where core takes only the enterprise-managed settings, so these
// project one onto the other at the single place App.tsx asks the question.
export function isEmaStepUp(
challenge: AuthChallenge,
server: ServerEntry | undefined,
): boolean {
return isCoreEmaStepUp(challenge, {
enterpriseManaged: server?.settings?.enterpriseManaged,
});
}
export function isStepUpConfirmation(
challenge: AuthChallenge,
server: ServerEntry | undefined,
): boolean {
return isCoreStepUpConfirmation(challenge, {
enterpriseManaged: server?.settings?.enterpriseManaged,
});
}
/** Which in-flight action opened the step-up modal (for scoped cancel UX). */
export type StepUpSource = "tool" | "prompt" | "resource" | "ambient" | "app";

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
formatProgressToastMessage,
PROGRESS_TOAST_AUTOCLOSE_MS,
progressToastId,
} from "./progressToasts";
describe("PROGRESS_TOAST_AUTOCLOSE_MS", () => {
it("lingers a few seconds after the last tick", () => {
expect(PROGRESS_TOAST_AUTOCLOSE_MS).toBe(5000);
});
});
describe("progressToastId", () => {
it("keys by the progress token", () => {
expect(progressToastId("abc")).toBe("progress-abc");
expect(progressToastId(7)).toBe("progress-7");
});
it("shares one id when the server sends no token", () => {
expect(progressToastId(undefined)).toBe("progress-default");
});
});
describe("formatProgressToastMessage", () => {
it("renders the fraction and percentage when a total is present", () => {
expect(formatProgressToastMessage({ progress: 1, total: 4 })).toBe(
"1 / 4 (25%)",
);
});
it("prefixes the server's message when present", () => {
expect(
formatProgressToastMessage({
progress: 3,
total: 4,
message: "Indexing",
}),
).toBe("Indexing — 3 / 4 (75%)");
});
it("omits the fraction when there is no total", () => {
expect(formatProgressToastMessage({ progress: 9 })).toBe("9");
expect(
formatProgressToastMessage({ progress: 9, message: "Working" }),
).toBe("Working — 9");
});
it("omits the fraction when the total is zero (no divide-by-zero)", () => {
expect(formatProgressToastMessage({ progress: 0, total: 0 })).toBe("0");
});
});

View file

@ -0,0 +1,29 @@
import type { Progress, ProgressToken } from "@modelcontextprotocol/client";
// How long a progress toast lingers after its last tick. Each new tick on the
// same progress stream resets this window (via `notifications.update`), so a
// steady stream keeps one toast alive; the toast clears a few seconds after
// progress stops (i.e. the call finished or went quiet).
export const PROGRESS_TOAST_AUTOCLOSE_MS = 5000;
// Stable toast id for a progress stream. Notifications keyed by this id are
// replaced (not stacked) so a chatty server updates one toast per stream
// rather than flooding the corner. The injected `progressToken` correlates a
// stream with the request that triggered it; when absent (the common case —
// the inspector doesn't expose a caller token), all ticks share one toast.
export function progressToastId(token: ProgressToken | undefined): string {
return `progress-${String(token ?? "default")}`;
}
// One-line toast body: "<message> — <progress> / <total> (NN%)". The fraction
// and percentage are omitted when the server sends no `total`.
export function formatProgressToastMessage(
detail: Progress & { progressToken?: ProgressToken },
): string {
const { progress, total, message } = detail;
const ratio =
total !== undefined && total > 0
? `${progress} / ${total} (${Math.round((progress / total) * 100)}%)`
: `${progress}`;
return message ? `${message} — ${ratio}` : ratio;
}

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
formatTaskToastMessage,
isTerminalTaskStatus,
TASK_CANCELLED_TOAST_AUTOCLOSE_MS,
taskToastColor,
taskToastId,
TERMINAL_TASK_STATUSES,
} from "./taskToasts";
describe("TASK_CANCELLED_TOAST_AUTOCLOSE_MS", () => {
it("auto-dismisses the one-shot confirmation", () => {
expect(TASK_CANCELLED_TOAST_AUTOCLOSE_MS).toBe(5000);
});
});
describe("isTerminalTaskStatus", () => {
it("is true for every terminal status", () => {
for (const status of TERMINAL_TASK_STATUSES) {
expect(isTerminalTaskStatus(status)).toBe(true);
}
expect([...TERMINAL_TASK_STATUSES].sort()).toEqual([
"cancelled",
"completed",
"failed",
]);
});
it("is false for a status that can still change", () => {
expect(isTerminalTaskStatus("working")).toBe(false);
expect(isTerminalTaskStatus("input_required")).toBe(false);
});
});
describe("taskToastId", () => {
it("keys one toast per task", () => {
expect(taskToastId("t1")).toBe("task-t1");
expect(taskToastId("t2")).not.toBe(taskToastId("t1"));
});
});
describe("taskToastColor", () => {
it("mirrors the TaskStatusBadge mapping", () => {
expect(taskToastColor("completed")).toBe("green");
expect(taskToastColor("failed")).toBe("red");
expect(taskToastColor("cancelled")).toBe("gray");
expect(taskToastColor("input_required")).toBe("yellow");
});
it("falls back to blue for an in-flight status", () => {
expect(taskToastColor("working")).toBe("blue");
});
});
describe("formatTaskToastMessage", () => {
it("prefers the server's statusMessage", () => {
expect(
formatTaskToastMessage({
status: "working",
statusMessage: "Step 2 of 3",
}),
).toBe("Step 2 of 3");
});
it("falls back to naming the status", () => {
expect(formatTaskToastMessage({ status: "failed" })).toBe("Task failed");
});
});

View file

@ -0,0 +1,51 @@
import type { Task } from "@modelcontextprotocol/client";
// A task cancellation is a one-shot confirmation (unlike the live status toast,
// which stays open while the task runs), so it auto-dismisses after a moment.
export const TASK_CANCELLED_TOAST_AUTOCLOSE_MS = 5000;
// Terminal task states — once a task reaches one of these it can't change, so
// its toast is dismissed and its per-task progress entry is pruned.
export const TERMINAL_TASK_STATUSES: ReadonlySet<Task["status"]> = new Set([
"completed",
"failed",
"cancelled",
]);
export function isTerminalTaskStatus(status: Task["status"]): boolean {
return TERMINAL_TASK_STATUSES.has(status);
}
// Stable toast id per task so live status updates replace one toast rather than
// stacking a fresh one per `notifications/tasks/status` tick.
export function taskToastId(taskId: string): string {
return `task-${taskId}`;
}
// Toast color per task status — mirrors TaskStatusBadge's mapping so the toast
// and the Tasks-screen badge read consistently.
export function taskToastColor(status: Task["status"]): string {
switch (status) {
case "completed":
return "green";
case "failed":
return "red";
case "cancelled":
return "gray";
case "input_required":
return "yellow";
default:
return "blue";
}
}
// The subset of Task fields the toast layer reads. Both task-event payloads —
// the server `taskStatusChange` (full Task) and the client-origin
// `requestorTaskUpdated` (Task with optional createdAt) — satisfy this.
export type TaskToastInput = Pick<Task, "status"> & { statusMessage?: string };
// One-line toast body: the task's `statusMessage` when present, else a short
// fallback naming the status. The title carries the status itself.
export function formatTaskToastMessage(task: TaskToastInput): string {
return task.statusMessage ?? `Task ${task.status}`;
}

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
bodyDroppedToastId,
CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
} from "./toastIds";
describe("bodyDroppedToastId", () => {
it("keys the toast per server so a storm updates one toast", () => {
expect(bodyDroppedToastId("srv-1")).toBe("fetch-body-dropped-srv-1");
expect(bodyDroppedToastId("srv-1")).toBe(bodyDroppedToastId("srv-1"));
expect(bodyDroppedToastId("srv-2")).not.toBe(bodyDroppedToastId("srv-1"));
});
});
describe("CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID", () => {
it("is a stable id", () => {
expect(CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID).toBe(
"client-config-load-error",
);
});
});

View file

@ -0,0 +1,14 @@
// Toast ids that aren't owned by a feature-specific formatter module. The
// progress and task ids live beside their message formatters in
// `progressToasts.ts` / `taskToasts.ts`; these two have no formatter of their
// own, so they land here.
// Stable toast id for the "response body dropped" warning, keyed per server so
// a request storm updates one persistent toast rather than stacking thousands
// (the drop event can fire rapidly). Mirrors the progress-toast dedupe pattern.
export function bodyDroppedToastId(serverId: string): string {
return `fetch-body-dropped-${serverId}`;
}
export const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID =
"client-config-load-error";