Merge branch 'v2/main' into v2/fix/1942-tui-coverage-flake

This commit is contained in:
Cliff Hall 2026-08-16 17:40:11 -04:00 • committed by GitHub
commit 2f0aa24726
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 10868 additions and 1605 deletions

View file

@ -213,4 +213,173 @@ describe("schemaToForm", () => {
);
expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" });
});
// #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()`
// has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch —
// so before normalization every one of these degraded to a plain text field.
describe("nullable unions", () => {
it("renders a select for an anyOf string-enum|null argument", () => {
const form = schemaToForm(
{
properties: {
direction: {
anyOf: [
{ type: "string", enum: ["envio", "recebimento"] },
{ type: "null" },
],
},
},
},
"nullableEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "direction",
type: "select",
options: [
{ label: "envio", value: "envio" },
{ label: "recebimento", value: "recebimento" },
],
});
});
it("maps the remaining nullable scalar branches to their typed fields", () => {
const form = schemaToForm(
{
properties: {
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
quantity: {
anyOf: [
{ type: "integer", minimum: 1, maximum: 9 },
{ type: "null" },
],
},
ratio: { anyOf: [{ type: "number" }, { type: "null" }] },
express: { anyOf: [{ type: "boolean" }, { type: "null" }] },
},
},
"nullableScalars",
);
const fields = form.sections[0]!.fields;
expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([
{ name: "reference", type: "string" },
{ name: "quantity", type: "integer" },
{ name: "ratio", type: "float" },
{ name: "express", type: "boolean" },
]);
// The branch's own constraints are hoisted along with its type.
expect(fields[1]).toMatchObject({ min: 1, max: 9 });
});
it("renders a select for a nullable array-of-enum argument", () => {
const form = schemaToForm(
{
properties: {
tags: {
anyOf: [
{ type: "array", items: { enum: ["a", "b"] } },
{ type: "null" },
],
},
},
},
"nullableArrayEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "a", value: "a" },
{ label: "b", value: "b" },
],
});
});
// With the null left in the list, the all-strings check would reject the
// enum and this would degrade to a plain text field.
it("renders a select for a type: [string, null] enum, null stripped", () => {
const form = schemaToForm(
{
properties: {
direction: {
type: ["string", "null"],
enum: ["envio", "recebimento", null],
},
},
},
"typeArrayNullableEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
type: "select",
options: [
{ label: "envio", value: "envio" },
{ label: "recebimento", value: "recebimento" },
],
});
});
it("collapses the type: [T, null] encoding too", () => {
const form = schemaToForm(
{
properties: {
note: { type: ["string", "null"] },
flag: { type: ["boolean", "null"] },
},
},
"typeArrayNull",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "note", type: "string" },
{ name: "flag", type: "boolean" },
]);
});
// ink-form hands a select's stringified value straight back, so a numeric
// enum routed to a select would submit "1" for 1. The typed field loses the
// enum constraint but keeps the value's type — the safer loss.
it("routes a nullable numeric enum to its typed field, not a select", () => {
const form = schemaToForm(
{
properties: {
level: {
anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }],
},
ratio: {
anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }],
},
},
},
"numericEnum",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "level", type: "integer" },
{ name: "ratio", type: "float" },
]);
});
it("routes a plain numeric enum to its typed field too", () => {
const form = schemaToForm(
{ properties: { level: { type: "integer", enum: [1, 2] } } },
"plainNumericEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" });
});
it("leaves a union of two real types as a plain string field", () => {
const form = schemaToForm(
{
properties: {
mixed: { anyOf: [{ type: "string" }, { type: "number" }] },
},
},
"realUnion",
);
expect(form.sections[0]!.fields[0]).toMatchObject({
name: "mixed",
type: "string",
});
});
});
});

View file

