Merge branch 'v2/main' into v2/fix/2055-ui-resource-meta-ui

This commit is contained in:
Cliff Hall 2026-08-23 22:51:16 -04:00 • committed by GitHub
commit 5eba105c67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 3149 additions and 665 deletions

View file

@ -1,5 +1,5 @@
import { createRef, StrictMode } from "react";
import { act } from "@testing-library/react";
import { act, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/client";
@ -546,15 +546,21 @@ describe("AppRenderer", () => {
// MutationObserver callbacks are delivered on a microtask.
await Promise.resolve();
});
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
// `waitFor`, not a bare assertion after the single microtask above: how
// many ticks happy-dom takes to deliver the MutationObserver callback is
// load-dependent, so under a full parallel run the observer can still be
// pending here. Observed failing exactly once that way, with 0 calls.
await waitFor(() =>
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
expect.objectContaining({
theme: "dark",
styles: expect.objectContaining({
variables: expect.objectContaining({
"--color-background-primary": "#101113",
}),
}),
}),
}),
),
);
} finally {
getComputedStyleSpy.mockRestore();

View file

@ -0,0 +1,217 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, waitFor, within } from "storybook/test";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Request metadata JSON";
/**
* The component is controlled, so every story drives it through a small
* stateful wrapper — otherwise the editor would not accept a keystroke and the
* play functions could not exercise the draft behavior at all.
*
* These stories run in real Chromium, which is what makes them the *only*
* place Ace's keyboard behavior can be tested: the unit suite runs in
* happy-dom, where Ace's hidden-textarea input path does not work at all.
*/
function ControlledJsonObjectInput({ initial }: { initial: StrictJsonObject }) {
const [value, setValue] = useState<StrictJsonObject>(initial);
return (
<JsonObjectInput
label="Request metadata"
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
);
}
const meta: Meta<typeof ControlledJsonObjectInput> = {
title: "Elements/JsonObjectInput",
component: ControlledJsonObjectInput,
};
export default meta;
type Story = StoryObj<typeof ControlledJsonObjectInput>;
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
function aceEditor(canvasElement: HTMLElement): {
focus(): void;
selectAll(): void;
getValue(): string;
session: { getAnnotations(): { row: number; type: string }[] };
} {
const container = canvasElement.querySelector(".ace_editor");
const editor = (
container as
| (HTMLElement & {
env?: {
editor?: {
focus(): void;
selectAll(): void;
getValue(): string;
session: { getAnnotations(): { row: number; type: string }[] };
};
};
})
| null
)?.env?.editor;
if (!editor) throw new Error("Ace editor not mounted");
return editor;
}
/**
* Focus the editor and empty it, so the next keystrokes are the whole document
* and the typing itself — the thing under test — is left to `userEvent`.
*
* Emptied rather than merely selected: typing an opening brace or quote with a
* selection makes Ace **wrap** the selection instead of replacing it, so typing
* `{` over a selected `{}` yields `{{}}` and the assertion stops being about
* auto-closing at all.
*
* Focus and select go through Ace's API on purpose. `userEvent.click` does not
* focus the editor — Ace's input is a 1px offscreen textarea, so the click
* lands nowhere and the keystrokes reach the body instead. And select-all is
* not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would
* make these stories pass or fail by host OS.
*/
async function focusAndClear(canvasElement: HTMLElement): Promise<void> {
const editor = aceEditor(canvasElement);
editor.focus();
editor.selectAll();
await userEvent.keyboard("{Backspace}");
}
/** Ace renders each token in its own element, so read the document, not text. */
function editorText(canvasElement: HTMLElement): string {
return aceEditor(canvasElement).getValue();
}
/** A structured payload renders as formatted JSON, nesting intact. */
export const Populated: Story = {
args: {
initial: {
tenant: "acme",
trace: { id: "abc123", sampled: true },
features: ["apps", "tasks"],
},
},
play: async ({ canvasElement }) => {
await expect(JSON.parse(editorText(canvasElement))).toEqual({
tenant: "acme",
trace: { id: "abc123", sampled: true },
features: ["apps", "tasks"],
});
},
};
/** An empty payload opens as `{}` and carries no error. */
export const Empty: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(editorText(canvasElement)).toBe("{}");
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
/**
* Opening a brace closes it — one of the two reasons this is an editor rather
* than a textarea, and the thing that makes hand-writing a nested `_meta`
* payload bearable.
*
* Deliberately one keystroke: Ace also auto-pairs quotes and skips over a
* closing character you type yourself, so a longer sequence asserts the sum of
* several behaviours and stops being a readable statement about this one.
*/
export const ClosesBracesWhileTyping: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
// `{{` is userEvent's escape for a literal `{`.
await userEvent.keyboard("{{");
// Ace does not close it on the keystroke — an opening brace at the end of a
// line is often the start of a block the user is about to fill in.
await expect(editorText(canvasElement)).toBe("{");
// Enter is what completes the pair, leaving the cursor indented between
// them. This is the behaviour that makes writing nested `_meta` by hand
// bearable, and the reason this is an editor rather than a textarea.
await userEvent.keyboard("{Enter}");
await expect(editorText(canvasElement)).toBe("{\n \n}");
// The result parses, so nothing is flagged.
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
},
};
/**
* Half-typed text is displayed as typed and flagged, rather than being
* re-escaped back into the box each keystroke (the #1928 failure this draft
* split exists to avoid).
*/
export const InvalidDraft: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
// Genuinely half-typed — the state a user is in partway through writing an
// entry, rather than a contrived bad string.
await userEvent.keyboard('{{"a":');
// Shown exactly as typed. The old textarea re-escaped its own contents on
// every keystroke here, which is what made it unusable (#1928).
await expect(editorText(canvasElement)).toBe('{"a":');
await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible();
},
};
/**
* Valid JSON that is not an object is rejected with its own message, distinct
* from unparseable text.
*
* A bare number rather than an array: brackets are auto-paired too, so typing
* one would test the pairing as much as the rejection.
*/
export const NotAnObject: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await focusAndClear(canvasElement);
await userEvent.keyboard("42");
await expect(editorText(canvasElement)).toBe("42");
await expect(
await canvas.findByText(/Must be a JSON object/),
).toBeVisible();
},
};
/**
* The JSON worker marks the offending line in the gutter — the reason this
* component registers `worker-json` through `ace.config.setModuleUrl` with a
* Vite `?url` asset.
*
* Asserted because nothing else covers it: the worker runs out-of-process, so
* if its URL registration breaks, Ace fails to start it and silently falls back
* to no annotations. Every other test here — including the wrapper's own
* "Not valid JSON" message, which comes from our parser, not from Ace — would
* still pass while the gutter diagnostics quietly disappeared.
*/
export const AnnotatesTheOffendingLine: Story = {
args: { initial: {} },
play: async ({ canvasElement }) => {
await focusAndClear(canvasElement);
// A trailing comma on line 2: valid-looking until the closing brace, so the
// worker has to locate it rather than just reject the document.
await userEvent.keyboard('{{"a": 1,{Enter}}');
await waitFor(
async () => {
const annotations = aceEditor(canvasElement).session.getAnnotations();
await expect(annotations.length).toBeGreaterThan(0);
await expect(annotations.some((a) => a.type === "error")).toBe(true);
},
// The worker is asynchronous and debounced, so this needs a real wait
// rather than a tick.
{ timeout: 5000 },
);
},
};

