Merge pull request #2170 from modelcontextprotocol/v2/feat/2151-ace-json-editor

feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle
This commit is contained in:
Cliff Hall 2026-08-27 20:19:56 -04:00 • committed by GitHub
commit 6bc3251962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4038 additions and 486 deletions

View file

@ -49,8 +49,8 @@ Components live under `src/components/` in four layers, smallest to largest:
| Layer | Count | What it is |
| ----------- | ----- | ------------------------------------------------------------------------------ |
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
| `elements/` | ~48 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
| `groups/` | ~64 | Composite pieces (cards, panels, modals, control bars). |
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
@ -283,17 +283,34 @@ Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Tex
**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated `<style>` after the stylesheet imports, so a `:root` rule there loses on source order at equal specificity. `cssVariablesResolver` is the supported seam. It is passed at **all three** `MantineProvider` sites — the app (`main.tsx`), the Storybook preview, and `renderWithMantine` — so the running app, the stories, and the tests cannot disagree about a token's value. It currently corrects `--mantine-color-error`, whose Mantine defaults fail WCAG AA in both schemes at the size input error text renders.
## Code editing (`JsonObjectInput`)
## JSON editing and display (`JsonEditor`)
Payloads whose _values_ may be arbitrary JSON — `_meta` is the case that forced it ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)) — are edited with **Ace** (`react-ace` + `ace-builds`, declared in this client because they render React) rather than the key/value rows used for headers and env, which cannot express an object value. Ace brings code folding, brace auto-closing, and per-line error annotation from its JSON worker.
Every surface in this client where JSON is **typed**, plus `ContentViewer`'s read-only JSON branch, renders one element: **`elements/JsonEditor`**, an **Ace** editor (`react-ace` + `ace-builds`, declared in this client because they render React). Ace brings code folding, line numbers, brace auto-closing, and per-line error annotation from its JSON worker — the last three are why hand-writing a nested payload in a bare textarea was the actual pain ([#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)).
Three integration details are load-bearing:
`JsonEditor` is deliberately **text in, text out**: it never parses. The editing contracts above it disagree about what an unparseable draft means, and neither can be expressed by a component that decides for them:
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations.
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden.
| Consumer | While the draft is invalid |
| --- | --- |
| `elements/JsonObjectInput` (Server Settings → Request Metadata) | Parent is **not** told; the last valid object stands. There is no Save button to gate — `onChange` writes straight through — so emitting `{}` would discard configured metadata on a stray keystroke ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)). |
| `SchemaJsonField` (the object/array/union fallback in `groups/SchemaForm`) | Parent is told `undefined`, **and** invalidity is reported up through `onValidityChange` so Execute / Open App / Submit are disabled ([#2020](https://github.com/modelcontextprotocol/inspector/issues/2020)). |
| `SchemaForm`'s **Edit as JSON** switch | Same as above, for the whole arguments object — the v1 escape hatch, restored. Seeded from what the form holds, so a root-union switch's pruning ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)) is not undone by a round trip. |
| `groups/EditReplayModal` (Protocol → Edit and replay) | Send is disabled. Unlike the metadata editor this modal *has* a commit gesture to gate. |
| `groups/ImportServerJsonPanel`, `groups/ExperimentalFeaturesPanel` | The panel owns the text and validates it itself. |
| `elements/ContentViewer`'s JSON branch | Read-only — see below. |
**Read-only mode is what `ContentViewer` renders JSON as**, and through it every payload that reaches the app that way: Protocol and Network entries, tool results, structured output, resource previews, server cards. Highlighting is *not* what that buys — JSON already highlighted, via the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is **folding**, line numbers and a gutter on a large payload. The Prism `json` grammar was dropped in the same change rather than kept beside it: two highlighters for one language drift, and nothing else asks for `json`. Two cases stay on the plain renderer — a `wrap={false}` caller (the server card's fixed-height, single-line box) and untyped text that only *looks* like JSON but does not parse, which in an editor would frame a server's prose as a malformed document.
Two read-only JSON displays deliberately do **not** go through `ContentViewer`, and so are not on this editor — worth knowing before assuming it is the route for all of them. `ExperimentalFeaturesPanel` renders its JSON-RPC **response** in a read-only `Textarea` (its *request* box is on the editor), and `ConnectionInfoContent/OAuthTokenField` renders a decoded JWT in a `Code` block beside the raw token, where the decoded/raw toggle and the copy affordance belong to the field rather than to a viewer. Both are candidates for the same treatment; neither is in [#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)'s scope.
Five integration details are load-bearing:
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations. It is registered at **module scope** — the registration is global to Ace and idempotent, so it must not move into a per-mount effect.
- **Ace fires two change events for a replace** (a remove, then an insert), so a select-all-and-retype passes through a momentarily *empty* document. `JsonEditor` coalesces the pair in a microtask and reports only the settled text; acting on the first event reports the empty document as the user's answer.
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden. The read-only caret is hidden the same way (`.json-editor-readonly`), since `readOnly` has no option that removes it.
- **The label and error are wired to Ace's hidden textarea by hand.** `Input.Wrapper` associates a _Mantine_ input through context; Ace renders its own DOM, so the id, `aria-invalid` and `aria-describedby` are set on the textarea in an effect.
- **`ariaLabel` is required.** Ace names its hidden textarea "Cursor at row N", which is a position readout rather than a name — and it only recomputes that label when the cursor moves, so the option has to be applied *and* recomputed in `onLoad` to reach the DOM before the user clicks in.
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts`; real keyboard behaviour lives in the Storybook play functions, which run in Chromium.
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts` (`setAceText` / `getAceText`, plus the `*ByLabel` variants for a screen holding more than one editor); real keyboard behaviour lives in the Storybook play functions, which run in Chromium. The same split applies to *reading* a payload back: Ace virtualizes its lines, so a read-only editor's text is not fully in the DOM and assertions go through the editor rather than `getByText`.
## Testing

View file

@ -615,3 +615,10 @@ body.resizing-col {
.ace_editor.ace-github-dark .ace_gutter {
color: var(--inspector-code-gutter-text);
}
/* A read-only Ace editor still paints a caret, which reads as an editable field
whose keystrokes are being swallowed. `readOnly` has no option that removes
it, so — like the gutter above — it can only be reached by selector. */
.ace_editor.json-editor-readonly .ace_cursor {
display: none;
}

View file

@ -9,14 +9,11 @@ const meta: Meta<typeof CodeHighlight> = {
export default meta;
type Story = StoryObj<typeof CodeHighlight>;
export const Json: Story = {
// No JSON story: JSON is rendered by `JsonEditor` (read-only), not here (#2151).
export const Yaml: Story = {
args: {
language: "json",
code: JSON.stringify(
{ name: "my-app", version: "1.0.0", tags: ["a", "b"] },
null,
2,
),
language: "yaml",
code: "name: my-app\nversion: 1.0.0\ntags:\n - a\n - b",
},
};

View file

@ -22,9 +22,6 @@ vi.mock("react-syntax-highlighter/dist/esm/prism-light", () => ({
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/tomorrow", () => ({
default: {},
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/json", () => ({
default: { __grammar: "json" },
}));
vi.mock("react-syntax-highlighter/dist/esm/languages/prism/markup", () => ({
default: { __grammar: "markup" },
}));
@ -53,15 +50,15 @@ beforeEach(() => {
describe("CodeHighlight", () => {
it("renders plain code initially, then upgrades to the highlighter", async () => {
const CodeHighlight = await loadComponent();
renderWithMantine(<CodeHighlight language="json" code='{"a":1}' />);
renderWithMantine(<CodeHighlight language="yaml" code="a: 1" />);
// Plain Mantine Code before the grammar resolves.
expect(screen.queryByTestId("prism")).not.toBeInTheDocument();
expect(screen.getByText('{"a":1}')).toBeInTheDocument();
expect(screen.getByText("a: 1")).toBeInTheDocument();
// After the lazy grammar loads, the prism runtime takes over.
const prism = await screen.findByTestId("prism");
expect(prism).toHaveAttribute("data-language", "json");
expect(registerLanguage).toHaveBeenCalledWith("json", {
__grammar: "json",
expect(prism).toHaveAttribute("data-language", "yaml");
expect(registerLanguage).toHaveBeenCalledWith("yaml", {
__grammar: "yaml",
});
});
@ -113,14 +110,14 @@ describe("CodeHighlight", () => {
it("reuses an already-registered grammar without re-importing", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
<CodeHighlight language="yaml" code="a: 1" />,
);
await screen.findByTestId("prism");
expect(registerLanguage).toHaveBeenCalledTimes(1);
unmount();
// A second mount finds json already registered: ready synchronously, no
// A second mount finds yaml already registered: ready synchronously, no
// second registerLanguage call.
renderWithMantine(<CodeHighlight language="json" code='{"b":2}' />);
renderWithMantine(<CodeHighlight language="yaml" code="b: 2" />);
expect(await screen.findByTestId("prism")).toBeInTheDocument();
expect(registerLanguage).toHaveBeenCalledTimes(1);
});
@ -128,7 +125,7 @@ describe("CodeHighlight", () => {
it("loads a second language reusing the already-loaded runtime", async () => {
const CodeHighlight = await loadComponent();
const { unmount } = renderWithMantine(
<CodeHighlight language="json" code='{"a":1}' />,
<CodeHighlight language="yaml" code="a: 1" />,
);
await screen.findByTestId("prism");
unmount();
@ -146,7 +143,7 @@ describe("CodeHighlight", () => {
const CodeHighlight = await loadComponent();
renderWithMantine(
<>
<CodeHighlight language="json" code='{"a":1}' />
<CodeHighlight language="yaml" code="a: 1" />
<CodeHighlight language="css" code=".a{}" />
</>,
);

View file

@ -35,7 +35,11 @@ type PrismRuntime = ComponentType<{
* below if the canonical Prism name differs) as the type matrix grows.
*/
const LANGUAGE_LOADERS: Record<string, () => Promise<{ default: Grammar }>> = {
json: () => import("react-syntax-highlighter/dist/esm/languages/prism/json"),
// No `json` entry, deliberately (#2151). JSON is rendered by the Ace-backed
// `JsonEditor` in read-only mode, which folds and numbers a large payload —
// things a Prism block cannot do. Registering the grammar here as well would
// leave two highlighters for one language, differing in appearance and
// drifting apart; there is no caller left that asks for it.
markup: () =>
import("react-syntax-highlighter/dist/esm/languages/prism/markup"),
css: () => import("react-syntax-highlighter/dist/esm/languages/prism/css"),

View file

@ -5,6 +5,7 @@ import type {
TextResourceContents,
} from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText } from "../../../test/aceEditor";
import { ContentViewer } from "./ContentViewer";
// Stub the lazy highlighter so JSON/XML/CSS branches are assertable
@ -28,16 +29,19 @@ describe("ContentViewer", () => {
expect(screen.getByText("hello world")).toBeInTheDocument();
});
it("pretty-prints JSON text", () => {
it("pretty-prints JSON text into the JSON editor", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
expect(getAceText()).toBe('{\n "a": 1\n}');
});
// Text that merely *starts* like JSON stays plain: presenting it in a JSON
// gutter would frame the server's prose as a malformed document (#2151).
it("falls back to raw text when JSON is malformed", () => {
const block: ContentBlock = { type: "text", text: "{ broken" };
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText("{ broken")).toBeInTheDocument();
expect(document.querySelector(".ace_editor")).toBeNull();
});
it("renders a copy overlay when copyable", () => {
@ -206,14 +210,90 @@ describe("ContentViewer", () => {
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("highlights a JSON text block when mimeType is application/json", () => {
it("renders a declared JSON text block in the JSON editor", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer block={block} mimeType="application/json" />,
);
const probe = screen.getByTestId("code-highlight");
expect(probe).toHaveAttribute("data-language", "json");
expect(probe.textContent).toContain('"a": 1');
expect(getAceText()).toBe('{\n "a": 1\n}');
// The Prism path is no longer taken for JSON — one highlighter per
// language, and Ace is the one that folds (#2151).
expect(screen.queryByTestId("code-highlight")).not.toBeInTheDocument();
});
// The server card's fixed-height, single-line box (`wrap={false}`) must not
// become a multi-line editor, whatever MIME it declares.
it("keeps a non-wrapping JSON block on the plain renderer", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer block={block} mimeType="application/json" wrap={false} />,
);
expect(document.querySelector(".ace_editor")).toBeNull();
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
});
// An expanded Protocol entry holds two of these and a list holds many pairs,
// so an unnamed editor leaves a screen reader tabbing through textboxes that
// all announce the same thing.
it("names the JSON editor from jsonLabel", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer
block={block}
mimeType="application/json"
jsonLabel="tools/call response JSON"
/>,
);
expect(
screen.getByLabelText(/tools\/call response JSON/),
).toBeInTheDocument();
});
it("falls back to a generic name when no label is given", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByLabelText(/JSON content/)).toBeInTheDocument();
});
it("keeps the copy overlay above the JSON editor", () => {
const block: ContentBlock = { type: "text", text: '{"a":1}' };
renderWithMantine(
<ContentViewer block={block} mimeType="application/json" copyable />,
);
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
});
// The `resource` block branch used to render every embedded resource in a
// plain code block, so a prompt message carrying JSON did not inherit this
// migration even though the text-block branch did.
it("renders an embedded JSON resource in the JSON editor", () => {
const block: ContentBlock = {
type: "resource",
resource: {
uri: "file:///a.json",
mimeType: "application/json",
text: '{"a":1}',
},
};
renderWithMantine(<ContentViewer block={block} />);
expect(getAceText()).toBe('{\n "a": 1\n}');
});
// Narrowed to JSON on purpose: routing every declared type through would put
// an embedded `text/html` resource into the sandboxed frame, which is a
// change to what a tool result is.
it("leaves a non-JSON embedded resource on the plain renderer", () => {
const block: ContentBlock = {
type: "resource",
resource: {
uri: "file:///a.html",
mimeType: "text/html",
text: "<p>hi</p>",
},
};
renderWithMantine(<ContentViewer block={block} />);
expect(screen.getByText("<p>hi</p>")).toBeInTheDocument();
expect(document.querySelector("iframe")).toBeNull();
});
it("renders nothing when neither block nor contents is provided", () => {
@ -274,15 +354,13 @@ describe("ContentViewer (resource contents)", () => {
).toBeInTheDocument();
});
it("highlights JSON contents", () => {
it("renders JSON contents in the JSON editor", () => {
renderWithMantine(
<ContentViewer
contents={text({ text: '{"a":1}', mimeType: "application/json" })}
/>,
);
const probe = screen.getByTestId("code-highlight");
expect(probe).toHaveAttribute("data-language", "json");
expect(probe.textContent).toContain('"a": 1');
expect(getAceText()).toBe('{\n "a": 1\n}');
});
it("indents and highlights XML contents", () => {

View file

@ -10,10 +10,12 @@ import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeHighlight } from "../CodeHighlight/CodeHighlight";
import { CopyButton } from "../CopyButton/CopyButton";
import { JsonEditor } from "../JsonEditor/JsonEditor";
import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo";
import {
formatJson,
formatXml,
formatJsonDocument,
getMimeKind,
isSafeHref,
isTextualKind,
@ -58,8 +60,27 @@ export interface ContentViewerProps {
* one line — don't pass it for multi-line content.
*/
wrap?: boolean;
/**
* Accessible name for the read-only JSON editor a JSON payload renders in.
*
* Say what the payload *is* whenever a screen can hold more than one: an
* expanded Protocol entry carries both its parameters and its response, and
* a list of them carries many such pairs — so the default leaves a screen
* reader tabbing through textboxes that all announce the same thing, with
* only the visible section headings to tell them apart.
*
* Reaches the JSON branch only; every other renderer here is not a control
* and takes no name.
*/
jsonLabel?: string;
}
/**
* Rows a read-only JSON payload renders before the editor scrolls internally
* instead of growing. See the note at the `JsonEditor` that uses it.
*/
const JSON_DISPLAY_MAX_LINES = 200;
function buildDataUri(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
@ -161,15 +182,95 @@ function HighlightedContent({
);
}
/**
* A read-only JSON payload, rendered in the same Ace editor the JSON *input*
* surfaces use (#2151).
*
* Highlighting is not what this buys — JSON already highlighted here, through
* the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is
* **folding**, line numbers and a gutter, which is the difference between
* reading a large `tools/call` result and scrolling past it. Prism's `json`
* grammar was dropped in the same change rather than kept beside this: two
* highlighters for one language drift, and nothing else asks for `json`.
*
* The copy affordance is unchanged — `CopyableWrapper` overlays it above
* whichever renderer was picked, so `copyable` keeps working across the swap.
* It copies the *original* text, not the pretty-printed form, matching every
* other branch here.
*/
function JsonContent({
text,
formatted,
copyable,
label,
}: {
/** The original payload — what the copy button copies. */
text: string;
/** Its pretty-printed form, already parsed once by whoever decided to use it. */
formatted: string;
copyable: boolean;
label: string;
}) {
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
<JsonEditor
ariaLabel={label}
value={formatted}
readOnly
// Sized to the payload, and capped.
//
// `minLines={1}` so a two-line result does not open six rows of blank
// editor. The cap is the more interesting number, and it is a trade:
//
// - Below it the editor grows to fit, so the *host's* scroll container
// is the only one — every consumer already provides the one it wants
// (the structured-output section caps at `mah` and scrolls; a result
// panel scrolls whole), and nesting a second scrollbar inside it is
// worse to use.
// - Above it Ace scrolls internally and renders only the rows its own
// viewport covers. That is the point of having a cap at all: the
// Protocol and Network lists are not virtualized and keep every
// entry's payload mounted inside its `Collapse`, so an uncapped
// editor would put a whole 10k-line response in the DOM per entry.
//
// 200 is chosen so essentially no real payload nests a scrollbar while
// the pathological one stays bounded.
minLines={1}
maxLines={JSON_DISPLAY_MAX_LINES}
/>
</CopyableWrapper>
);
}
function PlainTextContent({
text,
copyable,
wrap,
jsonLabel,
}: {
text: string;
copyable: boolean;
wrap: boolean;
jsonLabel: string;
}) {
// Untyped text that really parses as JSON gets the JSON renderer too — the
// heuristic is how a server that sent no MIME type still reads well. Not when
// `wrap` is false, though: that caller wants one clipped line at a fixed
// height, which a multi-line editor is not.
//
// The formatted text comes back from the same call that decided, so the
// payload is parsed once rather than once to test and again to render.
const asJson = wrap ? formatJsonDocument(text) : null;
if (asJson !== null) {
return (
<JsonContent
text={text}
formatted={asJson}
copyable={copyable}
label={jsonLabel}
/>
);
}
const displayText = looksLikeJson(text) ? formatJson(text) : text;
return (
<CopyableWrapper copyable={copyable} copyValue={text}>
@ -199,23 +300,40 @@ function TextualContent({
mimeType,
copyable,
wrap,
jsonLabel,
}: {
text: string;
mimeType: string | undefined;
copyable: boolean;
wrap: boolean;
jsonLabel: string;
}) {
const kind = mimeType ? getMimeKind(mimeType) : "text";
switch (kind) {
case "markdown":
return <MarkdownContent text={text} copyable={copyable} />;
case "json":
return (
<HighlightedContent
code={formatJson(text)}
language="json"
copyValue={text}
// `wrap={false}` is a fixed-height, single-line, ellipsis-clipped box
// (the server card's command/URL). Pretty-printed JSON collapsed onto one
// line is exactly what that prop is documented not to be used for, so
// that caller keeps the plain renderer.
return wrap ? (
<JsonContent
text={text}
// A *declared* `application/json` renders as JSON even when it does
// not parse — the server said what it sent — so this cannot go
// through `formatJsonDocument`, which reports unparseable text as
// not-JSON. `formatJson` returns the original on failure.
formatted={formatJson(text)}
copyable={copyable}
label={jsonLabel}
/>
) : (
<PlainTextContent
text={text}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
case "xml":
@ -249,7 +367,14 @@ function TextualContent({
</Stack>
);
default:
return <PlainTextContent text={text} copyable={copyable} wrap={wrap} />;
return (
<PlainTextContent
text={text}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
}
}
@ -277,11 +402,13 @@ function ResourceContent({
mimeType,
copyable,
wrap,
jsonLabel,
}: {
contents: TextResourceContents | BlobResourceContents;
mimeType: string;
copyable: boolean;
wrap: boolean;
jsonLabel: string;
}) {
if ("text" in contents) {
return (
@ -290,6 +417,7 @@ function ResourceContent({
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
}
@ -318,6 +446,7 @@ function ResourceContent({
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
}
@ -330,11 +459,13 @@ function BlockContent({
mimeType,
copyable,
wrap,
jsonLabel,
}: {
block: ContentBlock;
mimeType: string | undefined;
copyable: boolean;
wrap: boolean;
jsonLabel: string;
}) {
switch (block.type) {
case "text":
@ -344,24 +475,47 @@ function BlockContent({
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
case "image":
return <ImageContent data={block.data} mimeType={block.mimeType} />;
case "audio":
return <AudioContent data={block.data} mimeType={block.mimeType} />;
case "resource":
case "resource": {
// An embedded *text* resource that declares JSON goes through the same
// dispatch a text block does, so this branch inherits the JSON renderer
// rather than staying on a plain code block (#2151).
//
// Narrowed to JSON on purpose, the same way the Network tab's bodies are:
// routing every declared type through would also put an embedded
// `text/html` resource into the sandboxed frame and a `text/csv` one into
// a table. The Resources screen already renders resource contents that
// way, so doing it here too is defensible — but it is a change to what a
// tool result *is*, and belongs to whoever decides that rather than to
// this swap.
const embedded = "text" in block.resource ? block.resource : null;
if (embedded && getMimeKind(embedded.mimeType ?? "") === "json") {
return (
<TextualContent
text={embedded.text}
mimeType={embedded.mimeType}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
}
return (
<Stack gap="xs">
<ContentWrapper>
<CodeBlock>
{"text" in block.resource
? block.resource.text
: `[blob: ${block.resource.uri}]`}
{embedded ? embedded.text : `[blob: ${block.resource.uri}]`}
</CodeBlock>
</ContentWrapper>
</Stack>
);
}
case "resource_link":
// Static metadata only. The interactive, read-on-demand presentation
// lives in the `groups/ResourceLink` group, rendered by content-block
@ -384,6 +538,7 @@ export function ContentViewer({
copyable = false,
mimeType,
wrap = true,
jsonLabel = "JSON content",
}: ContentViewerProps) {
if (contents) {
const effective =
@ -394,6 +549,7 @@ export function ContentViewer({
mimeType={effective}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
}
@ -404,6 +560,7 @@ export function ContentViewer({
mimeType={mimeType}
copyable={copyable}
wrap={wrap}
jsonLabel={jsonLabel}
/>
);
}

View file

@ -7,6 +7,7 @@ import {
getMimeKind,
isSafeHref,
isTextualKind,
formatJsonDocument,
looksLikeJson,
PREVIEW_HTML_CSP,
tryDecodeBase64ToBytes,
@ -102,6 +103,35 @@ describe("formatJson", () => {
it("returns the input unchanged when invalid", () => {
expect(formatJson("{ broken")).toBe("{ broken");
});
// Pretty-printing is a round trip through JS's number type, which is not
// lossless. Reformatting these would show the reader something other than
// what the sender put on the wire — the worst kind of bug in an inspector.
it("leaves a payload alone when reindenting would change a value", () => {
// Would render as …992.
expect(formatJson('{"id":9007199254740993}')).toBe(
'{"id":9007199254740993}',
);
// `JSON.parse` yields Infinity, which `JSON.stringify` writes as null.
expect(formatJson('{"limit":1e400}')).toBe('{"limit":1e400}');
});
// The starkest instance of the same class: only the *second* `role` survives
// `JSON.parse`, so reindenting would show the reader less than the server
// sent.
it("leaves a payload alone when it names the same member twice", () => {
expect(formatJson('{"role":"user","role":"admin"}')).toBe(
'{"role":"user","role":"admin"}',
);
});
// The spelling of a number changes on a round trip and its *value* does not,
// so these still get indented: `0.10000000000000001` and `0.1` are the same
// double, and `1e2` and `100` are the same number.
it("still formats a payload whose numbers only change spelling", () => {
expect(formatJson('{"n":0.10000000000000001}')).toBe('{\n "n": 0.1\n}');
expect(formatJson('{"n":1e2}')).toBe('{\n "n": 100\n}');
});
});
describe("looksLikeJson", () => {
@ -112,6 +142,38 @@ describe("looksLikeJson", () => {
});
});
describe("formatJsonDocument", () => {
it("pretty-prints text that really parses as JSON", () => {
expect(formatJsonDocument(' {"a":1}')).toBe('{\n "a": 1\n}');
expect(formatJsonDocument("[1,2]")).toBe("[\n 1,\n 2\n]");
});
// The stricter half of the heuristic (#2151). Text that merely *starts* like
// JSON must not be handed to the JSON editor: a note beginning `{` would then
// be presented as a document, in a JSON gutter, framed as malformed.
// Still a JSON document — it just does not get reindented when reindenting
// would change what it says.
it("returns the source unindented when reindenting would change a value", () => {
expect(formatJsonDocument('{"id":9007199254740993}')).toBe(
'{"id":9007199254740993}',
);
expect(formatJsonDocument('{"limit":1e400}')).toBe('{"limit":1e400}');
});
it("reports text that only looks like JSON as not JSON", () => {
expect(formatJsonDocument("{ broken")).toBeNull();
expect(formatJsonDocument("[unclosed")).toBeNull();
expect(formatJsonDocument("plain")).toBeNull();
});
// A bare scalar is valid JSON but never looked like a document, so it stays
// on the plain renderer — otherwise ordinary prose that happens to be a
// number would open an editor.
it("rejects a scalar that never looked like a document", () => {
expect(formatJsonDocument("42")).toBeNull();
});
});
describe("formatXml", () => {
it("indents nested elements", () => {
const out = formatXml("<a><b>x</b></a>");

View file

@ -1,8 +1,16 @@
/**
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept in
* a dependency-free module so they can be unit-tested in isolation and reused by
* the blob renderers (PDF / CSV / HTML) without dragging in React.
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept
* free of component dependencies so they can be unit-tested in isolation and
* reused by the blob renderers (PDF / CSV / HTML) without dragging in React.
* The predicates imported below are the exception that proves it: plain
* functions over a string, shared so the viewer and the JSON editors cannot
* disagree about which payloads survive a round trip.
*/
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
import {
findDuplicateObjectKey,
findUnsendableNumberLiteral,
} from "../../../utils/jsonObjectDraft";
/**
* The renderer family a MIME type maps to. `ContentViewer` switches on this to
@ -130,18 +138,82 @@ export function tryDecodeBase64ToBytes(
/** Pretty-print JSON text; returns the input unchanged when it doesn't parse. */
export function formatJson(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2);
const parsed: unknown = JSON.parse(content);
return reformatsFaithfully(content, parsed)
? JSON.stringify(parsed, null, 2)
: content;
} catch {
return content;
}
}
/**
* Whether pretty-printing this payload would show the reader the same values
* the sender put on the wire.
*
* Pretty-printing here is `JSON.parse` then `JSON.stringify`, which is a
* *round trip through JavaScript's number type* — and that is not lossless.
* A server sending `{"id":9007199254740993}` would be shown `…992`, and one
* sending `{"limit":1e400}` would be shown `null`, because `JSON.parse` yields
* `Infinity` and `JSON.stringify` writes that as null. Neither is a formatting
* difference; both are the viewer telling the reader something untrue about
* the payload it received, which for an inspector is the worst kind of bug.
*
* So when the round trip would alter a value, the payload is displayed exactly
* as it arrived — unindented, but honest. The copy button already copied the
* original text either way; this makes what is on screen agree with it.
*
* The same checks the draft parsers use, which is why they live in
* `utils/jsonObjectDraft` rather than beside either caller.
*/
function reformatsFaithfully(text: string, parsed: unknown): boolean {
return (
isSerializableJson(parsed) &&
findUnsendableNumberLiteral(text) === null &&
// A duplicate member name is the same class and the starkest instance:
// `{"role":"user","role":"admin"}` would be *shown* as only the second,
// which is the viewer hiding what the server sent.
findDuplicateObjectKey(text) === null
);
}
/** Heuristic: does this plain text (no MIME) look like a JSON document? */
export function looksLikeJson(text: string): boolean {
const trimmed = text.trimStart();
return trimmed.startsWith("{") || trimmed.startsWith("[");
}
/**
* Pretty-print untyped text if it really is a JSON document, or report that it
* is not.
*
* {@link looksLikeJson} only tests the first character, which is enough to
* decide whether to *try* pretty-printing — a failed parse falls back to the
* original text and nothing is lost. It is not enough to decide to hand the
* text to the JSON editor: a note beginning `{` would then be presented as a
* JSON document, in a JSON gutter, with the server's plain text framed as
* malformed. A declared `application/json` is different — there the server said
* what it sent, so its own broken payload is shown as JSON on purpose.
*
* Returns the formatted text rather than a boolean so the caller does not parse
* a second time to render what this already parsed to decide. Protocol and
* Network entries hand their serialized bodies over as untyped text and are not
* virtualized, so a long history pays for every parse on every render.
*/
export function formatJsonDocument(text: string): string | null {
if (!looksLikeJson(text)) return null;
try {
const parsed: unknown = JSON.parse(text);
// Still a JSON document either way — it just does not get reindented when
// reindenting would change what it says. See `reformatsFaithfully`.
return reformatsFaithfully(text, parsed)
? JSON.stringify(parsed, null, 2)
: text;
} catch {
return null;
}
}
/**
* Indent a single-line or minified XML/HTML-ish document for readability before
* syntax highlighting. Hand-rolled: split on `>\s*<` boundaries, then track a

View file

@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { EditReplayButton } from "./EditReplayButton";
const meta: Meta<typeof EditReplayButton> = {
title: "Elements/EditReplayButton",
component: EditReplayButton,
args: { onClick: fn() },
};
export default meta;
type Story = StoryObj<typeof EditReplayButton>;
export const Default: Story = {};
export const Activated: Story = {
play: async ({ args, canvasElement, userEvent }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: "Edit and replay" }),
);
await expect(args.onClick).toHaveBeenCalled();
},
};

View file

@ -0,0 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { EditReplayButton } from "./EditReplayButton";
describe("EditReplayButton", () => {
it("is named for assistive tech", () => {
renderWithMantine(<EditReplayButton onClick={vi.fn()} />);
expect(
screen.getByRole("button", { name: "Edit and replay" }),
).toBeInTheDocument();
});
it("invokes onClick when activated", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderWithMantine(<EditReplayButton onClick={onClick} />);
await user.click(screen.getByRole("button", { name: "Edit and replay" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,31 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import { MdEditNote } from "react-icons/md";
export interface EditReplayButtonProps {
/** Open the editor seeded with the owning entry's request params. */
onClick: () => void;
}
/**
* "Edit and replay" — the sibling of {@link ReplayButton} that stops for the
* params instead of re-sending them verbatim (#2151).
*
* Same subtle gray icon-button styling as Replay and the pin toggle, so the
* three read as one control cluster on a Protocol entry.
*/
const EditReplayActionIcon = ActionIcon.withProps({
variant: "subtle",
color: "gray",
size: "md",
"aria-label": "Edit and replay",
});
export function EditReplayButton({ onClick }: EditReplayButtonProps) {
return (
<Tooltip label="Edit and replay">
<EditReplayActionIcon onClick={onClick}>
<MdEditNote size={20} />
</EditReplayActionIcon>
</Tooltip>
);
}

View file

@ -0,0 +1,146 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent } from "storybook/test";
import { JsonEditor } from "./JsonEditor";
const meta: Meta<typeof JsonEditor> = {
title: "Elements/JsonEditor",
component: JsonEditor,
};
export default meta;
type Story = StoryObj<typeof JsonEditor>;
const SAMPLE = JSON.stringify(
{
trace: { id: "abc-123", sampled: true },
tags: ["alpha", "beta"],
retries: 3,
},
null,
2,
);
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
function aceEditor(canvasElement: HTMLElement): {
focus(): void;
getValue(): string;
} {
const container = canvasElement.querySelector(".ace_editor");
const editor = (
container as
| (HTMLElement & {
env?: { editor?: { focus(): void; getValue(): string } };
})
| null
)?.env?.editor;
if (!editor) throw new Error("Ace editor not mounted");
return editor;
}
/**
* Stateful wrapper: the editor is controlled on text by every real consumer, so
* a story without one would not accept a keystroke.
*
* Ace's *keyboard* behavior — auto-closing a brace, keeping an invalid draft
* raw, the worker's gutter annotation — is covered by the `JsonObjectInput`
* play functions, which drive this same editor through its object-shaped
* wrapper. What is left to pin here is the read-only mode those cannot reach.
*/
function Editable({ initial }: { initial: string }) {
const [text, setText] = useState(initial);
return (
<JsonEditor
ariaLabel="Payload JSON"
label="Payload"
value={text}
onChange={setText}
/>
);
}
export const Editing: Story = {
render: () => <Editable initial={SAMPLE} />,
};
export const Empty: Story = {
render: () => <Editable initial="" />,
};
export const WithError: Story = {
args: {
ariaLabel: "Payload JSON",
label: "Payload",
value: '{"a":',
description: "Sent with every request.",
error: "Not valid JSON — changes are not applied",
onChange: () => {},
},
};
/**
* Switched off by the form around it — what a tool panel does to every field
* while a call is in flight. Ace has no disabled state, so this is read-only
* plus a dimmed wrapper, `aria-disabled`, and removal from the tab order.
*/
export const Disabled: Story = {
args: {
ariaLabel: "Payload JSON",
label: "Payload",
value: SAMPLE,
disabled: true,
onChange: () => {},
},
parameters: {
a11y: {
config: {
rules: [
// Re-stated because a story's `a11y.config.rules` replaces the
// preview's rather than merging with it — dropping this would make
// `region` fire here and nowhere else.
{ id: "region", enabled: false },
// WCAG 1.4.3 exempts "inactive user interface components" from the
// contrast minimum, and this whole editor is one: the dimming is the
// affordance that says so. axe cannot see that, because the disabled
// state lives on Ace's hidden textarea while the text it measures is
// in the separately-rendered gutter — so it reports the dimmed line
// numbers as a violation on every row.
//
// Scoped to this story on purpose. Every other story here, and the
// whole read-only path, is still contrast-checked; a preview-level
// disable would retire the rule for the enabled editor too, which is
// where it has something to catch.
{ id: "color-contrast", enabled: false },
],
},
},
},
};
/**
* Display mode — what every read-only JSON payload in the app renders as
* (#2151). It keeps the folding and the line numbers a large `tools/call`
* result gains from, and takes no input.
*
* The keystroke assertion is the point: a read-only Ace editor is still
* focusable and still owns a hidden textarea, so "does not edit" is a real
* behavior to pin rather than a property of the markup.
*/
export const ReadOnly: Story = {
args: {
ariaLabel: "JSON content",
value: SAMPLE,
readOnly: true,
},
play: async ({ canvasElement }) => {
const editor = aceEditor(canvasElement);
editor.focus();
await userEvent.keyboard("x");
await expect(editor.getValue()).toBe(SAMPLE);
// The caret is hidden too — a blinking cursor in a box that swallows every
// keystroke reads as a broken field.
await expect(
canvasElement.querySelector(".json-editor-readonly"),
).not.toBeNull();
},
};

View file

@ -0,0 +1,249 @@
import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonEditor } from "./JsonEditor";
const LABEL = "Payload JSON";
/** Controlled on text, which is the contract every consumer uses. */
function Harness({
initial,
onChange,
}: {
initial: string;
onChange?: (text: string) => void;
}) {
const [text, setText] = useState(initial);
return (
<JsonEditor
ariaLabel={LABEL}
value={text}
onChange={(next) => {
setText(next);
onChange?.(next);
}}
/>
);
}
describe("JsonEditor", () => {
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();
});
// Ace recomputes the textarea's `aria-label` from its option only when the
// cursor moves, so a name that changes while the editor stays mounted — a
// tool refreshed in place under a new field title — would leave the visible
// label and the accessible name disagreeing until the user clicked into it.
it("re-applies the accessible name when it changes in place", () => {
const { rerender } = renderWithMantine(
<JsonEditor ariaLabel="Series JSON" value="" onChange={vi.fn()} />,
);
expect(screen.getByLabelText(aceLabel("Series JSON"))).toBeInTheDocument();
rerender(
<JsonEditor ariaLabel="Data points JSON" value="" onChange={vi.fn()} />,
);
expect(
screen.getByLabelText(aceLabel("Data points JSON")),
).toBeInTheDocument();
expect(screen.queryByLabelText(aceLabel("Series JSON"))).toBeNull();
});
it("shows the text it is given, verbatim", () => {
// Braces, not a bare JSX string: a JSX string attribute does not process
// escapes, so `'…\n…'` written inline would be a literal backslash-n.
renderWithMantine(<Harness initial={'{\n "a": 1\n}'} />);
expect(getAceText()).toBe('{\n "a": 1\n}');
});
it("reports the settled text of an edit", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial="" onChange={onChange} />);
await setAceText('{"a":1}');
expect(onChange).toHaveBeenLastCalledWith('{"a":1}');
});
// Ace fires a *remove* then an *insert* for a replace. Reporting the first
// would tell the consumer the document is empty, which for `JsonObjectInput`
// means `{}` — the user's saved metadata, wiped a keystroke before the
// replacement lands.
it("reports a replace once, not as an empty document first", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial='{"keep":1}' onChange={onChange} />);
await setAceText('{"next":2}');
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('{"next":2}');
});
it("uses Ace's dark theme under a dark color scheme", () => {
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();
});
describe("wrapper wiring", () => {
it("associates the label, description and error with the editor", () => {
renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value=""
onChange={vi.fn()}
label="Metadata"
description="Sent with every request."
error="Not valid JSON"
/>,
);
const input = screen.getByLabelText(aceLabel(LABEL));
expect(input).toHaveAttribute("aria-invalid", "true");
const describedBy = input.getAttribute("aria-describedby")?.split(" ");
expect(describedBy).toHaveLength(2);
// Both nodes exist and are the ones named — a description that is never
// announced, or an error pointing at nothing, is the failure mode here.
for (const id of describedBy ?? []) {
expect(document.getElementById(id)).toBeInTheDocument();
}
expect(screen.getByText("Not valid JSON")).toBeInTheDocument();
});
it("drops the error wiring once the text is valid again", () => {
const { rerender } = renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value=""
onChange={vi.fn()}
error="Not valid JSON"
/>,
);
rerender(<JsonEditor ariaLabel={LABEL} value="" onChange={vi.fn()} />);
const input = screen.getByLabelText(aceLabel(LABEL));
expect(input).not.toHaveAttribute("aria-invalid");
expect(input).not.toHaveAttribute("aria-describedby");
});
});
describe("read-only", () => {
it("is read-only when no change handler is supplied", () => {
renderWithMantine(<JsonEditor ariaLabel={LABEL} value='{"a":1}' />);
expect(getAceText()).toBe('{"a":1}');
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
});
it("is read-only when asked, even with a handler", () => {
renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
readOnly
/>,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
});
// Ace has no disabled state, so its hidden textarea keeps `tabindex="0"`
// unless something takes it out — leaving a keyboard user able to tab into
// a field the rest of the form has switched off, with nothing announced.
it("leaves the tab order and says so when disabled", () => {
const { rerender } = renderWithMantine(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
);
const input = screen.getByLabelText(aceLabel(LABEL));
expect(input).toHaveAttribute("tabindex", "0");
expect(input).not.toHaveAttribute("aria-disabled");
rerender(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
disabled
/>,
);
expect(input).toHaveAttribute("tabindex", "-1");
expect(input).toHaveAttribute("aria-disabled", "true");
// …and both are restored when the form re-enables.
rerender(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
);
expect(input).toHaveAttribute("tabindex", "0");
expect(input).not.toHaveAttribute("aria-disabled");
});
// Read-only is not disabled: a payload the user may need to scroll past the
// bottom of has to be reachable by keyboard (WCAG SC 2.1.1), which is why
// the preformatted block this replaced carried `tabIndex={0}`.
it("stays focusable when merely read-only", () => {
renderWithMantine(<JsonEditor ariaLabel={LABEL} value='{"a":1}' />);
const input = screen.getByLabelText(aceLabel(LABEL));
expect(input).toHaveAttribute("tabindex", "0");
expect(input).not.toHaveAttribute("aria-disabled");
});
it("is read-only and dimmed when disabled", () => {
const { container } = renderWithMantine(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
disabled
/>,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
// The only affordance a pointer user gets, since Ace has no disabled
// state of its own.
expect(container.querySelector('[style*="opacity: 0.6"]')).not.toBeNull();
});
// Read-only is derived state, so both transitions happen to an editor that
// is already mounted — and react-ace's `componentDidUpdate` reads
// `prevProps.className.trim()` with no guard when the class changes. An
// `undefined` on either side of that swap throws, or writes the literal
// class name "undefined" onto the element.
it("becomes editable when a handler arrives", () => {
const { rerender } = renderWithMantine(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' />,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
rerender(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
);
expect(document.querySelector(".json-editor-readonly")).toBeNull();
expect(document.querySelector(".undefined")).toBeNull();
});
// The direction a tool form takes when a call goes in flight: the panel
// disables the whole form, so an editable editor becomes read-only in
// place. This threw before the class was made unconditionally a string.
it("becomes read-only when the form is disabled mid-edit", () => {
const { rerender } = renderWithMantine(
<JsonEditor ariaLabel={LABEL} value='{"a":1}' onChange={vi.fn()} />,
);
expect(document.querySelector(".json-editor-readonly")).toBeNull();
rerender(
<JsonEditor
ariaLabel={LABEL}
value='{"a":1}'
onChange={vi.fn()}
disabled
/>,
);
expect(document.querySelector(".json-editor-readonly")).not.toBeNull();
});
});
});

View file

@ -0,0 +1,300 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } 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";
// 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);
export interface JsonEditorProps {
/**
* The text the editor displays. This is a **text** contract, not a JSON one:
* the editor never parses, so a caller holding a value keeps its own draft
* text and decides for itself what an unparseable draft means.
*/
value: string;
/**
* Settled text after an edit. Omitted for a read-only editor.
*
* Called once per edit, not once per Ace change event — see the coalescing
* note on `handleChange`.
*/
onChange?: (text: string) => void;
/**
* Display only: no caret, no editing, and no JSON worker (its annotations
* describe a document the user cannot fix). Implied when `onChange` is
* omitted.
*/
readOnly?: boolean;
/**
* 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;
label?: ReactNode;
description?: ReactNode;
/** Rendered by the wrapper and referenced from the editor's `aria-describedby`. */
error?: ReactNode;
withAsterisk?: boolean;
/**
* Non-editable *and* visibly inert, for a form whose whole surface is
* disabled. Ace has no disabled state of its own, so this is read-only plus
* a dimmed wrapper.
*/
disabled?: boolean;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
maxLines?: number;
}
/**
* The Ace-backed JSON editor every JSON-typing and JSON-displaying surface in
* the web client renders (#2151).
*
* 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 a nested
* payload, numbers its lines, and — via its JSON worker — marks the offending
* line in the gutter instead of reporting one message for the whole document.
* Read-only it keeps the folding and the line numbers, which is what a large
* response payload gains over a preformatted block.
*
* Deliberately **text in, text out**. The two editing contracts above it want
* different things from an unparseable draft — `JsonObjectInput` keeps the last
* valid object and says nothing to its parent, `SchemaJsonField` reports
* `undefined` and blocks submission — and neither can be expressed by a
* component that decides what a draft means. So parsing, and the draft/value
* split that goes with it, belongs to each caller; only the editing surface is
* shared.
*/
export function JsonEditor({
value,
onChange,
readOnly,
ariaLabel,
label,
description,
error,
withAsterisk,
disabled = false,
minLines = 6,
maxLines = 24,
}: JsonEditorProps) {
// `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,
});
// 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);
// Read through a ref so the coalescing flush always calls the current
// handler, without the handler's identity being part of any dependency list.
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
/**
* 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 report the momentarily-empty document as the
* user's answer: for `JsonObjectInput` that is `{}`, which overwrites the
* metadata they had just selected; for a raw-JSON form it is a cleared
* arguments object. If the replacement text then failed to parse, that empty
* value 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;
onChangeRef.current?.(pendingTextRef.current);
});
};
const wrapperId = useId();
const editorRef = useRef<Ace.Editor | null>(null);
const isReadOnly = readOnly === true || disabled || onChange === undefined;
const hasError = error !== undefined && error !== null && error !== false;
// `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 editor = editorRef.current;
const element = editor?.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 (!editor || !element) return;
element.id = wrapperId;
// Re-apply the accessible name, not just set it once at mount.
//
// `setOptions` does update Ace's *option* when `ariaLabel` changes, but Ace
// only recomputes the textarea's `aria-label` from it inside
// `TextInput.setAriaLabel()`, which it calls when the cursor moves. So a
// name that changes while the editor stays mounted — a tool refreshed in
// place under a new field title, which `SchemaJsonField` passes straight
// through — leaves the visible label and the accessible name disagreeing
// until the user happens to click into the box.
//
// `onLoad` still does this too: an effect runs after paint, and this is the
// control's only name, so mount should not present a frame without it.
editor.setOption("textInputAriaLabel", ariaLabel);
editor.textInput.setAriaLabel();
// 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`,
hasError ? `${wrapperId}-error` : null,
].filter((id) => id !== null);
if (describedBy.length > 0) {
element.setAttribute("aria-describedby", describedBy.join(" "));
} else {
element.removeAttribute("aria-describedby");
}
if (hasError) {
element.setAttribute("aria-invalid", "true");
} else {
element.removeAttribute("aria-invalid");
}
// `disabled` and `readOnly` are different states here, and only one of them
// leaves the tab order.
//
// A **read-only** editor is a rendering of a payload the user may need to
// read past the bottom of, so it stays focusable — the same reason the
// preformatted block it replaced carries `tabIndex={0}` (WCAG SC 2.1.1).
//
// A **disabled** one is a control the rest of the form has switched off. Ace
// has no disabled state, so without this its hidden textarea keeps
// `tabindex="0"` and a keyboard user tabs into a field every other control
// beside it has dropped out of the order — landing somewhere that looks
// editable and silently swallows what they type. Assistive tech is told
// nothing either, since dimming is a purely visual signal.
if (disabled) {
element.setAttribute("aria-disabled", "true");
element.tabIndex = -1;
} else {
element.removeAttribute("aria-disabled");
element.tabIndex = 0;
}
}, [wrapperId, hasError, description, disabled, ariaLabel]);
return (
<Input.Wrapper
id={wrapperId}
label={label}
description={description}
error={error}
withAsterisk={withAsterisk}
// Ace has no disabled state, so the only signal a pointer user gets is
// that the surface is dimmed — the same affordance a disabled Mantine
// input carries.
opacity={disabled ? 0.6 : undefined}
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`}
// A class, not a style: Ace renders its own DOM and paints a caret even
// when read-only, which reads as an editable field whose keystrokes are
// being swallowed. Hiding it needs a selector into that DOM, which is
// the same reason the gutter override in App.css exists.
//
// `""` rather than `undefined` for the off case: react-ace's
// `componentDidUpdate` reads `prevProps.className.trim()` whenever the
// class changes, with no guard — so going *to* a class from `undefined`
// throws, and going *from* one to `undefined` writes the literal class
// name "undefined" onto the element. Both are reachable here, because
// read-only is derived state: a tool form disables itself while a call
// is in flight, which flips this on an editor already mounted.
className={isReadOnly ? "json-editor-readonly" : ""}
value={value}
onChange={handleChange}
readOnly={isReadOnly}
width="100%"
minLines={minLines}
maxLines={maxLines}
tabSize={2}
showPrintMargin={false}
// A read-only editor is a rendering of someone else's payload, so it
// carries none of the caret furniture an editable one does.
highlightActiveLine={!isReadOnly}
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. Pointless on a read-only
// document — the reader cannot act on what it flags — and actively
// misleading where the text is a *fragment* rendered for display.
useWorker: !isReadOnly,
// 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,
highlightGutterLine: !isReadOnly,
}}
/>
</Input.Wrapper>
);
}

View file

@ -1,22 +1,8 @@
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 { useState } from "react";
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);
import { JsonEditor } from "../JsonEditor/JsonEditor";
/** Two-space canonical form — the same shape the catalog file is written in. */
function serialize(value: StrictJsonObject): string {
@ -28,12 +14,7 @@ export interface JsonObjectInputProps {
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.
*/
/** See {@link JsonEditor}'s prop of the same name — Ace names its own input. */
ariaLabel: string;
/** Rows shown before the editor grows; it autosizes between these bounds. */
minLines?: number;
@ -50,10 +31,9 @@ export interface JsonObjectInputProps {
* `_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 editing surface is {@link JsonEditor}, shared with every other JSON
* surface in the client (#2151). What lives here is the *contract*: an object
* in, an object out, and a decision about what an unparseable draft means.
*
* 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
@ -75,8 +55,8 @@ export function JsonObjectInput({
label,
description,
ariaLabel,
minLines = 6,
maxLines = 24,
minLines,
maxLines,
}: JsonObjectInputProps) {
const [draft, setDraft] = useState(() => serialize(value));
// The canonical JSON of the last value this component emitted.
@ -85,63 +65,22 @@ export function JsonObjectInput({
// 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,
});
const handleChange = (text: string) => {
setDraft(text);
const result = parseJsonObjectDraft(text);
if (!result.ok) return;
setEchoed(serialize(result.value));
onChange(result.value);
};
const parsed = parseJsonObjectDraft(draft);
// 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);
@ -152,100 +91,16 @@ export function JsonObjectInput({
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}
<JsonEditor
value={draft}
onChange={handleChange}
label={label}
description={description}
ariaLabel={ariaLabel}
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>
minLines={minLines}
maxLines={maxLines}
/>
);
}

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { Tool } from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { setAceTextByLabel } from "../../../test/aceEditor";
import { AppDetailPanel } from "./AppDetailPanel";
const noFieldsTool: Tool = {
@ -179,7 +180,6 @@ describe("AppDetailPanel", () => {
// `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",
@ -192,11 +192,10 @@ describe("AppDetailPanel", () => {
const openApp = screen.getByRole("button", { name: /open app/i });
expect(openApp).not.toBeDisabled();
const jsonInput = screen.getByLabelText(/Series/);
await user.type(jsonInput, "x");
await setAceTextByLabel(/Series/, "x");
expect(openApp).toBeDisabled();
await user.clear(jsonInput);
await setAceTextByLabel(/Series/, "");
expect(openApp).not.toBeDisabled();
});
});

View file

@ -0,0 +1,51 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, within } from "storybook/test";
import { EditReplayModal } from "./EditReplayModal";
const meta: Meta<typeof EditReplayModal> = {
title: "Groups/EditReplayModal",
component: EditReplayModal,
args: {
opened: true,
method: "tools/call",
params: { name: "get_weather", arguments: { city: "Boston" } },
onSend: fn(),
onClose: fn(),
},
};
export default meta;
type Story = StoryObj<typeof EditReplayModal>;
export const Default: Story = {};
/** A request that carried no params — `tools/list` and friends. */
export const NoParams: Story = {
args: { method: "tools/list", params: undefined },
};
/**
* Ace's own editing behavior is covered by the `JsonObjectInput` play
* functions; what this pins is the gate on top of it — invalid text must not be
* sendable, because unlike the metadata editor this modal commits.
*/
export const InvalidDraftBlocksSend: Story = {
play: async ({ canvasElement, userEvent }) => {
const canvas = within(canvasElement);
// Modals render in a portal, so the body is the canvas here.
const body = within(document.body);
const editor = document.querySelector(".ace_editor") as HTMLElement & {
env: { editor: { focus(): void; selectAll(): void } };
};
editor.env.editor.focus();
editor.env.editor.selectAll();
await userEvent.keyboard("{Backspace}");
// A bare number: valid JSON, wrong shape for JSON-RPC `params`.
await userEvent.keyboard("42");
await expect(await body.findByText(/must be a JSON object/i)).toBeVisible();
await expect(body.getByRole("button", { name: "Send" })).toBeDisabled();
// The canvas itself holds no controls — everything is portalled.
await expect(canvas.queryByRole("button", { name: "Send" })).toBeNull();
},
};

View file

@ -0,0 +1,306 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText, setAceText } from "../../../test/aceEditor";
import type { Tool } from "@modelcontextprotocol/client";
import { EditReplayModal } from "./EditReplayModal";
const TOOLS: Tool[] = [
{
name: "echo",
inputSchema: {
type: "object",
properties: { message: { type: "string" } },
},
},
{
name: "add",
inputSchema: { type: "object", properties: { count: { type: "number" } } },
},
];
const baseProps = {
opened: true,
method: "tools/call",
onSend: vi.fn(),
onClose: vi.fn(),
};
describe("EditReplayModal", () => {
it("renders nothing while closed", () => {
renderWithMantine(<EditReplayModal {...baseProps} opened={false} />);
expect(screen.queryByRole("button", { name: "Send" })).toBeNull();
});
it("seeds the editor with the entry's params and names the method", () => {
renderWithMantine(
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
);
expect(getAceText()).toBe('{\n "name": "echo"\n}');
expect(screen.getByText("tools/call")).toBeInTheDocument();
});
it("opens empty for a request that carried no params", () => {
renderWithMantine(<EditReplayModal {...baseProps} method="tools/list" />);
expect(getAceText()).toBe("");
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
});
it("sends the edited params and closes", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
const onClose = vi.fn();
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
onSend={onSend}
onClose={onClose}
/>,
);
await setAceText('{"name":"add","arguments":{"a":1}}');
await user.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledWith({
name: "add",
arguments: { a: 1 },
});
expect(onClose).toHaveBeenCalledTimes(1);
});
// `null`, not `undefined`: the caller has to be able to tell "the user
// cleared the editor" from "the user did not edit anything" (#2151).
it("reports an emptied editor as no params at all", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
onSend={onSend}
/>,
);
await setAceText("");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(onSend).toHaveBeenCalledWith(null);
});
// Unlike the metadata editor, this modal has a commit gesture to gate — so
// invalid text must disable it rather than silently sending the last value
// that parsed.
it("disables Send while the draft does not parse", async () => {
renderWithMantine(
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
);
await setAceText('{"name":');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
});
it("disables Send for JSON that is not an object", async () => {
renderWithMantine(<EditReplayModal {...baseProps} />);
await setAceText("42");
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/must be a JSON object/i)).toBeInTheDocument();
});
// `JSON.parse("1e400")` yields `Infinity`, which `JSON.stringify` writes back
// as `null` — so Send would dispatch a number the user never typed while the
// editor still showed what they wrote.
it("disables Send for a number that cannot survive being sent", async () => {
renderWithMantine(<EditReplayModal {...baseProps} />);
await setAceText('{"limit":1e400}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/Numbers must be finite/)).toBeInTheDocument();
});
// Replay dispatches through the typed client methods, so a param they have no
// room for is dropped. Saying which, rather than seeding it into the editor,
// is what keeps the modal from inviting an edit that Send discards.
it("names the params replay cannot re-send", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
droppedParamKeys={["_meta"]}
/>,
);
expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument();
});
it("says nothing when every param survives", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ name: "echo" }}
droppedParamKeys={[]}
/>,
);
expect(screen.queryByText(/not re-sent/)).toBeNull();
});
it("agrees with its verb for several dropped params", () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
params={{ cursor: "abc" }}
droppedParamKeys={["_meta", "extra"]}
/>,
);
expect(
screen.getByText(/`_meta`, `extra` are not re-sent/),
).toBeInTheDocument();
});
// Seeding from the projection is only half of it: a key the dispatcher does
// not read can be *added* to the draft, and Send would then discard an edit
// the user was looking at.
it("disables Send for a key this method's dispatch would not read", async () => {
renderWithMantine(
<EditReplayModal {...baseProps} method="tools/list" params={undefined} />,
);
await setAceText('{"cursor":"abc","_meta":{"progressToken":1}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`_meta` is not carried by tools\/list/),
).toBeInTheDocument();
});
it("re-enables Send once the unsupported key is removed", async () => {
renderWithMantine(
<EditReplayModal {...baseProps} method="tools/list" params={undefined} />,
);
await setAceText('{"cursor":"abc","_meta":{}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await setAceText('{"cursor":"abc"}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
expect(screen.queryByText(/not carried by/)).toBeNull();
});
// The check is per method, not a fixed key list: `arguments` is read by a
// tool call and by nothing on a list request.
it("judges a key against the method it would be sent to", async () => {
const { rerender } = renderWithMantine(
<EditReplayModal {...baseProps} method="tools/call" />,
);
await setAceText('{"name":"echo","arguments":{"a":1}}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
rerender(<EditReplayModal {...baseProps} method="tools/list" />);
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`name`, `arguments` are not carried by tools\/list/),
).toBeInTheDocument();
});
// Surviving the key check is not the same as being sent as written:
// `replayProtocolRequest` applies `arguments ?? {}`, so a null shown in the
// editor reaches the wire as `{}`.
it("disables Send for a null arguments, which would be sent as {}", async () => {
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
await setAceText('{"name":"echo","arguments":null}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/sent as `\{\}` when null/)).toBeInTheDocument();
});
it("disables Send for a non-object arguments", async () => {
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
await setAceText('{"name":"echo","arguments":[1,2]}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`arguments` must be a JSON object/),
).toBeInTheDocument();
});
// `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so
// the draft shows digits the wire will not carry.
it("disables Send for a whole number that loses digits when parsed", async () => {
renderWithMantine(<EditReplayModal {...baseProps} method="tools/call" />);
await setAceText('{"name":"echo","arguments":{"id":9007199254740993}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/would not arrive as written/)).toBeInTheDocument();
});
it("disables Send for a non-string prompt argument", async () => {
renderWithMantine(<EditReplayModal {...baseProps} method="prompts/get" />);
await setAceText('{"name":"greet","arguments":{"count":2}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(
screen.getByText(/`count` would be sent as JSON text/),
).toBeInTheDocument();
});
// `callTool` coerces string arguments to the type the schema declares, so
// `"2"` against a numeric field is sent as `2`.
it("disables Send for a tool argument the schema would coerce", async () => {
renderWithMantine(
<EditReplayModal {...baseProps} method="tools/call" tools={TOOLS} />,
);
await setAceText('{"name":"add","arguments":{"count":"2"}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/`count` would be converted/)).toBeInTheDocument();
});
// `name` is editable, so the schema to validate against is whichever tool the
// draft *now* names — not the one the entry happened to call.
it("re-validates against the tool the draft retargets to", async () => {
renderWithMantine(
<EditReplayModal
{...baseProps}
method="tools/call"
params={{ name: "echo", arguments: { message: "hi" } }}
tools={TOOLS}
/>,
);
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
// Retargeted at `add`, whose `count` is a number — the string would coerce.
await setAceText('{"name":"add","arguments":{"count":"2"}}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
// And back: `echo`'s `message` is a string, so nothing is converted.
await setAceText('{"name":"echo","arguments":{"message":"2"}}');
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
});
// `JSON.parse` keeps only the last occurrence, so the draft would show two
// cursors and Send would dispatch one.
it("disables Send for a document that names the same member twice", async () => {
renderWithMantine(<EditReplayModal {...baseProps} method="tools/list" />);
await setAceText('{"cursor":"first","cursor":"second"}');
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
expect(screen.getByText(/`cursor` appears twice/)).toBeInTheDocument();
});
it("closes without sending when cancelled", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
const onClose = vi.fn();
renderWithMantine(
<EditReplayModal {...baseProps} onSend={onSend} onClose={onClose} />,
);
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSend).not.toHaveBeenCalled();
});
// The draft belongs to the one opening of the modal it was typed in.
// Reopening must show the entry's params again, not whatever was typed and
// abandoned last time.
it("reseeds from the entry when reopened", async () => {
const { rerender } = renderWithMantine(
<EditReplayModal {...baseProps} params={{ name: "echo" }} />,
);
await setAceText('{"name":"abandoned"}');
rerender(
<EditReplayModal
{...baseProps}
opened={false}
params={{ name: "echo" }}
/>,
);
rerender(<EditReplayModal {...baseProps} params={{ name: "echo" }} />);
expect(getAceText()).toBe('{\n "name": "echo"\n}');
});
});

View file

@ -0,0 +1,245 @@
import { useState } from "react";
import type { Tool } from "@modelcontextprotocol/client";
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import {
duplicateKeyError,
findDuplicateObjectKey,
findUnsendableNumberLiteral,
isJsonObject,
unsendableNumberError,
} from "../../../utils/jsonObjectDraft";
import {
replayableParams,
reshapedReplayParam,
} from "../../../lib/protocolReplay";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
export interface EditReplayModalProps {
opened: boolean;
/** The method being replayed, shown so the editor is unambiguous. */
method: string;
/**
* The originating entry's params, seeded into the editor — already reduced to
* the ones replay will actually read. See {@link droppedParamKeys}.
*/
params?: Record<string, unknown>;
/**
* Param names the entry carried that replay cannot re-send, so the modal can
* say so rather than leave the difference to be discovered.
*
* Replay dispatches through the typed `InspectorClient` methods rather than
* re-sending the captured frame, and those signatures have no room for
* `_meta` (or for anything but `cursor` on a list request). Seeding the
* editor with them would invite an edit that Send silently discards, which is
* the one thing an inspector must not do.
*/
droppedParamKeys?: string[];
/**
* The connected server's tools, used only to detect an argument the client
* would coerce to the type the schema declares — see
* {@link reshapedReplayParam}.
*
* The whole list rather than the one the entry called, because `name` is
* itself editable: retargeting an `echo` replay at `add` has to be validated
* against `add`'s schema, not the one the entry happened to use.
*/
tools?: Tool[];
/**
* Re-issue the request with these params. `null` means "send no params",
* which is a request an empty editor can legitimately express — see
* {@link ReplayParamsOverride} for why it is not `undefined`.
*/
onSend: (params: Record<string, unknown> | null) => void;
onClose: () => void;
}
const MethodText = Text.withProps({
size: "sm",
c: "dimmed",
ff: "monospace",
});
const Footer = Group.withProps({ justify: "flex-end", gap: "sm" });
// Names the params replay cannot carry. `light` and gray rather than a warning
// colour: nothing is wrong and there is nothing for the user to fix — it is a
// property of how replay dispatches.
const DroppedNote = Alert.withProps({ variant: "light", color: "gray" });
/** "`a`, `b` are" / "`a` is" — the list, agreeing with its verb. */
function formatDroppedKeys(keys: string[]): string {
const names = keys.map((key) => `\`${key}\``).join(", ");
return `${names} ${keys.length === 1 ? "is" : "are"}`;
}
/**
* What the editor's text currently means: the params to send, or why they
* cannot be sent.
*
* Empty text is "no params" rather than an error — a `tools/list` replay
* genuinely carries none, and an empty editor wearing a red error would read as
* broken. A non-object is reported separately from text that is not JSON at
* all: JSON-RPC `params` must be a structured value, so `42` is a shape that
* cannot be sent however finished it is. A number that overflows, and a key
* this method's dispatch does not read, are the two subtle cases — see the
* notes on those branches.
*/
function parseParamsDraft(
method: string,
text: string,
tools: Tool[] | undefined,
):
| { ok: true; value: Record<string, unknown> | null }
| { ok: false; error: string } {
if (text.trim() === "") return { ok: true, value: null };
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: "Not valid JSON — this cannot be sent" };
}
if (!isJsonObject(parsed)) {
return { ok: false, error: "Params must be a JSON object (`{ … }`)" };
}
// `JSON.parse` accepts numeric literals it cannot represent: `1e400` parses to
// `Infinity`, which `JSON.stringify` then writes as `null`. Accepting it here
// would show the user one value and send another — the one thing an inspector
// must not do. Same guard, same reason, as `parseJsonObjectDraft`.
if (!isSerializableJson(parsed)) {
return {
ok: false,
error:
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
};
}
const unsendable = findUnsendableNumberLiteral(text);
if (unsendable !== null) {
return { ok: false, error: unsendableNumberError(unsendable) };
}
const duplicate = findDuplicateObjectKey(text);
if (duplicate !== null) {
return { ok: false, error: duplicateKeyError(duplicate) };
}
// Seeding from the projection is only half of it: a key the dispatcher does
// not read can also be *added* to the draft, and Send would then discard an
// edit the user was looking at. Checked with the same function the seed is
// built from, so the editor and the dispatcher cannot disagree about which
// keys survive.
const { dropped } = replayableParams(method, parsed);
if (dropped.length > 0) {
return {
ok: false,
error: `${formatDroppedKeys(dropped)} not carried by ${method} — remove ${
dropped.length === 1 ? "it" : "them"
} so this matches what is sent`,
};
}
// Surviving the key check is not the same as being sent as written: see
// `reshapedReplayParam` for the two ways `arguments` does not.
// Resolved from the *draft's* name, so retargeting the call re-validates
// against the tool it would actually reach.
const named = typeof parsed.name === "string" ? parsed.name : undefined;
const reshaped = reshapedReplayParam(
method,
parsed,
tools?.find((candidate) => candidate.name === named),
);
if (reshaped !== null) {
return { ok: false, error: reshaped };
}
return { ok: true, value: parsed };
}
/**
* The modal's body, split out so its draft is owned by a component Mantine
* unmounts on close — which is what makes reopening start from the entry's
* params again rather than from whatever was last typed and abandoned.
*/
function EditReplayForm({
method,
params,
droppedParamKeys,
tools,
onSend,
onClose,
}: Omit<EditReplayModalProps, "opened">) {
const [draft, setDraft] = useState(() =>
params === undefined ? "" : JSON.stringify(params, null, 2),
);
const parsed = parseParamsDraft(method, draft, tools);
return (
<Stack gap="sm">
<MethodText>{method}</MethodText>
{droppedParamKeys && droppedParamKeys.length > 0 && (
<DroppedNote>
<Text size="xs">
{`Replay re-issues this request through the client's own ${method}
method, which carries only the fields below, so
${formatDroppedKeys(droppedParamKeys)} not re-sent.`}
</Text>
</DroppedNote>
)}
<JsonEditor
ariaLabel="Request params"
label="Params"
value={draft}
onChange={setDraft}
error={parsed.ok ? undefined : parsed.error}
minLines={8}
maxLines={24}
/>
<Footer>
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
// Unlike the metadata editor, this modal *has* a commit gesture to
// gate — so invalid text disables Send rather than quietly sending
// the last value that parsed. Same reasoning as `SchemaJsonField`.
disabled={!parsed.ok}
onClick={() => {
/* v8 ignore next -- Send is disabled while the draft is invalid */
if (!parsed.ok) return;
onSend(parsed.value);
onClose();
}}
>
Send
</Button>
</Footer>
</Stack>
);
}
/**
* Edit a Protocol entry's request params, then replay it (#2151).
*
* Replay itself is one click and unchanged; this is a second way to *supply*
* the params, not a second replay implementation — Send dispatches through the
* same `replayProtocolRequest` the plain Replay button does, so the same
* method gate and the same "Can't replay" failure toast apply.
*/
export function EditReplayModal({
opened,
method,
params,
droppedParamKeys,
tools,
onSend,
onClose,
}: EditReplayModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Edit and replay" size="lg">
<EditReplayForm
method={method}
params={params}
droppedParamKeys={droppedParamKeys}
tools={tools}
onSend={onSend}
onClose={onClose}
/>
</Modal>
);
}

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