@ -3,10 +3,19 @@
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
/** Minimal JSON Schema property shape used when building tool parameter forms */
interface JsonSchemaProperty {
type?: string;
/**
* An array here is the `["string", "null"]` nullable encoding, which
* {@link normalizeNullableUnion} collapses to a single name before the
* dispatch below reads it.
*/
type?: string | string[];
title?: string;
enum?: unknown[];
/** Non-standard legacy support: titles for enum values */
@ -15,6 +24,8 @@ interface JsonSchemaProperty {
minimum?: number;
maximum?: number;
default?: unknown;
/** Present on a nullable union; see {@link normalizeNullableUnion}. */
anyOf?: readonly unknown[];
}
/**
@ -68,9 +79,16 @@ export function schemaToForm(
// `properties` values are `unknown` (the SDK schema admits anything), so
// guard before treating a value as a schema object — a malformed server
// schema with e.g. `properties: { foo: null }` must not throw on `.title`.
const property = (
typeof prop === "object" && prop !== null ? prop : {}
) as JsonSchemaProperty;
// Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`)
// before dispatching. Every branch below reads a single `type` string, and
// for an enum the `enum` keyword sits on the union's surviving branch — so
// without this an argument declared with Zod's `.nullish()` loses its
// select and degrades to a plain text field (#2015, the TUI twin of #1928).
const property = normalizeNullableUnion(
(typeof prop === "object" && prop !== null
? prop
: {}) as JsonSchemaProperty,
);
const baseField = {
name: key,
label: property.title || key,
@ -90,12 +108,17 @@ export function schemaToForm(
...baseField,
options: toSelectOptions(property.items.enum, property.items.enumNames),
} as FormField;
} else if (property.enum) {
// Single select
} else if (isStringEnum(property.enum)) {
// Single select. Gated on the members being strings because
// `toSelectOptions` stringifies them and ink-form hands the string
// straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate
// the schema. A typed non-string enum falls through to its typed field
// below, which loses the enum constraint but keeps the value's type —
// the safer of the two losses.
field = {
type: "select",
...baseField,
options: toSelectOptions(property.enum, property.enumNames),
options: toSelectOptions(property.enum ?? [], property.enumNames),
} as FormField;
} else {
// Map JSON Schema types to ink-form types

View file

@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer
## Host binding & the origin allow-list
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error.
**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice.
The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off.
@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.

View file

@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean {
return v === "true" || v === "1";
}
/**
* The default bind host: the IPv4 loopback **address**, deliberately not the
* name `localhost`.
*
* `server.listen(port, host)` resolves a name through `dns.lookup` and binds the
* **single** address it gets back. Since Node 17 the default result order is
* `verbatim`, so Node takes whatever the resolver returns first rather than
* preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts`
* (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost`
* therefore binds IPv6 loopback **only**, and every IPv4 client is refused.
*
* That is invisible on a desktop, where the browser resolves `localhost` the
* same way and lands on `::1` too. It breaks wherever the connection is made by
* something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port
* forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck.
* Binding the address directly removes the resolver from the decision, and the
* loopback origin allow-list already covers `localhost`, `127.0.0.1`, and
* `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers
* fall back across address families; a Node `listen` does not).
*/
export const DEFAULT_BIND_HOST = "127.0.0.1";
/**
* Resolve the bind hostname from `env` (default `process.env`), defaulting to
* `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their
* legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly
* enabled — the published Docker image sets it, since a container must bind
* `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than
* silently binding wide open. The returned value is trimmed and de-bracketed
* (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection,
* `listen()`, and the origin list all consume the same value; `formatHostForUrl`
* re-adds the brackets wherever a URL is built.
* {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` /
* empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is
* explicitly enabled — the published Docker image sets it, since a container
* must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly)
* rather than silently binding wide open. The returned value is trimmed and
* de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so
* detection, `listen()`, and the origin list all consume the same value;
* `formatHostForUrl` re-adds the brackets wherever a URL is built.
*
* An explicit `HOST=localhost` is honored as typed — the single-family bind
* described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours.
*/
export function resolveBindHostname(
env: NodeJS.ProcessEnv = process.env,
): string {
const host = (env.HOST ?? "localhost").trim();
const host = (env.HOST ?? DEFAULT_BIND_HOST).trim();
if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) {
// Show the resolved address when it differs from the typed spelling — the
// guard now catches forms the resolver folds to the wildcard (a fullwidth

View file

@ -11,13 +11,14 @@ import {
canonicalUrlHost,
isAllInterfacesHost,
} from "../../../core/node/hostUrl.ts";
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export interface SandboxControllerOptions {
/** Port to bind (0 = dynamic). */
port: number;
/** Host to bind (default localhost). */
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
host?: string;
/**
* The backend's origin allow-list (the embedder origins). Used to build the
@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined {
}
/**
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic).
* An invalid value falls through rather than crashing the boot; a set-but-invalid
* MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't
* silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS.
* The default sandbox listen port — **fixed**, deliberately not `0`.
*
* A `0` default lets the OS assign, which is fine on a desktop (the browser
* learns the port from `/api/config`) but unusable anywhere the port has to be
* declared *before* the process starts: a dev container's `forwardPorts`, a
* `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run,
* so it could never be named in that config and the Apps tab was unreachable
* (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same
* reason; this is its sandbox counterpart.
*
* `6275` sits next to the web port so the pair is easy to forward together. It
* is not a v1 carry-over — v2 has no proxy server on it.
*/
export const DEFAULT_SANDBOX_PORT = 6275;
/**
* Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT →
* {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than
* crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is
* warned so the fall-through isn't silent — matching the warn-and-drop
* precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the
* OS assign" ({@link parseListenPort} accepts it).
*/
export function resolveSandboxPort(): number {
const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT);
@ -107,13 +126,21 @@ export function resolveSandboxPort(): number {
`Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`,
);
}
return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0;
return (
fromSandbox ??
parseListenPort(process.env.SERVER_PORT) ??
DEFAULT_SANDBOX_PORT
);
}
export function createSandboxController(
options: SandboxControllerOptions,
): SandboxController {
const { port, host = "localhost", allowedOrigins } = options;
// Defaulted to the same address the web server binds, never the name
// `localhost` — a name resolves to one address family and would reintroduce
// the #1951 split (web on IPv4, sandbox on IPv6) for any future call site
// that omits `host`. Both call sites pass `config.sandboxHost` today.
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
let server: Server | null = null;
let sandboxUrl: string | null = null;
@ -177,8 +204,28 @@ export function createSandboxController(
});
res.end(sandboxHtml);
});
// A taken port used to mean "no sandbox at all", which was tolerable
// only because the default was dynamic and therefore never collided.
// Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector —
// or anything else already on 6275 — would take the Apps tab down, so
// retry once on an OS-assigned port. The retry is announced loudly:
// whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin
// didn't take, and a silently-moved port is exactly the failure this
// default was introduced to fix.
let retriedDynamic = false;
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (!retriedDynamic && port !== 0) {
retriedDynamic = true;
console.warn(
`Sandbox: port ${port} in use; falling back to an OS-assigned port. ` +
`The MCP Apps tab will work locally, but the sandbox is no longer ` +
`on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` +
`need to forward it (container / SSH tunnel / reverse proxy).`,
);
server!.listen(0, host);
return;
}
console.error(
`Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`,
);

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

@ -173,4 +173,30 @@ describe("AppDetailPanel", () => {
rerender(<AppDetailPanel {...baseProps} tool={numberFieldTool("app_b")} />);
expect(input().value).toBe("");
});
// #2020: `hasMissingRequiredFields` sees a field with no value, but text a
// field could not turn into a value looks identical to it — both report
// `undefined` — so an optional argument was dropped from the call with no
// gate. The form reports draft validity for this reason.
it("disables Open App while a field holds text it cannot send", async () => {
const user = userEvent.setup();
const jsonFieldTool: Tool = {
name: "chart_app",
title: "Chart App",
inputSchema: {
type: "object",
properties: { series: { type: "array", title: "Series" } },
},
};
renderWithMantine(<AppDetailPanel {...baseProps} tool={jsonFieldTool} />);
const openApp = screen.getByRole("button", { name: /open app/i });
expect(openApp).not.toBeDisabled();
const jsonInput = screen.getByLabelText(/Series/);
await user.type(jsonInput, "x");
expect(openApp).toBeDisabled();
await user.clear(jsonInput);
expect(openApp).not.toBeDisabled();
});
});

View file

@ -1,4 +1,5 @@
import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core";
import { useState } from "react";
import { MdPlayArrow } from "react-icons/md";
import type { Tool } from "@modelcontextprotocol/client";
import { SchemaForm } from "../SchemaForm/SchemaForm";
@ -58,7 +59,12 @@ export function AppDetailPanel({
/* v8 ignore next -- unreachable: Tool.inputSchema is always an object */
const formSchema = toFormSchema(inputSchema) ?? {};
const hasErrors = hasMissingRequiredFields(formSchema, formValues);
const disabled = isOpening || hasErrors;
// A field holding text it could not turn into a value (unparseable JSON, an
// unrepresentable number) reports `undefined`, which is indistinguishable
// from empty once it reaches `formValues` — so the form reports it directly
// and an optional argument can no longer be dropped silently (#2020).
const [hasInvalidDraft, setHasInvalidDraft] = useState(false);
const disabled = isOpening || hasErrors || hasInvalidDraft;
const hasFields = hasInputFields(tool);
return (
@ -82,6 +88,7 @@ export function AppDetailPanel({
// tool's name to drop another app's in-progress field text. See
// SchemaFormProps.resetKey.
resetKey={tool.name}
onValidityChange={setHasInvalidDraft}
/>
<OpenAppButton

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

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