View file

@ -0,0 +1,260 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { act, screen } from "@testing-library/react";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Payload JSON";
/** Controlled harness: the component never owns the value, only the draft. */
function Harness({
initial,
onChange,
}: {
initial: StrictJsonObject;
onChange?: (v: StrictJsonObject) => void;
}) {
const [value, setValue] = useState<StrictJsonObject>(initial);
return (
<>
<JsonObjectInput
ariaLabel={LABEL}
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
<output data-testid="emitted">{JSON.stringify(value)}</output>
</>
);
}
describe("JsonObjectInput", () => {
it("names the editor for assistive tech", () => {
renderWithMantine(<Harness initial={{}} />);
// Ace composes "<label>, Cursor at row N", so the match is by substring —
// the position readout is Ace's, the name is ours.
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
});
it("uses Ace's dark theme under a dark color scheme", () => {
// The editor picks its Ace theme from the computed scheme rather than
// inheriting Mantine tokens, so the dark branch is only exercised by
// actually rendering dark.
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
// same class, so getting it wrong silently drops the fix in dark mode.
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
expect(document.querySelector(".ace-github")).toBeNull();
});
it("uses Ace's light theme under a light color scheme", () => {
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
expect(document.querySelector(".ace-github")).not.toBeNull();
expect(document.querySelector(".ace-github_dark")).toBeNull();
});
it("opens with the value serialized two-space, nesting intact", () => {
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
expect(getAceText()).toBe(
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
);
});
it("emits the parsed object — a structured value stays structured", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
await setAceText('{"trace":{"id":"x"},"n":3}');
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
});
// Also the regression test for the paired-event bug `handleChange` coalesces:
// `setValue` is a remove followed by an insert, and acting on the remove
// emitted `{}` and wiped `keep` before the insert had landed.
it("shows the text as typed while it is invalid and does not emit", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText('{"a":');
// Displayed verbatim — not re-escaped back through JSON.stringify.
expect(getAceText()).toBe('{"a":');
expect(
screen.getByText("Not valid JSON — changes are not applied"),
).toBeInTheDocument();
// Nothing emitted, so the caller still holds the last valid object.
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}');
});
it("flags valid JSON that is not an object without emitting", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText("[1]");
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it("treats a cleared editor as the empty object", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
await setAceText("");
expect(onChange).toHaveBeenLastCalledWith({});
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
});
it("re-syncs when the parent's value diverges from the draft", async () => {
// An external reset (switching servers, a reloaded catalog) must reach the
// editor; an in-progress edit must not be overwritten by what it emitted.
function Externally() {
const [value, setValue] = useState<StrictJsonObject>({ a: 1 });
return (
<>
<JsonObjectInput
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
<button onClick={() => setValue({ b: 2 })}>reset</button>
</>
);
}
renderWithMantine(<Externally />);
await act(async () => {
screen.getByRole("button", { name: "reset" }).click();
});
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
});
it("applies an external value it showed before (A → B → A)", async () => {
// Regression: `echoed` used to hold only what this component last emitted,
// so returning to a previously-shown value matched the stale entry and was
// dismissed as our own echo, stranding the editor on B.
function Externally({ value }: { value: StrictJsonObject }) {
return (
<JsonObjectInput ariaLabel={LABEL} value={value} onChange={() => {}} />
);
}
const { rerender } = renderWithMantine(<Externally value={{ a: 1 }} />);
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
rerender(<Externally value={{ b: 2 }} />);
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
// Back to the first value — a distinct object, same content.
rerender(<Externally value={{ a: 1 }} />);
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
});
describe("wiring to the Input.Wrapper label and error", () => {
/** Ace's hidden textarea — the element the wrapper has to point at. */
function textarea(): HTMLTextAreaElement {
const el = document.querySelector<HTMLTextAreaElement>(
"textarea.ace_text-input",
);
if (!el) throw new Error("Ace text input not mounted");
return el;
}
it("gives two instances distinct DOM ids", () => {
// react-ace uses `name` as the container id; a fixed value would put
// duplicate ids in the document as soon as a second editor renders.
renderWithMantine(
<>
<JsonObjectInput ariaLabel="First" value={{}} onChange={() => {}} />
<JsonObjectInput ariaLabel="Second" value={{}} onChange={() => {}} />
</>,
);
const containerIds = Array.from(
document.querySelectorAll(".ace_editor"),
).map((n) => n.id);
const inputIds = Array.from(
document.querySelectorAll("textarea.ace_text-input"),
).map((n) => n.id);
expect(containerIds).toHaveLength(2);
expect(new Set([...containerIds, ...inputIds]).size).toBe(4);
expect(containerIds.concat(inputIds).every((id) => id !== "")).toBe(true);
});
it("lets the visible label focus the editor", () => {
renderWithMantine(
<JsonObjectInput
label="Request metadata"
ariaLabel={LABEL}
value={{}}
onChange={() => {}}
/>,
);
// `Input.Wrapper` renders `<label for={id}>`; the editor is only
// reachable from it if Ace's textarea carries that same id.
const label = screen.getByText("Request metadata");
expect(label.getAttribute("for")).toBe(textarea().id);
expect(textarea().id).not.toBe("");
});
it("marks the editor invalid and points it at the error text", async () => {
renderWithMantine(<Harness initial={{}} />);
expect(textarea().getAttribute("aria-invalid")).toBeNull();
await setAceText("[1]");
expect(textarea().getAttribute("aria-invalid")).toBe("true");
const describedBy = textarea().getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
// The id must resolve to the message actually on screen.
expect(document.getElementById(describedBy!)?.textContent).toMatch(
/Must be a JSON object/,
);
});
it("announces a supplied description, and keeps it when the draft breaks", async () => {
function WithDescription() {
const [value, setValue] = useState<StrictJsonObject>({});
return (
<JsonObjectInput
ariaLabel={LABEL}
description="Sent with every request"
value={value}
onChange={setValue}
/>
);
}
renderWithMantine(<WithDescription />);
const described = () =>
(textarea().getAttribute("aria-describedby") ?? "")
.split(" ")
.filter(Boolean)
.map((id) => document.getElementById(id)?.textContent);
// Valid draft: described by the description alone.
expect(described()).toEqual(["Sent with every request"]);
// Invalid draft: the error joins it rather than replacing it — the
// description is still true, and dropping it would silently un-describe
// the control at the moment it needs explaining most.
await setAceText("[1]");
expect(described()).toHaveLength(2);
expect(described()[0]).toBe("Sent with every request");
expect(described()[1]).toMatch(/Must be a JSON object/);
});
it("clears the invalid marking once the text parses again", async () => {
renderWithMantine(<Harness initial={{}} />);
await setAceText("[1]");
expect(textarea().getAttribute("aria-invalid")).toBe("true");
await setAceText('{"a":1}');
expect(textarea().getAttribute("aria-invalid")).toBeNull();
expect(textarea().getAttribute("aria-describedby")).toBeNull();
});
});
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
renderWithMantine(<Harness initial={{}} />);
// The parent re-renders with the object this text parsed to; the draft must
// not be reformatted out from under the cursor.
await setAceText('{"a":1}');
expect(getAceText()).toBe('{"a":1}');
});
});

