Merge pull request #2149 from modelcontextprotocol/v2/chore/2127-app-decompose-phase-0
chore(web): move App.tsx's module-level block into files
This commit is contained in:
commit
78bad6a421
31 changed files with 1326 additions and 478 deletions
|
|
@ -1048,11 +1048,11 @@ Two consequences worth stating:
|
|||
|
||||
The web client keeps two grab-bag directories under `clients/web/src`, split by a real (now codified) rule — **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.
|
||||
|
||||
- **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`.) Carve-outs that are still `utils`:
|
||||
- **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`, `errorFormat`, `stepUp`, and the toast-id/formatter modules under `utils/toasts/`.) Carve-outs that are still `utils`:
|
||||
- _Domain types._ Pure **shared domain types plus their pure constructors/transforms** live here (`customHeaders` — `CustomHeader` + `headersToRecord`/`migrateFromLegacyAuth`, a shape staged for `ServerSettingsForm`, see `specification/v2_ux_interfaces_plan.md`, so it currently has no importer but its own test). There is no `types/` sub-bucket **inside** `lib`/`utils` — removing `lib/types/` is what the `customHeaders` move settles.
|
||||
- _Diagnostic logging._ `console.warn`/`console.error` does **not** count as a side effect for this rule — a validator that warns on bad input is still "pure" here (`sandbox-csp`, `jsonUtils`, `schemaUtils` all warn).
|
||||
- _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's _stateful runtime_, not merely importing from it.
|
||||
- **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads.)
|
||||
- **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads; `authToken` reads `window.location` and `sessionStorage`; `protocolReplay` re-issues a request through the live `InspectorClient`.)
|
||||
|
||||
The top-level **`src/types/`** is a sibling of both and is not the place for new domain types — it's now purely the home for ambient `.d.ts` module stubs (e.g. the `react-syntax-highlighter` shims wired through `tsconfig.app.json` `paths`). The last plain-`.ts` domain type there, the dead `navigation.ts` `InspectorTab`, was removed in #1785, so a pure domain type belongs in `utils/`, not `src/types/`.
|
||||
|
||||
|
|
|
|||
|
|
@ -81,11 +81,11 @@ exercises every rule.
|
|||
|
||||
Two grab-bag directories, split by one rule: **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.
|
||||
|
||||
- **`src/utils/`** — pure, side-effect-free functions (no DOM/`window`/`sessionStorage` I/O, no subsystem ownership), trivially unit-testable with no mocks. Examples: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`. Carve-outs that stay `utils`:
|
||||
- **`src/utils/`** — pure, side-effect-free functions (no DOM/`window`/`sessionStorage` I/O, no subsystem ownership), trivially unit-testable with no mocks. Examples: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`, `errorFormat`, `stepUp`, and the toast ids/formatters under `utils/toasts/`. Carve-outs that stay `utils`:
|
||||
- _Diagnostic logging_ (`console.warn`/`console.error`) doesn't count as a side effect.
|
||||
- _Importing from `@inspector/core`_ — neither a type-only import nor re-exporting core's pure functions/constants is a subsystem dependency (what makes a module `lib` is wrapping core's stateful runtime).
|
||||
- _Pure domain types + their constructors_ (`customHeaders`) — there is no `types/` sub-bucket inside `lib`/`utils`.
|
||||
- **`src/lib/`** — infrastructure / stateful adapters: modules that compose subsystems, wrap the `@inspector/core` **runtime**, or produce side effects. Examples: `environmentFactory`, `remoteOAuthStorage`, `oauthResume` (sessionStorage), `browserTabVisibility` (DOM listeners), `clearServerOAuthState`, `downloadFile`.
|
||||
- **`src/lib/`** — infrastructure / stateful adapters: modules that compose subsystems, wrap the `@inspector/core` **runtime**, or produce side effects. Examples: `environmentFactory`, `remoteOAuthStorage`, `oauthResume` (sessionStorage), `browserTabVisibility` (DOM listeners), `clearServerOAuthState`, `downloadFile`, `authToken` (`window.location` + `sessionStorage`), `protocolReplay` (re-issues through the live client).
|
||||
|
||||
The top-level `src/types/` is a separate sibling — ambient `.d.ts` module stubs, not the place for new domain types (the one that lingers there, dead `navigation.ts`, is tracked for removal in [#1785](https://github.com/modelcontextprotocol/inspector/issues/1785)).
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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'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's structuredContent doesn't match the
|
||||
tool'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'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).
|
||||
|
|
|
|||
|
|
@ -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'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's traffic
|
||||
</List.Item>
|
||||
</ToastCauseList>
|
||||
<ToastLinkButton onClick={onAdjust}>
|
||||
Adjust Network Log Size for this server
|
||||
</ToastLinkButton>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -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's structuredContent doesn't match the
|
||||
tool's outputSchema. The inspector renders it anyway, but strict MCP
|
||||
clients may not.
|
||||
</Text>
|
||||
<ToastLinkButton onClick={onViewDetails}>
|
||||
View validation details
|
||||
</ToastLinkButton>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
@ -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() },
|
||||
};
|
||||
87
clients/web/src/components/elements/Toasts/Toasts.test.tsx
Normal file
87
clients/web/src/components/elements/Toasts/Toasts.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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's nothing to open.
|
||||
</Text>
|
||||
<ToastLinkButton onClick={onViewDetails}>
|
||||
View error details
|
||||
</ToastLinkButton>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -30,8 +30,8 @@ import {
|
|||
extractMethod,
|
||||
extractResultType,
|
||||
extractSubscriptionId,
|
||||
isReplayableProtocolMethod,
|
||||
} from "../protocolUtils.js";
|
||||
import { isReplayableProtocolMethod } from "../../../utils/replayableProtocolMethods";
|
||||
|
||||
export interface ProtocolEntryProps {
|
||||
entry: MessageEntry;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 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 } },
|
||||
});
|
||||
|
|
@ -13,30 +13,6 @@ export function extractMethod(entry: MessageEntry): MessageMethod {
|
|||
return "response";
|
||||
}
|
||||
|
||||
/**
|
||||
* Request methods the Protocol Replay action can re-issue (client→server reads
|
||||
* and calls). Server→client requests (roots/list, sampling, elicitation) and
|
||||
* side-effectful methods (logging/setLevel, subscribe) are intentionally
|
||||
* excluded. Single source of truth: `ProtocolEntry` hides the Replay button for
|
||||
* anything not listed here, and App's `replayProtocolRequest` gates dispatch on
|
||||
* the same set.
|
||||
*/
|
||||
export const REPLAYABLE_PROTOCOL_METHODS: ReadonlySet<string> = new Set([
|
||||
"tools/call",
|
||||
"prompts/get",
|
||||
"resources/read",
|
||||
"tools/list",
|
||||
"prompts/list",
|
||||
"resources/list",
|
||||
"resources/templates/list",
|
||||
"tasks/list",
|
||||
"ping",
|
||||
]);
|
||||
|
||||
export function isReplayableProtocolMethod(method: string): boolean {
|
||||
return REPLAYABLE_PROTOCOL_METHODS.has(method);
|
||||
}
|
||||
|
||||
// --- Modern-era (2026-07-28) message vocabulary -----------------------------
|
||||
//
|
||||
// The modern era changes the over-the-wire conversation (spec §7.2–7.4): every
|
||||
|
|
|
|||
107
clients/web/src/lib/authToken.test.ts
Normal file
107
clients/web/src/lib/authToken.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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();
|
||||
Reflect.deleteProperty(window, INSPECTOR_API_TOKEN_GLOBAL);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
restoreSessionStorage();
|
||||
setSearch("");
|
||||
window.sessionStorage.clear();
|
||||
Reflect.deleteProperty(window, INSPECTOR_API_TOKEN_GLOBAL);
|
||||
});
|
||||
|
||||
it("prefers the injected global and persists it", () => {
|
||||
Reflect.set(window, 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", () => {
|
||||
Reflect.set(window, INSPECTOR_API_TOKEN_GLOBAL, "");
|
||||
setSearch(`?${KEY}=from-url`);
|
||||
|
||||
expect(getAuthToken()).toBe("from-url");
|
||||
|
||||
Reflect.set(window, 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 });
|
||||
Reflect.set(window, 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);
|
||||
});
|
||||
});
|
||||
61
clients/web/src/lib/authToken.ts
Normal file
61
clients/web/src/lib/authToken.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
187
clients/web/src/lib/protocolReplay.test.ts
Normal file
187
clients/web/src/lib/protocolReplay.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
messagesToLogEntries,
|
||||
replayProtocolRequest,
|
||||
type ReplayClient,
|
||||
} from "./protocolReplay";
|
||||
|
||||
describe("messagesToLogEntries", () => {
|
||||
const at = new Date("2026-01-01T00:00:00Z");
|
||||
|
||||
const notification = (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): MessageEntry => ({
|
||||
id: `n-${method}`,
|
||||
timestamp: at,
|
||||
direction: "notification",
|
||||
message: { jsonrpc: "2.0", method, params },
|
||||
});
|
||||
|
||||
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: at, params: { level: "info", data: "hello" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips requests and responses", () => {
|
||||
const request: MessageEntry = {
|
||||
id: "r-1",
|
||||
timestamp: at,
|
||||
direction: "request",
|
||||
// A request frame carrying the same method — only `direction` should
|
||||
// decide, so this must still be skipped.
|
||||
message: { jsonrpc: "2.0", id: 1, method: "notifications/message" },
|
||||
};
|
||||
const response: MessageEntry = {
|
||||
id: "r-2",
|
||||
timestamp: at,
|
||||
direction: "response",
|
||||
message: { jsonrpc: "2.0", id: 1, result: {} },
|
||||
};
|
||||
expect(messagesToLogEntries([request, response])).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a frame labelled a notification that carries no method", () => {
|
||||
// `direction` and the frame shape can disagree — the log records what
|
||||
// arrived, so the `"method" in message` guard has to hold on its own.
|
||||
const malformed: MessageEntry = {
|
||||
id: "m-1",
|
||||
timestamp: at,
|
||||
direction: "notification",
|
||||
message: { jsonrpc: "2.0", id: 1, result: {} },
|
||||
};
|
||||
expect(messagesToLogEntries([malformed])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replayProtocolRequest", () => {
|
||||
const tool: Tool = { name: "echo", inputSchema: { type: "object" } };
|
||||
|
||||
// Typed against `ReplayClient`, so the mock has to satisfy the same contract
|
||||
// the function declares rather than being cast into a whole InspectorClient.
|
||||
// Each mock is typed against the real method signature, so a drift in
|
||||
// `InspectorClient` surfaces here rather than being absorbed by a cast. None
|
||||
// of them needs a resolved value: `replayProtocolRequest` awaits the call and
|
||||
// discards the result, so what is asserted is the dispatch, not the payload.
|
||||
function makeClient() {
|
||||
return {
|
||||
callTool: vi.fn<ReplayClient["callTool"]>(),
|
||||
getPrompt: vi.fn<ReplayClient["getPrompt"]>(),
|
||||
readResource: vi.fn<ReplayClient["readResource"]>(),
|
||||
listTools: vi.fn<ReplayClient["listTools"]>(),
|
||||
listPrompts: vi.fn<ReplayClient["listPrompts"]>(),
|
||||
listResources: vi.fn<ReplayClient["listResources"]>(),
|
||||
listResourceTemplates: vi.fn<ReplayClient["listResourceTemplates"]>(),
|
||||
listRequestorTasks: vi.fn<ReplayClient["listRequestorTasks"]>(),
|
||||
ping: vi.fn<ReplayClient["ping"]>(),
|
||||
} satisfies ReplayClient;
|
||||
}
|
||||
|
||||
const replay = (
|
||||
client: ReturnType<typeof makeClient>,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
tools: Tool[] = [tool],
|
||||
) => replayProtocolRequest(client, 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, or carried a non-string", 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();
|
||||
});
|
||||
});
|
||||
129
clients/web/src/lib/protocolReplay.ts
Normal file
129
clients/web/src/lib/protocolReplay.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
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";
|
||||
// Type-only, so this creates no runtime edge into `components/` and the
|
||||
// `components → lib → utils` direction still holds: `LogEntryData` is the shape
|
||||
// the Logs screen renders, and this module exists to produce exactly that.
|
||||
import type { LogEntryData } from "../components/elements/LogEntry/LogEntry";
|
||||
import { isReplayableProtocolMethod } from "../utils/replayableProtocolMethods";
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* The slice of `InspectorClient` a replay actually reaches. Naming it — rather
|
||||
* than taking the whole client — is what lets a caller (and a test) supply a
|
||||
* value that satisfies the real contract instead of casting one in.
|
||||
*/
|
||||
export type ReplayClient = Pick<
|
||||
InspectorClient,
|
||||
| "callTool"
|
||||
| "getPrompt"
|
||||
| "readResource"
|
||||
| "listTools"
|
||||
| "listPrompts"
|
||||
| "listResources"
|
||||
| "listResourceTemplates"
|
||||
| "listRequestorTasks"
|
||||
| "ping"
|
||||
>;
|
||||
|
||||
export async function replayProtocolRequest(
|
||||
client: ReplayClient,
|
||||
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 */
|
||||
}
|
||||
}
|
||||
66
clients/web/src/utils/errorFormat.test.ts
Normal file
66
clients/web/src/utils/errorFormat.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
37
clients/web/src/utils/errorFormat.ts
Normal file
37
clients/web/src/utils/errorFormat.ts
Normal 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);
|
||||
}
|
||||
37
clients/web/src/utils/replayableProtocolMethods.test.ts
Normal file
37
clients/web/src/utils/replayableProtocolMethods.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isReplayableProtocolMethod,
|
||||
REPLAYABLE_PROTOCOL_METHODS,
|
||||
} from "./replayableProtocolMethods";
|
||||
|
||||
describe("isReplayableProtocolMethod", () => {
|
||||
it("admits every client→server read and call in the set", () => {
|
||||
for (const method of REPLAYABLE_PROTOCOL_METHODS) {
|
||||
expect(isReplayableProtocolMethod(method)).toBe(true);
|
||||
}
|
||||
expect([...REPLAYABLE_PROTOCOL_METHODS].sort()).toEqual([
|
||||
"ping",
|
||||
"prompts/get",
|
||||
"prompts/list",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"resources/templates/list",
|
||||
"tasks/list",
|
||||
"tools/call",
|
||||
"tools/list",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes server→client requests and side-effectful methods", () => {
|
||||
for (const method of [
|
||||
"roots/list",
|
||||
"sampling/createMessage",
|
||||
"elicitation/create",
|
||||
"logging/setLevel",
|
||||
"resources/subscribe",
|
||||
"initialize",
|
||||
]) {
|
||||
expect(isReplayableProtocolMethod(method)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue