Merge branch 'v2/main' into v2/fix/1941-cli-vitest-teardown-crash

This commit is contained in:
Cliff Hall 2026-08-16 17:43:24 -04:00 • committed by GitHub
commit 138e07fab2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 6840 additions and 1277 deletions

View file

@ -100,7 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo
## Gates and PR hygiene
- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating.
- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency.
- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency.
- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged.
- **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`.
- **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch.

File diff suppressed because one or more lines are too long

View file

@ -294,14 +294,14 @@ Each client self-validates from its own folder; the root scripts chain them. The
| Script | What it does |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. |
| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). |
| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. |
| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. |
| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. |
| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. |
| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`.
| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. |
| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). |

View file

@ -507,6 +507,9 @@ function renderApp(servers: Record<string, TuiServer>) {
async function mount(servers: Record<string, TuiServer>) {
const r = renderApp(servers);
await tick();
// The mount commit's effect flush can still be queued behind this tick, and
// this write is the one that absorbs the dropped first keypress.
await settleInputHandlers();
r.stdin.write("x");
await tick();
return r;
@ -532,6 +535,11 @@ const ENTER = "\r";
*/
async function press(r: RenderResult, keys: string[]) {
for (const k of keys) {
// Same hazard `waitUntil` guards against, at the other end: a caller can
// reach here on a turn that still has React's passive-effect flush queued
// (e.g. straight after a plain `tick`, or after an earlier key committed a
// render), so settle before every write rather than only after a poll.
await settleInputHandlers();
r.stdin.write(k);
await tick();
await tick();
@ -552,9 +560,29 @@ async function press(r: RenderResult, keys: string[]) {
*/
const POLL_TRIES = 100;
/**
* One check-phase turn, queued BEHIND React's already-scheduled passive-effect
* flush. A frame observed by a poll predicate is written during React's
* COMMIT, but ink re-arms its useInput listeners in the passive-effect flush
* React schedules (via setImmediate in Node) during that same commit. Node's
* event loop runs the timers phase before the check phase, so a 25ms poll
* tick can observe the new frame and let the test write the next keypress
* BEFORE that flush has run — the key is then dispatched to the previous
* commit's stale useInput closures (where e.g. pendingStepUp is still null)
* and silently swallowed (#1942). Yielding one setImmediate turn after the
* predicate passes sequences the next stdin write after the flush (FIFO
* within the check queue), so "frame visible" once again implies "input
* handlers armed".
*/
const settleInputHandlers = () =>
new Promise((resolve) => setImmediate(resolve));
async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) {
for (let i = 0; i < tries; i++) {
if (predicate()) return;
if (predicate()) {
await settleInputHandlers();
return;
}
await tick();
}
}
@ -626,6 +654,23 @@ afterEach(() => {
while (mounted.length) mounted.pop()?.unmount();
});
// Pins the synchronization contract the OAuth step-up assertions depend on
// (#1942). The `setImmediate` sentinel below stands in for React's pending
// passive-effect flush — the turn where ink re-arms `useInput`. If `waitUntil`
// ever returns without yielding a check-phase turn, the sentinel has not run
// and this fails, instead of the regression resurfacing as a differently-named
// flaky OAuth test under coverage instrumentation.
describe("test helpers", () => {
it("waitUntil settles input handlers before resolving", async () => {
let flushed = false;
setImmediate(() => {
flushed = true;
});
await waitUntil(() => true);
expect(flushed).toBe(true);
});
});
describe("App (foundation)", () => {
it("renders the server list with the MCP Servers header", async () => {
const r = renderApp(stdioServer());

View file

@ -598,6 +598,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = {
serverInfo: undefined,
instructions: undefined,
excludedTools: [],
malformedListItems: [],
appRendererClient: null,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),

View file

@ -63,6 +63,7 @@ import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedReq
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
import {
cleanRoots,
oauthAuthorizationParamsFromSettings,
serializeMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
@ -163,6 +164,7 @@ import {
import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile";
import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs";
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
import { visibleMalformedListItems } from "./utils/malformedListReport";
import {
parseDeepLink,
deepLinkConfigEquals,
@ -990,6 +992,7 @@ function App() {
protocolEra,
discoverResult,
excludedTools,
malformedListItems,
lastError,
} = useInspectorClient(inspectorClient);
const {
@ -1055,6 +1058,14 @@ function App() {
setPaginatedListsOverride(null);
}, [persistedPaginatedLists, activeServerId]);
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
// The malformed-entry report is written by the aggregate walk's salvage. In
// paginated mode the tools/prompts/resources panels render the paged stores
// instead, which never write or clear it — so it would linger above a page it
// does not describe. See `visibleMalformedListItems` (#1909 + #1721).
const shownMalformedListItems = useMemo(
() => visibleMalformedListItems(malformedListItems, paginatedLists),
[malformedListItems, paginatedLists],
);
const connected = connectionStatus === "connected";
const toolsPagination = usePaginatedList({
connected,
@ -2301,11 +2312,15 @@ function App() {
.map((m) => [m.key, m.value]),
)
: undefined;
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
savedSettings.enterpriseManaged)
? {
...(savedSettings.oauthClientId && {
@ -2317,6 +2332,9 @@ function App() {
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),
@ -4376,6 +4394,7 @@ function App() {
latencyMs={latencyMs}
tools={tools}
excludedTools={excludedTools}
malformedListItems={shownMalformedListItems}
prompts={prompts}
resources={resources}
resourceTemplates={resourceTemplates}

View file

@ -10,6 +10,17 @@ describe("CopyButton", () => {
expect(screen.getByText("⎘")).toBeInTheDocument();
});
it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
const { unmount } = renderWithMantine(<CopyButton value="hello" />);
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
unmount();
renderWithMantine(<CopyButton value="hello" label="ID Token" />);
expect(
screen.getByRole("button", { name: "Copy ID Token" }),
).toBeInTheDocument();
});
it("triggers a click without throwing when clipboard is unavailable", async () => {
const user = userEvent.setup();
renderWithMantine(<CopyButton value="hello" />);

View file

@ -11,6 +11,15 @@ export interface CopyButtonProps {
* rows (e.g. beside a Code block). Icon size is unchanged.
*/
flush?: boolean;
/**
* Names what is being copied, for the accessible label only ("Copy ID
* Token"). Set it when more than one CopyButton can appear in the same
* region, so button-by-button screen-reader navigation can tell them apart;
* a lone control needs no qualifier. The visible tooltip stays "Copy" either
* way, and the accessible name keeps "Copy"/"Copied" as its first word so it
* still contains the visible text (WCAG 2.5.3).
*/
label?: string;
}
const CopyActionIcon = ActionIcon.withProps({
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
fz: 24,
});
export function CopyButton({ value, flush = false }: CopyButtonProps) {
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
const qualifier = label ? ` ${label}` : "";
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
<CopyActionIcon
color={copied ? "green" : "var(--inspector-text-primary)"}
onClick={copy}
aria-label={copied ? "Copied" : "Copy"}
aria-label={(copied ? "Copied" : "Copy") + qualifier}
{...(flush && { p: 0, h: "auto", w: "auto" })}
>
{copied ? "\u2713" : "\u2398"}

View file

@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MalformedItemsWarning } from "./MalformedItemsWarning";
const meta: Meta<typeof MalformedItemsWarning> = {
title: "Elements/MalformedItemsWarning",
component: MalformedItemsWarning,
};
export default meta;
type Story = StoryObj<typeof MalformedItemsWarning>;
/** The failure this was built for: a PHP server whose empty `annotations`
* object reaches the wire as `[]` (#1909). */
export const OneDroppedTemplate: Story = {
args: {
method: "resources/templates/list",
what: "resource templates",
items: [
{
method: "resources/templates/list",
index: 1,
label: "array_annotations",
reason: "annotations: Invalid input: expected object, received array",
},
],
},
};
export const SeveralDropped: Story = {
args: {
method: "tools/list",
what: "tools",
items: [
{
method: "tools/list",
index: 0,
label: "get_weather",
reason: "inputSchema: Invalid input: expected object, received string",
},
{
method: "tools/list",
index: 4,
label: "add",
reason: "name: Invalid input: expected string, received number",
},
],
},
};
/** An entry too broken to carry a name is reported by its position. */
export const UnlabeledEntry: Story = {
args: {
method: "prompts/list",
what: "prompts",
items: [
{
method: "prompts/list",
index: 2,
reason: "Invalid input: expected object, received null",
},
],
},
};
/** Entries belonging to another list method are ignored — the same set is
* passed to every panel, and each selects its own. */
export const OtherMethodOnly: Story = {
args: {
method: "resources/templates/list",
what: "resource templates",
items: [
{
method: "tools/list",
index: 0,
label: "get_weather",
reason: "inputSchema: Invalid input: expected object, received string",
},
],
},
};
/** The resting state: a conforming server, nothing rendered. */
export const Conforming: Story = {
args: {
method: "resources/templates/list",
what: "resource templates",
items: [],
},
};

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { screen } from "@testing-library/react";
import type { MalformedListItem } from "@inspector/core/mcp";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { MalformedItemsWarning } from "./MalformedItemsWarning";
const TEMPLATE_ENTRY: MalformedListItem = {
method: "resources/templates/list",
index: 1,
label: "array_annotations",
reason: "annotations: Invalid input: expected object, received array",
};
const TOOL_ENTRY: MalformedListItem = {
method: "tools/list",
index: 0,
label: "get_weather",
reason: "inputSchema: Invalid input: expected object, received string",
};
describe("MalformedItemsWarning", () => {
it("names the dropped entry and why it was dropped", () => {
renderWithMantine(
<MalformedItemsWarning
items={[TEMPLATE_ENTRY]}
method="resources/templates/list"
what="resource templates"
/>,
);
expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument();
expect(screen.getByText("array_annotations")).toBeInTheDocument();
expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument();
});
it("renders nothing when the server was conforming", () => {
renderWithMantine(
<MalformedItemsWarning
items={[]}
method="resources/templates/list"
what="resource templates"
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("ignores entries belonging to another list method", () => {
// Every panel receives the whole set and selects its own — a tools failure
// must not surface above the templates list.
renderWithMantine(
<MalformedItemsWarning
items={[TOOL_ENTRY]}
method="resources/templates/list"
what="resource templates"
/>,
);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("get_weather")).not.toBeInTheDocument();
});
it("pluralizes the count and lists every entry", () => {
renderWithMantine(
<MalformedItemsWarning
items={[
TOOL_ENTRY,
{ ...TOOL_ENTRY, index: 4, label: "add", reason: "name: bad" },
TEMPLATE_ENTRY,
]}
method="tools/list"
what="tools"
/>,
);
expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument();
expect(screen.getByText("get_weather")).toBeInTheDocument();
expect(screen.getByText("add")).toBeInTheDocument();
// The template entry belongs to the other panel.
expect(screen.queryByText("array_annotations")).not.toBeInTheDocument();
});
it("falls back to the position when the entry had no usable name", () => {
renderWithMantine(
<MalformedItemsWarning
items={[
{
method: "prompts/list",
index: 2,
reason: "Invalid input: expected object, received null",
},
]}
method="prompts/list"
what="prompts"
/>,
);
expect(screen.getByText("entry 2")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,99 @@
import { Alert, Code, List, ScrollArea, Text } from "@mantine/core";
import type { MalformedListItem } from "@inspector/core/mcp";
export interface MalformedItemsWarningProps {
/**
* Every malformed entry the client is currently reporting, across all list
* methods — pass the hook's set straight through; the component selects its
* own by {@link method}.
*/
items: MalformedListItem[];
/** The list method this panel renders, e.g. `"resources/templates/list"`. */
method: string;
/** What the entries are, for the alert title — e.g. "resource templates". */
what: string;
}
// Yellow, not red: unlike ListLoadError this is not a failure — the list DID
// load and is rendered below. It reports what the server sent that the
// Inspector could not accept.
const WarningAlert = Alert.withProps({
color: "yellow",
variant: "light",
});
// The entry list stays compact; the reasons are the payload, so they get the
// monospace treatment while the labels stay in body type.
const EntryList = List.withProps({
size: "xs",
spacing: 4,
withPadding: true,
});
const EntryReason = Code.withProps({
variant: "wrapping",
});
// A server can be wrong about many entries at once; cap the height so a badly
// broken list can't push the list itself off the sidebar.
const EntryScroll = ScrollArea.withProps({
mah: 180,
type: "auto",
});
const SummaryText = Text.withProps({
size: "xs",
mb: 4,
});
// The entry's identity, emphasized against its monospaced reason.
const EntryLabel = Text.withProps({
component: "span",
size: "xs",
fw: 600,
});
/** `label` when the entry had one, else its position in the response. */
function describeEntry(item: MalformedListItem): string {
return item.label ?? `entry ${item.index}`;
}
/**
* "Some entries were dropped" — the visible half of the #1909 salvage.
*
* The client keeps the valid entries of a list whose result failed schema
* validation, rather than losing the whole list to one bad entry. Dropping them
* silently would trade one lie for another, so what was dropped, and why, is
* reported here beside the list that rendered without them.
*/
export function MalformedItemsWarning({
items,
method,
what,
}: MalformedItemsWarningProps) {
const mine = items.filter((item) => item.method === method);
if (mine.length === 0) return null;
return (
<WarningAlert
title={`${mine.length} malformed ${
mine.length === 1 ? "entry" : "entries"
} dropped`}
>
<SummaryText>
The server returned {what} that don&apos;t match the MCP schema. The
rest of the list is shown below.
</SummaryText>
<EntryScroll>
<EntryList>
{mine.map((item) => (
<List.Item key={`${item.method}:${item.index}`}>
<EntryLabel>{describeEntry(item)}</EntryLabel>{" "}
<EntryReason>{item.reason}</EntryReason>
</List.Item>
))}
</EntryList>
</EntryScroll>
</WarningAlert>
);
}

View file

@ -3,7 +3,7 @@ import type {
InitializeResult,
} from "@modelcontextprotocol/client";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { expect, userEvent, within } from "storybook/test";
import {
ConnectionInfoContent,
SERVER_INFO_NOT_REPORTED_LABEL,
@ -161,3 +161,37 @@ export const WithOAuth: Story = {
},
},
};
// An authorization server that also returned an `id_token` (#2019). Shown for
// inspection only — decoding it is display-only, and it is never used as a
// credential.
export const WithOAuthIdToken: Story = {
args: {
initializeResult: {
protocolVersion: "2025-03-26",
serverInfo: { name: "OIDC-fronted Server", version: "3.0.0" },
capabilities: { tools: { listChanged: true } },
},
clientCapabilities: { roots: { listChanged: true } },
transport: "streamable-http",
oauth: {
protocol: "standard",
authorized: true,
authUrl: "https://auth.example.com/oauth2/authorize",
scopes: ["openid", "email", "read"],
accessToken: "opaque-access-token",
// header {"alg":"none"} / payload {"sub":"user-42","email":"a@b.test"}
idToken:
"eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwiZW1haWwiOiJhQGIudGVzdCJ9.",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("ID Token")).toBeInTheDocument();
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
await userEvent.click(
canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
);
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
},
};

View file

@ -473,6 +473,95 @@ describe("ConnectionInfoContent", () => {
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
});
it("renders the ID Token row when the token set carries one", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
accessToken: "token-123",
idToken: "id-token-456",
}}
/>,
);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText("ID Token")).toBeInTheDocument();
expect(screen.getByText("id-token-456")).toBeInTheDocument();
});
it("gives the two token rows' controls distinct accessible names", () => {
// Both rows carry a copy control, and both tokens here are JWTs so both
// carry a decode toggle. Screen-reader button navigation has only the
// accessible name to go on, so the four must not collide (#2019 review).
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
accessToken: jwt,
idToken: jwt,
}}
/>,
);
const names = screen
.getAllByRole("button")
.map((button) => button.getAttribute("aria-label") ?? button.textContent)
.filter((name): name is string => Boolean(name));
expect(new Set(names).size).toBe(names.length);
for (const name of [
"Copy Access Token",
"Copy ID Token",
"Decode JWT for Access Token",
"Decode JWT for ID Token",
]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
it("renders the ID Token row on its own when there is no access token", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
idToken: "id-token-456",
}}
/>,
);
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
expect(screen.getByText("ID Token")).toBeInTheDocument();
});
it("omits the ID Token row when only an access token is present", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
accessToken: "token-123",
}}
/>,
);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
});
it("does not render OAuth section when oauth prop is omitted", () => {

View file

@ -24,7 +24,7 @@ import {
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { EraBadge } from "../../elements/EraBadge/EraBadge";
import { isModernEra } from "../../elements/EraBadge/eraUtils";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
import { OAuthTokenField } from "./OAuthTokenField";
export interface OAuthDetails {
protocol: "standard" | "ema";
@ -34,6 +34,13 @@ export interface OAuthDetails {
authUrl?: string;
scopes?: string[];
accessToken?: string;
/**
* OIDC `id_token` from the stored token set, when the authorization server
* returned one. Shown for inspection only — the MCP authorization spec is
* plain OAuth 2.1 and the Inspector never treats this as a credential
* (#2019). Absent when the token set carries none, so no empty row renders.
*/
idToken?: string;
/** EMA only — install-level IdP session for legs 1–2. */
idpSession?: "none" | "logged_in" | "expired";
}
@ -370,20 +377,27 @@ export function ConnectionInfoContent({
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken ? (
<OAuthAccessTokenField
accessToken={oauth.accessToken}
{oauth.accessToken && (
<OAuthTokenField
label="Access Token"
token={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
) : (
onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)
)}
{/* Viewer only — an `id_token` the AS happened to return, decoded
on request. It carries no clear action: clearing OAuth state is
one action for the whole token set, owned by the access-token
row (or the standalone button below when there is none). */}
{oauth.idToken && (
<OAuthTokenField label="ID Token" token={oauth.idToken} />
)}
{!oauth.accessToken && onClearOAuth && (
<Flex justify="flex-end">
<ClearOAuthButton onClick={onClearOAuth}>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</ClearOAuthButton>
</Flex>
)}
</Stack>
</Stack>

View file

@ -1,90 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
describe("OAuthAccessTokenField", () => {
it("renders token with copy control beside the content", () => {
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
});
it("replaces raw token with decoded JSON and restores on toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show token" }));
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
});
it("omits decode toggle for opaque tokens", () => {
renderWithMantine(
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
);
expect(
screen.queryByRole("button", { name: "Decode JWT" }),
).not.toBeInTheDocument();
});
it("copies decoded JSON while decode view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"sub": "user"'),
);
expect(writeText).not.toHaveBeenCalledWith(jwt);
});
it("copies the raw token while token view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(jwt);
});
it("renders clear action on the access token header row", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(
<OAuthAccessTokenField
accessToken="opaque-access-token-value"
onClear={onClear}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>,
);
await user.click(
screen.getByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
);
expect(onClear).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,139 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
import { OAuthTokenField } from "./OAuthTokenField";
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
describe("OAuthTokenField", () => {
it("renders token with copy control beside the content", () => {
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Copy Access Token" }),
).toBeInTheDocument();
});
it("replaces raw token with decoded JSON and restores on toggle", async () => {
const user = userEvent.setup();
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
);
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Show token for Access Token" }),
);
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
});
it("omits decode toggle for opaque tokens", () => {
renderWithMantine(
<OAuthTokenField
label="Access Token"
token="opaque-access-token-value"
/>,
);
expect(
screen.queryByRole("button", { name: "Decode JWT for Access Token" }),
).not.toBeInTheDocument();
});
it("copies decoded JSON while decode view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
await user.click(
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
);
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"sub": "user"'),
);
expect(writeText).not.toHaveBeenCalledWith(jwt);
});
it("copies the raw token while token view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
expect(writeText).toHaveBeenCalledWith(jwt);
});
it("renders clear action on the access token header row", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(
<OAuthTokenField
label="Access Token"
token="opaque-access-token-value"
onClear={onClear}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>,
);
await user.click(
screen.getByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
);
expect(onClear).toHaveBeenCalledTimes(1);
});
it("labels the row from the label prop and decodes an ID token", async () => {
const user = userEvent.setup();
// header {"alg":"none"} / payload {"sub":"user"} — same shape an AS returns
// for an `id_token`; the decode path is identical to the access token's.
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
expect(screen.getByText("ID Token")).toBeInTheDocument();
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
);
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
});
it("qualifies the decode and copy accessible names with the row label", () => {
// Two rows can render simultaneously (Access Token + ID Token), so the
// controls must not share accessible names — see #2019 review.
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
expect(
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Copy ID Token" }),
).toBeInTheDocument();
});
it("omits the clear action when no handler is given", () => {
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
expect(
screen.queryByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,138 @@
import { Fragment, useMemo, useState } from "react";
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
/**
* One labelled OAuth token row: the raw value, a copy control, and — when the
* value has the three-segment JWT shape — an in-place "Decode JWT" toggle.
*
* Shared by the Access Token and ID Token rows (#2019). The decode is
* display-only (`decodeJwtPayload` does no signature verification), so this is
* a viewer: neither token is treated as a credential here.
*/
export interface OAuthTokenFieldProps {
/** Row caption, e.g. "Access Token" / "ID Token". */
label: string;
token: string;
onClear?: () => void;
clearLabel?: string;
}
const CaptionRow = Flex.withProps({
justify: "space-between",
align: "center",
gap: "sm",
wrap: "nowrap",
});
const Caption = Text.withProps({ size: "sm" });
const Toolbar = Flex.withProps({
gap: 4,
align: "center",
wrap: "nowrap",
});
const ToolbarButton = Button.withProps({
variant: "subtle",
size: "compact-xs",
});
const TokenRow = Flex.withProps({
align: "flex-start",
gap: 4,
wrap: "nowrap",
});
const TokenColumn = Flex.withProps({
flex: 1,
miw: 0,
direction: "column",
});
const TokenCode = Code.withProps({
block: true,
py: "xs",
ps: "xs",
pe: 0,
variant: "wrapping",
});
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
function JwtTokenText({ token }: { token: string }) {
const parts = token.split(".");
return (
<>
{parts.map((part, index) => (
<Fragment key={index}>
{index > 0 && "."}
{part}
</Fragment>
))}
</>
);
}
export function OAuthTokenField({
label,
token,
onClear,
clearLabel = "Clear",
}: OAuthTokenFieldProps) {
const [showDecoded, setShowDecoded] = useState(false);
const isJwt = isJwtFormat(token);
const jwtDecoded = useMemo(
() => (isJwt ? decodeJwtPayload(token) : undefined),
[token, isJwt],
);
const decodedText = useMemo(() => {
if (!jwtDecoded) return undefined;
return JSON.stringify(
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
null,
2,
);
}, [jwtDecoded]);
const copyValue = showDecoded && decodedText ? decodedText : token;
return (
<Stack gap="xs">
<CaptionRow>
<Caption>{label}</Caption>
<Toolbar>
{jwtDecoded && (
<ToolbarButton
onClick={() => setShowDecoded((open) => !open)}
aria-pressed={showDecoded}
aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`}
>
{showDecoded ? "Show token" : "Decode JWT"}
</ToolbarButton>
)}
{onClear && (
<ToolbarButton color="red" onClick={onClear}>
{clearLabel}
</ToolbarButton>
)}
</Toolbar>
</CaptionRow>
<TokenRow>
<TokenColumn>
<TokenCode>
{showDecoded && decodedText ? (
decodedText
) : isJwt ? (
<JwtTokenText token={token} />
) : (
token
)}
</TokenCode>
</TokenColumn>
<CopyButton value={copyValue} flush label={label} />
</TokenRow>
</Stack>
);
}

View file

@ -34,6 +34,39 @@ describe("oauthDetailsFromConnectionState", () => {
});
});
it("surfaces an id_token when the token set carries one", () => {
const state: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "https://mcp.example.com/mcp",
tokens: {
access_token: "tok",
token_type: "Bearer",
id_token: "header.payload.signature",
},
};
expect(oauthDetailsFromConnectionState(state)).toEqual({
protocol: "standard",
authorized: true,
accessToken: "tok",
idToken: "header.payload.signature",
});
});
it("omits idToken when the token set carries none", () => {
const state: OAuthConnectionState = {
authorized: true,
protocol: "standard",
serverUrl: "https://mcp.example.com/mcp",
tokens: { access_token: "tok", token_type: "Bearer" },
};
expect(oauthDetailsFromConnectionState(state)).not.toHaveProperty(
"idToken",
);
});
it("includes EMA idp session when present", () => {
const state: OAuthConnectionState = {
authorized: false,

View file

@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState(
...(state.tokens?.access_token && {
accessToken: state.tokens.access_token,
}),
// Surfaced for inspection when the AS returned one; omitted entirely
// otherwise so Connection Info renders no empty row (#2019).
...(state.tokens?.id_token && {
idToken: state.tokens.id_token,
}),
...(state.protocol === "ema" &&
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
};

View file

@ -3,6 +3,8 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { Prompt } from "@modelcontextprotocol/client";
import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator";
import { ListLoadError } from "../../elements/ListLoadError/ListLoadError";
import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning";
import type { MalformedListItem } from "@inspector/core/mcp";
import {
ListPaginationControls,
type ListPaginationControlsProps,
@ -41,6 +43,12 @@ export interface PromptControlsProps {
* A failed list load, surfaced above the list instead of leaving the panel
* empty (which reads as "this server has none") (#1953).
*/
/**
* Entries the client dropped from this list because they failed the MCP
* schema. Rendered as a warning above the list, which still shows the rest
* (#1909).
*/
malformedListItems?: MalformedListItem[];
loadError?: Error | null;
/** Pagination controls (#1721). */
pagination: ListPaginationControlsProps;
@ -54,6 +62,7 @@ export function PromptControls({
searchText = "",
listChanged,
onRefreshList,
malformedListItems = [],
loadError,
pagination,
onSearchChange,
@ -83,6 +92,11 @@ export function PromptControls({
/>
<ListPaginationControls {...pagination} />
<ListLoadError error={loadError} what="prompts" onRetry={onRefreshList} />
<MalformedItemsWarning
items={malformedListItems}
method="prompts/list"
what="prompts"
/>
<ListScroll viewportRef={viewportRef}>
<Stack gap="xs">
{filteredPrompts.map((prompt) => (

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