View file

@ -0,0 +1,251 @@
import { useEffect, useId, useRef, useState } from "react";
import { Input, useComputedColorScheme } from "@mantine/core";
import type { Ace } from "ace-builds";
import AceEditor from "react-ace";
import ace from "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/theme-github_dark";
// `?url` so Vite emits the worker as its own asset and hands back a real URL.
// Without it Ace fetches `worker-json.js` from a path that does not exist in a
// bundled app, silently loses its annotations, and logs a fetch error.
import jsonWorkerUrl from "ace-builds/src-noconflict/worker-json.js?url";
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { useValueChange } from "../../../hooks/useValueChange";
import { parseJsonObjectDraft } from "../../../utils/jsonObjectDraft";
// Module scope: registering the worker URL is global to Ace and idempotent, so
// it must not be repeated per mount.
ace.config.setModuleUrl("ace/mode/json_worker", jsonWorkerUrl);
/** Two-space canonical form — the same shape the catalog file is written in. */
function serialize(value: StrictJsonObject): string {
return JSON.stringify(value, null, 2);
}
export interface JsonObjectInputProps {
value: StrictJsonObject;
onChange: (value: StrictJsonObject) => void;
label?: string;
description?: string;
/**
* Accessible name for the editor's text input. Ace labels its hidden
* textarea "Cursor at row N" by default, which is a position readout rather
* than a name — so this is what makes the control identifiable to a screen
* reader, and what `getByLabelText` finds in a test.
*/
ariaLabel: string;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
maxLines?: number;
}
/**
* A JSON-object editor: the user types JSON, the parent receives the parsed
* object.
*
* This is the editor for payloads whose *values* may be any JSON, which is why
* it exists beside the key/value row editors used for headers and env. `_meta`
* is the case that forced it (#1910) — nothing in the MCP spec restricts a
* `_meta` value to a string, so a `{ key, value }` row editor cannot express
* what the protocol allows.
*
* Built on Ace rather than a plain textarea because hand-writing JSON in a bare
* box is the actual pain: Ace closes a brace when you open one, folds nested
* objects, and — via its JSON worker — marks the offending line in the gutter
* instead of reporting one message for the whole document.
*
* The text the user is typing is the source of truth for what is *displayed*;
* the parent only ever sees a parsed object. That split is what makes the box
* typeable: driving it off the parent's value and stringifying on every render
* re-escapes half-typed text once per keystroke, which is the #1928 symptom.
* `SchemaJsonField` in `SchemaForm.tsx` carries the same split for the same
* reason — see the long comment there.
*
* While the text is invalid the parent is **not** told, so the last valid
* object stands. The alternative — emitting `{}` — would silently discard a
* user's configured metadata the moment they typed a stray character, and this
* form has no Save button to gate instead: `onChange` writes straight through.
* The state is not silent, though: Ace flags the position in the gutter, and
* the wrapper repeats the reason underneath.
*/
export function JsonObjectInput({
value,
onChange,
label,
description,
ariaLabel,
minLines = 6,
maxLines = 24,
}: JsonObjectInputProps) {
const [draft, setDraft] = useState(() => serialize(value));
// The canonical JSON of the last value this component emitted.
//
// The re-sync below has to tell an *external* change apart from the parent
// echoing back what we just sent it, and the draft alone cannot: while the
// text is invalid it parses to nothing, so every parent change would look
// external and overwrite what is being typed.
//
// That is not hypothetical. Ace's `setValue` — which is what a select-all
// paste and any programmatic replace go through — fires *two* change events,
// a remove then an insert. The remove leaves an empty document, which is a
// valid `{}` and gets emitted; the insert then leaves invalid text. Comparing
// against the draft, the `{}` echo reads as an external reset and wipes the
// pasted text a keystroke after it lands.
const [echoed, setEchoed] = useState(() => serialize(value));
// `getInitialValueInEffect: false` so the first render already has the real
// scheme; otherwise the editor mounts light and repaints, which is visible.
const colorScheme = useComputedColorScheme("light", {
getInitialValueInEffect: false,
});
// Re-sync only on a value this component did not produce, so an external
// reset (switching servers, a reloaded catalog) reaches the editor while an
// in-progress edit is left alone. Compared through canonical JSON because the
// parent hands back a fresh object each time.
// Text from the most recent change event in the current tick, and whether a
// flush is already queued to consume it. Two refs rather than one nullable
// one so the flush reads a plain `string` — folding them together would add
// a null check that nothing can ever satisfy. See `handleChange`.
const pendingTextRef = useRef("");
const flushScheduledRef = useRef(false);
/**
* Coalesce the change events Ace fires within one edit, and act on the last.
*
* A replace — select-all-and-retype, or a paste over a selection — is a
* *remove* followed by an *insert*, and Ace fires a change event for each.
* Acting on the first would read the momentarily-empty document as the user
* clearing the field, emit `{}` to the parent, and so wipe their saved
* metadata the instant they selected it. If the replacement text then failed
* to parse, `{}` is what they would be left with.
*
* A microtask is enough because both events are dispatched synchronously
* inside the same edit, so the flush sees only the settled text.
*/
const handleChange = (text: string) => {
pendingTextRef.current = text;
if (flushScheduledRef.current) return;
flushScheduledRef.current = true;
queueMicrotask(() => {
flushScheduledRef.current = false;
const settled = pendingTextRef.current;
setDraft(settled);
const result = parseJsonObjectDraft(settled);
if (!result.ok) return;
setEchoed(serialize(result.value));
onChange(result.value);
});
};
const wrapperId = useId();
const editorRef = useRef<Ace.Editor | null>(null);
const parsed = parseJsonObjectDraft(draft);
useValueChange(serialize(value), (next) => {
if (next === echoed) return;
setDraft(next);
// `echoed` has to advance to the value now on screen, not just to what this
// component last emitted. Leaving it behind makes an external A → B → A
// sequence stick on B: the second A matches the stale `echoed` from before
// B arrived and is dismissed as our own echo.
setEchoed(next);
});
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
// is not one — it renders its own DOM — so the wrapper's label and error node
// exist but point at nothing, leaving the control unassociated with the error
// a screen reader needs and its visible label inert on click.
//
// A genuine external-system sync, so an effect rather than `useValueChange`:
// it writes to a DOM node React does not own. Giving the textarea the
// wrapper's own id is what makes the `<label for>` focus the editor; the
// error wiring follows Mantine's `${id}-error` naming.
useEffect(() => {
const element = editorRef.current?.textInput.getElement();
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
synchronously on mount, so this effect always sees an element. */
if (!element) return;
element.id = wrapperId;
// Both the description and the error are rendered by the wrapper and both
// describe this control, so both ids belong here — a supplied description
// is otherwise never announced, and it must not be dropped just because the
// draft went invalid. Mantine renders each only when its prop is set, so
// referencing an absent one would point at nothing.
const describedBy = [
description === undefined ? null : `${wrapperId}-description`,
parsed.ok ? null : `${wrapperId}-error`,
].filter((id) => id !== null);
if (describedBy.length > 0) {
element.setAttribute("aria-describedby", describedBy.join(" "));
} else {
element.removeAttribute("aria-describedby");
}
if (parsed.ok) {
element.removeAttribute("aria-invalid");
} else {
element.setAttribute("aria-invalid", "true");
}
}, [wrapperId, parsed.ok, description]);
return (
<Input.Wrapper
id={wrapperId}
label={label}
description={description}
error={parsed.ok ? undefined : parsed.error}
w="100%"
>
<AceEditor
mode="json"
theme={colorScheme === "dark" ? "github_dark" : "github"}
// react-ace uses `name` as the editor container's DOM id, so a fixed
// value would collide the moment two of these render — and it must also
// differ from the id given to the textarea below, which is the one the
// wrapper's `<label for>` points at.
name={`${wrapperId}-editor`}
value={draft}
onChange={handleChange}
width="100%"
minLines={minLines}
maxLines={maxLines}
tabSize={2}
showPrintMargin={false}
editorProps={{ $blockScrolling: Infinity }}
onLoad={(editor) => {
editorRef.current = editor;
// `textInputAriaLabel` alone is not enough: Ace composes the hidden
// textarea's label as "<label>, Cursor at row N" inside
// `TextInput.setAriaLabel`, and only recomputes it when the cursor
// moves — so an option applied at mount does not reach the DOM until
// the user clicks into the editor. Until then the control's only
// accessible name is the position readout, which names nothing.
// Setting it here and recomputing once is what makes the editor
// identifiable from first paint.
editor.setOption("textInputAriaLabel", ariaLabel);
editor.textInput.setAriaLabel();
}}
setOptions={{
// The JSON worker is what puts a marker on the offending line rather
// than one message for the whole document.
useWorker: true,
// Closes a brace/bracket/quote when you open one, and skips over the
// closing one you then type. On by default; named because it is half
// of why this component exists.
behavioursEnabled: true,
// Roving-tabindex arrow-key navigation plus an Esc-to-exit-trap, so
// the editor does not swallow Tab for keyboard users.
enableKeyboardAccessibility: true,
textInputAriaLabel: ariaLabel,
showFoldWidgets: true,
displayIndentGuides: true,
useSoftTabs: true,
scrollPastEnd: false,
}}
/>
</Input.Wrapper>
);
}

