Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1a2a36f
feat(web): use the Ace JSON editor everywhere, and restore the raw-JS…
cliffhall Aug 27, 2026
c9fb461
fix(web): address Copilot review on the shared JSON editor
cliffhall Aug 27, 2026
b4083ee
fix(web): reject non-finite numbers, and keep the branch picker with …
cliffhall Aug 27, 2026
12c00d8
fix(web): re-apply the editor's accessible name, and correct the READ…
cliffhall Aug 27, 2026
5abe289
fix(web): seed Edit-and-replay from what replay sends, name each JSON…
cliffhall Aug 27, 2026
4e7537e
fix(web): validate the edited replay draft, not just its seed
cliffhall Aug 27, 2026
0e9069a
fix(web): reject a reshaped replay param, and forward a declared JSON…
cliffhall Aug 27, 2026
8f4aa7f
fix(web): reject drafts whose digits or types would not survive being…
cliffhall Aug 27, 2026
fcdc65b
fix(web): read the literal, not the parsed number; project cursors an…
cliffhall Aug 27, 2026
03bf9ec
fix(web): resolve the replay tool from the draft, and say why a draft…
cliffhall Aug 27, 2026
390e0c4
fix(web): stop the read-only viewer reformatting a payload into a dif…
cliffhall Aug 27, 2026
1b28fc8
fix(web): detect duplicate object keys, which JSON.parse drops silently
cliffhall Aug 27, 2026
8aa6f01
fix(web): reject a negative-zero literal, which loses its sign when s…
cliffhall Aug 27, 2026
684eb9c
fix(web): reject an underflowing literal, and route embedded JSON res…
cliffhall Aug 27, 2026
592446f
Merge branch 'v2/main' into v2/feat/2151-ace-json-editor
cliffhall Aug 27, 2026
ccd9908
Merge branch 'v2/main' into v2/feat/2151-ace-json-editor
cliffhall Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions clients/web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
}));
Expand Down Expand Up @@ -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",
});
});

Expand Down Expand Up @@ -113,22 +110,22 @@ 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);
});

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();
Expand All @@ -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{}" />
</>,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading