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

View file

@ -7,11 +7,7 @@ import {
useSyncExternalStore,
} from "react";
import {
Anchor,
Box,
List,
Paper,
Stack,
Text,
useComputedColorScheme,
useMantineColorScheme,
@ -22,15 +18,10 @@ import type {
ElicitResult,
InitializeResult,
LoggingLevel,
LoggingMessageNotification,
Progress,
ProgressToken,
Resource,
Task,
Tool,
} from "@modelcontextprotocol/client";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { toRecord } from "@inspector/core/json/jsonUtils.js";
import { getServerType } from "@inspector/core/mcp/config.js";
import type {
InspectorClientEventMap,
@ -46,7 +37,6 @@ import type { TypedEventGeneric } from "@inspector/core/mcp/typedEventTarget.js"
import type {
InspectorServerSettings,
MCPServerConfig,
MessageEntry,
ServerEntry,
ServerType,
} from "@inspector/core/mcp/types.js";
@ -56,10 +46,6 @@ import {
eraToVersionNegotiation,
resolveModernLogLevel,
} from "@inspector/core/mcp/types.js";
import {
API_SERVER_ENV_VARS,
INSPECTOR_API_TOKEN_GLOBAL,
} from "@inspector/core/mcp/remote/constants.js";
import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState.js";
import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState.js";
import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState.js";
@ -91,7 +77,6 @@ import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import type { FetchRequestLogStateEventMap } from "@inspector/core/mcp/state/fetchRequestLogState.js";
import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js";
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
import {
parseOAuthCallbackParams,
parseOAuthState,
@ -175,7 +160,6 @@ import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/Con
import { oauthDetailsFromConnectionState } from "./components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
import { isReplayableProtocolMethod } from "./components/groups/protocolUtils.js";
import type { OAuthDetails } from "./components/groups/ConnectionInfoContent/ConnectionInfoContent";
import { ServerRemoveConfirmModal } from "./components/groups/ServerRemoveConfirmModal/ServerRemoveConfirmModal";
import { StepUpAuthModal } from "./components/groups/StepUpAuthModal/StepUpAuthModal";
@ -215,8 +199,6 @@ import {
emaStepUpFailureMessage,
emaStepUpInProgressMessage,
emaStepUpSuccessMessage,
isEmaStepUp as isCoreEmaStepUp,
isStepUpConfirmation as isCoreStepUpConfirmation,
} from "@inspector/core/auth/oauthUx.js";
import { clearServerOAuthState } from "./lib/clearServerOAuthState";
import {
@ -236,437 +218,43 @@ import {
onBrowserTabVisible,
} from "./lib/browserTabVisibility";
import type { PendingReauth } from "./utils/pendingReauth";
// 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.
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.
function getAuthToken(): string | undefined {
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;
}
}
// 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.
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.
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;
default:
return `Replay isn't supported for "${method}".`;
}
}
// 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.
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: [],
};
// 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.
function bodyDroppedToastId(serverId: string): string {
return `fetch-body-dropped-${serverId}`;
}
const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID = "client-config-load-error";
// Shared "list of likely causes" styling for the warning-toast bodies below.
const ToastCauseList = List.withProps({ size: "sm", spacing: 2 });
// The "open the relevant settings/details" link rendered at the bottom of each
// warning-toast body. Same static shape across all three toasts; each passes
// its own `onClick`.
const ToastLinkButton = Anchor.withProps({
component: "button",
type: "button",
size: "sm",
});
// The re-auth popup. A `Paper` so every static style is a prop; the stacking
// order goes through `styles.root` since Mantine has no `z` prop.
//
// Floats top-right rather than spanning the top as a sticky full-bleed bar.
// The bar cost the whole view a band of vertical space for what is a
// notification about one server, and it sat directly above the monitoring
// sidebar that an OAuth failure now opens (#2108) — the two things a user needs
// at once here are this affordance and those requests, so it must not push them
// around. `fixed`, not `sticky`, so scrolling the server list leaves it put.
//
// Centered, and deliberately WITHOUT an overlay. Every corner is spoken for —
// the toast stack owns bottom-right (see `main.tsx`), the right edge is the
// monitoring sidebar whose toolbar this would cover, and top-left is the
// Servers header — so anchoring it anywhere hides something. Centering is the
// one placement that reads as addressed to the whole window rather than
// attached to the wrong panel.
//
// The missing overlay is the point, not an omission: this is a notification,
// not a decision that must be made now. An OAuth failure opens the monitoring
// sidebar (#2108), and blocking the page would force a choice between reading
// those requests and keeping the affordance — dismissing is not free, since
// "Authorize again" also clears the stale OAuth state, which a plain reconnect
// does not do. So it floats above the page and leaves it usable.
//
// `transform` goes through `styles.root` for the same reason `zIndex` does:
// Mantine exposes neither as a style prop.
const ReAuthBannerBar = Paper.withProps({
pos: "fixed",
top: "50%",
left: "50%",
w: 420,
bg: "var(--mantine-color-body)",
shadow: "xl",
radius: "md",
styles: { root: { transform: "translate(-50%, -50%)", zIndex: 200 } },
});
// Body of the "response body dropped" warning toast: a one-line summary of what
// happened, the likely causes, and a link that opens this server's settings
// (on the Options section) so the user can raise the Network Log Size if it's
// just a high-traffic server. Surfaces the otherwise-invisible rotation drop
// described in #1390.
const FetchBodyDroppedToastMessage = ({
maxFetchRequests,
onAdjust,
}: {
maxFetchRequests: number;
onAdjust: () => void;
}) => (
<Stack gap={4}>
<Text size="sm">
A response body arrived after its Network log entry had already rotated
out (the log hit its {maxFetchRequests}-request limit), so the body
couldn&apos;t be shown. This usually indicates:
</Text>
<ToastCauseList>
<List.Item>
a chatty or misbehaving server (notification storms, rapid polling)
</List.Item>
<List.Item>an SSE/transport reconnect or retry storm</List.Item>
<List.Item>
a slow streaming call racing against high request volume
</List.Item>
<List.Item>
the Network Log Size set too low for this server&apos;s traffic
</List.Item>
</ToastCauseList>
<ToastLinkButton onClick={onAdjust}>
Adjust Network Log Size for this server
</ToastLinkButton>
</Stack>
);
// Body of the output-schema-mismatch warning toast: a one-line summary plus a
// link that opens the full validation details in a modal (the raw error is far
// too long for a toast).
const OutputValidationToastMessage = ({
onViewDetails,
}: {
onViewDetails: () => void;
}) => (
<Stack gap={4}>
<Text size="sm">
The tool result&apos;s structuredContent doesn&apos;t match the
tool&apos;s outputSchema. The inspector renders it anyway, but strict MCP
clients may not.
</Text>
<ToastLinkButton onClick={onViewDetails}>
View validation details
</ToastLinkButton>
</Stack>
);
// Body of the non-spec URLElicitationRequired toast: the server returned a
// -32042 error with no `elicitations` list, so there's no URL to open. We keep
// the toast short and link to a modal with the raw error body.
const UrlElicitationErrorToastMessage = ({
onViewDetails,
}: {
onViewDetails: () => void;
}) => (
<Stack gap={4}>
<Text size="sm">
The server reported a URLElicitationRequired error but listed no required
elicitations, so there&apos;s nothing to open.
</Text>
<ToastLinkButton onClick={onViewDetails}>
View error details
</ToastLinkButton>
</Stack>
);
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).
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.
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);
}
// 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).
const PROGRESS_TOAST_AUTOCLOSE_MS = 5000;
// 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.
const TASK_CANCELLED_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.
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`.
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;
}
// 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.
const TERMINAL_TASK_STATUSES: ReadonlySet<Task["status"]> = new Set([
"completed",
"failed",
"cancelled",
]);
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.
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.
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.
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.
function formatTaskToastMessage(task: TaskToastInput): string {
return task.statusMessage ?? `Task ${task.status}`;
}
function isEmaStepUp(
challenge: AuthChallenge,
server: ServerEntry | undefined,
): boolean {
return isCoreEmaStepUp(challenge, {
enterpriseManaged: server?.settings?.enterpriseManaged,
});
}
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). */
type StepUpSource = "tool" | "prompt" | "resource" | "ambient" | "app";
import { getAuthToken, redirectUrlProvider } from "./lib/authToken";
import {
messagesToLogEntries,
replayProtocolRequest,
} from "./lib/protocolReplay";
import {
errorCodeOf,
errorMessage,
formatErrorDetails,
} from "./utils/errorFormat";
import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
import {
isEmaStepUp,
isStepUpConfirmation,
type StepUpSource,
} from "./utils/stepUp";
import {
bodyDroppedToastId,
CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
} from "./utils/toasts/toastIds";
import {
formatProgressToastMessage,
PROGRESS_TOAST_AUTOCLOSE_MS,
progressToastId,
} from "./utils/toasts/progressToasts";
import {
formatTaskToastMessage,
isTerminalTaskStatus,
TASK_CANCELLED_TOAST_AUTOCLOSE_MS,
taskToastColor,
taskToastId,
type TaskToastInput,
} from "./utils/toasts/taskToasts";
import { FetchBodyDroppedToastMessage } from "./components/elements/Toasts/FetchBodyDroppedToastMessage";
import { OutputValidationToastMessage } from "./components/elements/Toasts/OutputValidationToastMessage";
import { UrlElicitationErrorToastMessage } from "./components/elements/Toasts/UrlElicitationErrorToastMessage";
import { ReAuthBannerBar } from "./components/groups/ReAuthBanner/ReAuthBannerBar";
function App() {
// Theme toggle plumbing (preserved from the pre-wire placeholder).

View file

@ -0,0 +1,38 @@
import { List, Stack, Text } from "@mantine/core";
import { ToastCauseList, ToastLinkButton } from "./ToastPrimitives";
// Body of the "response body dropped" warning toast: a one-line summary of what
// happened, the likely causes, and a link that opens this server's settings
// (on the Options section) so the user can raise the Network Log Size if it's
// just a high-traffic server. Surfaces the otherwise-invisible rotation drop
// described in #1390.
export const FetchBodyDroppedToastMessage = ({
maxFetchRequests,
onAdjust,
}: {
maxFetchRequests: number;
onAdjust: () => void;
}) => (
<Stack gap={4}>
<Text size="sm">
A response body arrived after its Network log entry had already rotated
out (the log hit its {maxFetchRequests}-request limit), so the body
couldn&apos;t be shown. This usually indicates:
</Text>
<ToastCauseList>
<List.Item>
a chatty or misbehaving server (notification storms, rapid polling)
</List.Item>
<List.Item>an SSE/transport reconnect or retry storm</List.Item>
<List.Item>
a slow streaming call racing against high request volume
</List.Item>
<List.Item>
the Network Log Size set too low for this server&apos;s traffic
</List.Item>
</ToastCauseList>
<ToastLinkButton onClick={onAdjust}>
Adjust Network Log Size for this server
</ToastLinkButton>
</Stack>
);

View file

@ -0,0 +1,22 @@
import { Stack, Text } from "@mantine/core";
import { ToastLinkButton } from "./ToastPrimitives";
// Body of the output-schema-mismatch warning toast: a one-line summary plus a
// link that opens the full validation details in a modal (the raw error is far
// too long for a toast).
export const OutputValidationToastMessage = ({
onViewDetails,
}: {
onViewDetails: () => void;
}) => (
<Stack gap={4}>
<Text size="sm">
The tool result&apos;s structuredContent doesn&apos;t match the
tool&apos;s outputSchema. The inspector renders it anyway, but strict MCP
clients may not.
</Text>
<ToastLinkButton onClick={onViewDetails}>
View validation details
</ToastLinkButton>
</Stack>
);

View file

@ -0,0 +1,13 @@
import { Anchor, List } from "@mantine/core";
// Shared "list of likely causes" styling for the warning-toast bodies.
export const ToastCauseList = List.withProps({ size: "sm", spacing: 2 });
// The "open the relevant settings/details" link rendered at the bottom of each
// warning-toast body. Same static shape across all three toasts; each passes
// its own `onClick`.
export const ToastLinkButton = Anchor.withProps({
component: "button",
type: "button",
size: "sm",
});

View file

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { FetchBodyDroppedToastMessage } from "./FetchBodyDroppedToastMessage";
import { OutputValidationToastMessage } from "./OutputValidationToastMessage";
import { UrlElicitationErrorToastMessage } from "./UrlElicitationErrorToastMessage";
// The bodies rendered inside a Mantine notification. They are shown here on
// their own so the copy and the affordance can be reviewed without provoking
// the failure that raises each one.
const meta: Meta = {
title: "Elements/Toasts",
};
export default meta;
/** #1390 — a response body arrived after its Network log entry rotated out. */
export const FetchBodyDropped: StoryObj<typeof FetchBodyDroppedToastMessage> = {
render: (args) => <FetchBodyDroppedToastMessage {...args} />,
args: { maxFetchRequests: 250, onAdjust: fn() },
};
/** A tool result whose `structuredContent` doesn't match its `outputSchema`. */
export const OutputValidation: StoryObj<typeof OutputValidationToastMessage> = {
render: (args) => <OutputValidationToastMessage {...args} />,
args: { onViewDetails: fn() },
};
/** A non-spec `-32042` carrying no `elicitations`, so there is no URL to open. */
export const UrlElicitationError: StoryObj<
typeof UrlElicitationErrorToastMessage
> = {
render: (args) => <UrlElicitationErrorToastMessage {...args} />,
args: { onViewDetails: fn() },
};

View file

@ -0,0 +1,87 @@
import { describe, expect, it, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { FetchBodyDroppedToastMessage } from "./FetchBodyDroppedToastMessage";
import { OutputValidationToastMessage } from "./OutputValidationToastMessage";
import { UrlElicitationErrorToastMessage } from "./UrlElicitationErrorToastMessage";
import { ToastCauseList, ToastLinkButton } from "./ToastPrimitives";
describe("ToastPrimitives", () => {
it("renders the shared cause list and link button", () => {
const onClick = vi.fn();
renderWithMantine(
<>
<ToastCauseList>
<li>a cause</li>
</ToastCauseList>
<ToastLinkButton onClick={onClick}>Do the thing</ToastLinkButton>
</>,
);
expect(screen.getByText("a cause")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Do the thing" }),
).toBeInTheDocument();
});
});
describe("FetchBodyDroppedToastMessage", () => {
it("names the limit that dropped the body and lists the likely causes", () => {
renderWithMantine(
<FetchBodyDroppedToastMessage
maxFetchRequests={250}
onAdjust={vi.fn()}
/>,
);
expect(screen.getByText(/250/)).toBeInTheDocument();
expect(
screen.getByText(/an SSE\/transport reconnect or retry storm/),
).toBeInTheDocument();
});
it("calls onAdjust from the settings link", async () => {
const onAdjust = vi.fn();
renderWithMantine(
<FetchBodyDroppedToastMessage
maxFetchRequests={10}
onAdjust={onAdjust}
/>,
);
await userEvent.click(
screen.getByRole("button", {
name: "Adjust Network Log Size for this server",
}),
);
expect(onAdjust).toHaveBeenCalledTimes(1);
});
});
describe("OutputValidationToastMessage", () => {
it("summarizes the mismatch and opens the details modal", async () => {
const onViewDetails = vi.fn();
renderWithMantine(
<OutputValidationToastMessage onViewDetails={onViewDetails} />,
);
expect(screen.getByText(/outputSchema/)).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "View validation details" }),
);
expect(onViewDetails).toHaveBeenCalledTimes(1);
});
});
describe("UrlElicitationErrorToastMessage", () => {
it("explains the empty elicitations list and opens the details modal", async () => {
const onViewDetails = vi.fn();
renderWithMantine(
<UrlElicitationErrorToastMessage onViewDetails={onViewDetails} />,
);
expect(
screen.getByText(/listed no required\s+elicitations/),
).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "View error details" }),
);
expect(onViewDetails).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,21 @@
import { Stack, Text } from "@mantine/core";
import { ToastLinkButton } from "./ToastPrimitives";
// Body of the non-spec URLElicitationRequired toast: the server returned a
// -32042 error with no `elicitations` list, so there's no URL to open. We keep
// the toast short and link to a modal with the raw error body.
export const UrlElicitationErrorToastMessage = ({
onViewDetails,
}: {
onViewDetails: () => void;
}) => (
<Stack gap={4}>
<Text size="sm">
The server reported a URLElicitationRequired error but listed no required
elicitations, so there&apos;s nothing to open.
</Text>
<ToastLinkButton onClick={onViewDetails}>
View error details
</ToastLinkButton>
</Stack>
);

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { ReAuthBannerBar } from "./ReAuthBannerBar";
describe("ReAuthBannerBar", () => {
it("floats the banner centered above the page", () => {
renderWithMantine(
<ReAuthBannerBar data-testid="bar">contents</ReAuthBannerBar>,
);
const bar = screen.getByTestId("bar");
expect(bar).toHaveTextContent("contents");
expect(bar.style.transform).toBe("translate(-50%, -50%)");
expect(bar.style.zIndex).toBe("200");
});
});

View file

@ -0,0 +1,38 @@
import { Paper } from "@mantine/core";
// The re-auth popup. A `Paper` so every static style is a prop; the stacking
// order goes through `styles.root` since Mantine has no `z` prop.
//
// Floats top-right rather than spanning the top as a sticky full-bleed bar.
// The bar cost the whole view a band of vertical space for what is a
// notification about one server, and it sat directly above the monitoring
// sidebar that an OAuth failure now opens (#2108) — the two things a user needs
// at once here are this affordance and those requests, so it must not push them
// around. `fixed`, not `sticky`, so scrolling the server list leaves it put.
//
// Centered, and deliberately WITHOUT an overlay. Every corner is spoken for —
// the toast stack owns bottom-right (see `main.tsx`), the right edge is the
// monitoring sidebar whose toolbar this would cover, and top-left is the
// Servers header — so anchoring it anywhere hides something. Centering is the
// one placement that reads as addressed to the whole window rather than
// attached to the wrong panel.
//
// The missing overlay is the point, not an omission: this is a notification,
// not a decision that must be made now. An OAuth failure opens the monitoring
// sidebar (#2108), and blocking the page would force a choice between reading
// those requests and keeping the affordance — dismissing is not free, since
// "Authorize again" also clears the stale OAuth state, which a plain reconnect
// does not do. So it floats above the page and leaves it usable.
//
// `transform` goes through `styles.root` for the same reason `zIndex` does:
// Mantine exposes neither as a style prop.
export const ReAuthBannerBar = Paper.withProps({
pos: "fixed",
top: "50%",
left: "50%",
w: 420,
bg: "var(--mantine-color-body)",
shadow: "xl",
radius: "md",
styles: { root: { transform: "translate(-50%, -50%)", zIndex: 200 } },
});

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 */
}
}

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");
});
});

Some files were not shown because too many files have changed in this diff Show more