View file

@ -10,7 +10,7 @@ import {
const defaultSettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -57,34 +57,9 @@ function InteractiveRender(args: ServerSettingsFormProps) {
},
});
}}
onAddMetadata={() => {
args.onAddMetadata();
updateArgs({
settings: {
...args.settings,
metadata: [...args.settings.metadata, { key: "", value: "" }],
},
});
}}
onRemoveMetadata={(index) => {
args.onRemoveMetadata(index);
updateArgs({
settings: {
...args.settings,
metadata: args.settings.metadata.filter((_, i) => i !== index),
},
});
}}
onMetadataChange={(index, key, value) => {
args.onMetadataChange(index, key, value);
updateArgs({
settings: {
...args.settings,
metadata: args.settings.metadata.map((m, i) =>
i === index ? { key, value } : m,
),
},
});
onMetadataChange={(metadata) => {
args.onMetadataChange(metadata);
updateArgs({ settings: { ...args.settings, metadata } });
}}
onAddEnv={() => {
args.onAddEnv();
@ -197,8 +172,6 @@ const meta: Meta<typeof ServerSettingsForm> = {
onRemoveEnv: fn(),
onEnvChange: fn(),
onCwdChange: fn(),
onAddMetadata: fn(),
onRemoveMetadata: fn(),
onMetadataChange: fn(),
onTimeoutChange: fn(),
onAutoRefreshChange: fn(),
@ -278,10 +251,13 @@ export const AllConfigured: Story = {
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [
{ key: "userId", value: "user-789" },
{ key: "sessionId", value: "session-012" },
],
metadata: {
userId: "user-789",
sessionId: "session-012",
// A structured value — the case a `{ key, value }` row editor could
// not express (#1910).
client: { name: "inspector", features: ["apps", "tasks"], retries: 3 },
},
connectionTimeout: 15000,
requestTimeout: 45000,
taskTtl: 45000,

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { within } from "@testing-library/react";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText, setAceText } from "../../../test/aceEditor";
import {
ServerSettingsForm,
type ServerSettingsSection,
@ -22,7 +23,7 @@ function clearButtonFor(input: HTMLElement): HTMLElement {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -33,7 +34,7 @@ const emptySettings: InspectorServerSettings = {
const populatedSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
metadata: { userId: "u-1", tenant: { id: 7, tags: ["a"] } },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -63,8 +64,6 @@ const baseHandlers = {
onRemoveEnv: vi.fn(),
onEnvChange: vi.fn(),
onCwdChange: vi.fn(),
onAddMetadata: vi.fn(),
onRemoveMetadata: vi.fn(),
onMetadataChange: vi.fn(),
onTimeoutChange: vi.fn(),
onAutoRefreshChange: vi.fn(),
@ -239,7 +238,7 @@ describe("ServerSettingsForm", () => {
expect(screen.getByText("Log Level per Request")).toBeInTheDocument();
});
it("shows empty hints for headers and metadata when no entries exist", () => {
it("shows the empty hint for headers, and an empty JSON object for metadata", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
@ -250,9 +249,9 @@ describe("ServerSettingsForm", () => {
expect(
screen.getByText("No custom headers configured"),
).toBeInTheDocument();
expect(
screen.getByText("No request metadata configured"),
).toBeInTheDocument();
// Metadata has no rows to be absent — the editor is always present, and
// "none configured" is spelled `{}` in it.
expect(getAceText()).toBe("{}");
});
it("invokes onAddHeader when + Add Header is clicked", async () => {
@ -270,21 +269,6 @@ describe("ServerSettingsForm", () => {
expect(onAddHeader).toHaveBeenCalledTimes(1);
});
it("invokes onAddMetadata when + Add Metadata is clicked", async () => {
const user = userEvent.setup();
const onAddMetadata = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onAddMetadata={onAddMetadata}
settings={emptySettings}
expandedSections={["metadata"]}
/>,
);
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onAddMetadata).toHaveBeenCalledTimes(1);
});
it("invokes onHeaderChange when typing in header value input", async () => {
const user = userEvent.setup();
const onHeaderChange = vi.fn();
@ -340,29 +324,34 @@ describe("ServerSettingsForm", () => {
expect(onRemoveHeader).toHaveBeenCalledWith(0);
});
it("invokes onMetadataChange and onRemoveMetadata for metadata rows", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
const onRemoveMetadata = vi.fn();
it("renders the configured metadata as formatted JSON, nested values included", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
onRemoveMetadata={onRemoveMetadata}
settings={populatedSettings}
expandedSections={["metadata"]}
/>,
);
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "Z");
expect(onMetadataChange).toHaveBeenCalled();
// The point of #1910: a nested object survives into the editor instead of
// being flattened into a `{ key, value }` string row.
expect(JSON.parse(getAceText())).toEqual({
userId: "u-1",
tenant: { id: 7, tags: ["a"] },
});
});
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
it("reports the whole edited object through onMetadataChange", async () => {
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
settings={emptySettings}
expandedSections={["metadata"]}
/>,
);
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
await setAceText('{"n":[1,2]}');
expect(onMetadataChange).toHaveBeenLastCalledWith({ n: [1, 2] });
});
it("invokes onTimeoutChange when typing in connection timeout", async () => {
@ -1470,23 +1459,6 @@ describe("ServerSettingsForm", () => {
expect(onClearStoredOAuth).toHaveBeenCalledTimes(1);
});
it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => {
const user = userEvent.setup();
const onMetadataChange = vi.fn();
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
onMetadataChange={onMetadataChange}
settings={populatedSettings}
expandedSections={["metadata"]}
/>,
);
// populatedSettings has one metadata { key: "userId", value: "u-1" }
const valueInput = screen.getByDisplayValue("u-1");
await user.click(clearButtonFor(valueInput));
expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", "");
});
it("omits the Clear buttons for an empty header key/value row", () => {
// A row whose key and value are both empty renders no Clear button in
// either field (the null branch of `item.key ?` / `item.value ?`).

View file

@ -4,6 +4,7 @@ import {
Alert,
Button,
Checkbox,
Code,
Flex,
Group,
NumberInput,
@ -13,11 +14,13 @@ import {
TextInput,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { JsonObjectInput } from "../../elements/JsonObjectInput/JsonObjectInput";
import type { ProtocolEra } from "@modelcontextprotocol/client";
import type {
InspectorServerSettings,
ModernLogLevel,
OAuthSettings,
RequestMetadata,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
@ -78,9 +81,12 @@ export interface ServerSettingsFormProps {
onRemoveEnv: (index: number) => void;
onEnvChange: (index: number, key: string, value: string) => void;
onCwdChange: (value: string) => void;
onAddMetadata: () => void;
onRemoveMetadata: (index: number) => void;
onMetadataChange: (index: number, key: string, value: string) => void;
/**
* Replace the whole default-`_meta` payload. Not the `(index, key, value)`
* row callback the header/env editors use — metadata is edited as a JSON
* object, since a `_meta` value may be any JSON (#1910).
*/
onMetadataChange: (metadata: RequestMetadata) => void;
onTimeoutChange: (
field: "connectionTimeout" | "requestTimeout" | "taskTtl",
value: number,
@ -434,8 +440,6 @@ export function ServerSettingsForm({
onRemoveEnv,
onEnvChange,
onCwdChange,
onAddMetadata,
onRemoveMetadata,
onMetadataChange,
onTimeoutChange,
onAutoRefreshChange,
@ -724,22 +728,23 @@ export function ServerSettingsForm({
<Accordion.Control>Request Metadata</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Group justify="space-between">
<HintText>
Metadata sent with every MCP request (included in _meta field)
</HintText>
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
</Group>
{settings.metadata.length === 0 ? (
<EmptyHint>No request metadata configured</EmptyHint>
) : (
<KeyValueRows
items={settings.metadata}
entityLabel="metadata entry"
onChange={onMetadataChange}
onRemove={onRemoveMetadata}
/>
)}
<HintText>
Metadata merged into the <Code>_meta</Code> field of every MCP
request to this server, as a JSON object. Values may be any JSON —
objects, arrays, numbers, booleans, <Code>null</Code> — not just
strings. Leave it as <Code>{"{}"}</Code> to send none. The keys
the protocol reserves keep their own shape:{" "}
<Code>progressToken</Code> must be a string or a safe integer (one
JavaScript can represent exactly), and{" "}
<Code>io.modelcontextprotocol/related-task</Code> an object with a
string <Code>taskId</Code>. A reserved key that does not match is
dropped from the request rather than sent.
</HintText>
<JsonObjectInput
ariaLabel="Request metadata JSON"
value={settings.metadata}
onChange={onMetadataChange}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>

View file

@ -14,7 +14,7 @@ const initialSettings: InspectorServerSettings = {
{ key: "X-Request-Id", value: "req-456" },
],
env: [],
metadata: [{ key: "userId", value: "user-789" }],
metadata: { userId: "user-789", client: { name: "inspector" } },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -74,7 +74,7 @@ export const EmptySettings: Story = {
settings: {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -96,7 +96,7 @@ export const StdioServer: Story = {
{ key: "API_KEY", value: "abc-123" },
{ key: "LOG_LEVEL", value: "debug" },
],
metadata: [],
metadata: {},
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 60000,

View file

@ -2,12 +2,13 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { setAceText } from "../../../test/aceEditor";
import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
headers: [{ key: "Authorization", value: "Bearer abc" }],
env: [],
metadata: [{ key: "userId", value: "u-1" }],
metadata: { userId: "u-1" },
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -21,7 +22,7 @@ const initialSettings: InspectorServerSettings = {
const emptySettings: InspectorServerSettings = {
headers: [],
env: [],
metadata: [],
metadata: {},
connectionTimeout: 30000,
requestTimeout: 60000,
taskTtl: 60000,
@ -310,76 +311,6 @@ describe("ServerSettingsModal", () => {
expect(lastCall.headers[0].value).toContain("Bearer abc");
});
it("calls onSettingsChange when adding metadata after expanding the section", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.click(screen.getByRole("button", { name: "+ Add Metadata" }));
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
metadata: [{ key: "", value: "" }],
});
});
it("calls onSettingsChange when removing metadata", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
// Both the header and metadata rows have a remove button; each is named
// for the row it belongs to, so no positional guess is needed.
await user.click(
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
metadata: [],
});
});
it("calls onSettingsChange when typing in metadata key/value", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
const valueInput = screen.getByDisplayValue("u-1");
await user.type(valueInput, "9");
expect(onSettingsChange).toHaveBeenCalled();
const lastCall =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(lastCall.metadata[0].key).toBe("userId");
});
it("calls onSettingsChange when changing timeouts", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
@ -703,20 +634,13 @@ describe("ServerSettingsModal", () => {
expect(lastCall.headers[1]).toEqual({ key: "B", value: "2" });
});
it("preserves sibling rows when editing one of several metadata entries", async () => {
it("replaces the whole metadata object when the JSON editor changes", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const twoMeta: InspectorServerSettings = {
...emptySettings,
metadata: [
{ key: "m1", value: "v1" },
{ key: "m2", value: "v2" },
],
};
renderWithMantine(
<ServerSettingsModal
opened
settings={twoMeta}
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
@ -724,9 +648,13 @@ describe("ServerSettingsModal", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Request Metadata" }));
await user.type(screen.getByDisplayValue("v1"), "x");
await setAceText('{"trace":{"id":"abc"}}');
const lastCall = onSettingsChange.mock.calls.at(-1)?.[0];
expect(lastCall.metadata[1]).toEqual({ key: "m2", value: "v2" });
// The nested object arrives as an object, not a stringified one — the
// whole point of #1910.
expect(lastCall.metadata).toEqual({ trace: { id: "abc" } });
// Every other setting is carried through untouched.
expect(lastCall.headers).toEqual(emptySettings.headers);
});
it("preserves sibling rows when editing one of several roots", async () => {

View file

@ -5,6 +5,7 @@ import type {
InspectorServerSettings,
ModernLogLevel,
OAuthSettings,
RequestMetadata,
ServerProtocolEra,
ServerType,
} from "@inspector/core/mcp/types.js";
@ -155,24 +156,7 @@ export function ServerSettingsModal({
onSettingsChange({ ...settings, cwd: value });
}
function handleAddMetadata() {
onSettingsChange({
...settings,
metadata: [...settings.metadata, { key: "", value: "" }],
});
}
function handleRemoveMetadata(index: number) {
onSettingsChange({
...settings,
metadata: settings.metadata.filter((_, i) => i !== index),
});
}
function handleMetadataChange(index: number, key: string, value: string) {
const metadata = settings.metadata.map((m, i) =>
i === index ? { key, value } : m,
);
function handleMetadataChange(metadata: RequestMetadata) {
onSettingsChange({ ...settings, metadata });
}
@ -303,8 +287,6 @@ export function ServerSettingsModal({
onRemoveEnv={handleRemoveEnv}
onEnvChange={handleEnvChange}
onCwdChange={handleCwdChange}
onAddMetadata={handleAddMetadata}
onRemoveMetadata={handleRemoveMetadata}
onMetadataChange={handleMetadataChange}
onTimeoutChange={handleTimeoutChange}
onAutoRefreshChange={handleAutoRefreshChange}