fix: address the Copilot review of the Ace editor

- `isProgressToken` used `Number.isInteger`, but zod 4's `.int()` rejects
  anything past `MAX_SAFE_INTEGER` — verified against the pinned zod — so an
  unsafe integer passed the guard and was left for the server to reject. Now
  `isSafeInteger`, with the boundary covered in both directions.

- `echoed` held only what this component last emitted, so it went stale the
  moment an external value was applied: A → B → A stranded the editor on B,
  because the second A matched the entry left from before B arrived and was
  dismissed as our own echo. It now advances to whatever is on screen.

- `Input.Wrapper` associates a *Mantine* input through context, and Ace is not
  one — it renders its own DOM. The wrapper's label and error node therefore
  pointed at nothing: the editor was never marked `aria-invalid`, never
  described by the error text, and its visible label did not focus it. Wired
  explicitly through the wrapper's id in an effect.

- Removed two leftover `[dbg]` logs an earlier regex cleanup missed.

- Documented the editor integration and the shared `cssVariablesResolver` in
  the web README, per the rule about new dependencies and patterns. Both
  footguns are recorded: the `?url` worker import, and the gutter override
  keying off Ace's cssClass rather than the theme module name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 17:36:43 -04:00
commit 7f10fa971c
5 changed files with 178 additions and 51 deletions

View file

@ -6,10 +6,10 @@ This README covers what's specific to the web client. For the repo-wide picture
## Two halves: `src/` (browser) and `server/` (Node)
| Path | Runs in | Purpose |
| --- | --- | --- |
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
| Path | Runs in | Purpose |
| --------- | ------- | ------------------------------------------------------------- |
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
The `server/` directory holds the Node-only backend:
@ -47,12 +47,12 @@ Two artifacts come out, both of which ship in the published package:
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). |
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
| 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). |
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
@ -74,23 +74,23 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage
The Apps screen exposes a small, stable set of `data-testid` / `data-*` attributes so an automated driver (deep-link auto-open, CI review harness) can `waitForSelector` on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable:
| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
| `data-testid="open-app"` | Open App button | Launches the selected app. |
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
| Attribute | Where | Meaning |
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
| `data-testid="open-app"` | Open App button | Launches the selected app. |
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair:
| Attribute | Where | Meaning |
| --- | --- | --- |
| Attribute | Where | Meaning |
| ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. |
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling).
@ -104,20 +104,20 @@ A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **conn
http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
```
| Param | Meaning |
| --- | --- |
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
| Param | Meaning |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
| `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. |
The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast:
| Attribute | Where | Meaning |
| --- | --- | --- |
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
| Attribute | Where | Meaning |
| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
### Landing on a rendered app
@ -127,11 +127,11 @@ Three further params extend the deep link to pre-select — and optionally auto-
…&openApp=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
```
| Param | Meaning |
| --- | --- |
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
| Param | Meaning |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
@ -139,15 +139,29 @@ The app-side render lifecycle is observable through the [MCP Apps screen automat
Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.
**`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`)
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.
Three 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.
- **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 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.
**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
Tests run under three Vitest **projects** (configured in `vite.config.ts`), each in the right environment:
| Project | Env | Scope | Script |
| --- | --- | --- | --- |
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
| Project | Env | Scope | Script |
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
- `npm test` runs the fast **unit** project (happy-dom). `test:watch` for the loop.
- **Integration** tests run in a real Node env (no happy-dom, 30s timeouts) and spawn `test-servers/build/test-server-stdio.js` as a subprocess, so `pretest`/the coverage script build the test servers first (`test-servers:build`).

View file

@ -79,11 +79,6 @@ describe("JsonObjectInput", () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText('{"a":');
console.log("[dbg] emitted:", screen.getByTestId("emitted").textContent);
console.log(
"[dbg] body has error?",
document.body.textContent?.includes("Not valid JSON"),
);
// Displayed verbatim — not re-escaped back through JSON.stringify.
expect(getAceText()).toBe('{"a":');
expect(
@ -133,6 +128,76 @@ describe("JsonObjectInput", () => {
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
});
it("applies an external value it showed before (A → B → A)", async () => {
// Regression: `echoed` used to hold only what this component last emitted,
// so returning to a previously-shown value matched the stale entry and was
// dismissed as our own echo, stranding the editor on B.
function Externally({ value }: { value: JsonObject }) {
return (
<JsonObjectInput ariaLabel={LABEL} value={value} onChange={() => {}} />
);
}
const { rerender } = renderWithMantine(<Externally value={{ a: 1 }} />);
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
rerender(<Externally value={{ b: 2 }} />);
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
// Back to the first value — a distinct object, same content.
rerender(<Externally value={{ a: 1 }} />);
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
});
describe("wiring to the Input.Wrapper label and error", () => {
/** Ace's hidden textarea — the element the wrapper has to point at. */
function textarea(): HTMLTextAreaElement {
const el = document.querySelector<HTMLTextAreaElement>(
"textarea.ace_text-input",
);
if (!el) throw new Error("Ace text input not mounted");
return el;
}
it("lets the visible label focus the editor", () => {
renderWithMantine(
<JsonObjectInput
label="Request metadata"
ariaLabel={LABEL}
value={{}}
onChange={() => {}}
/>,
);
// `Input.Wrapper` renders `<label for={id}>`; the editor is only
// reachable from it if Ace's textarea carries that same id.
const label = screen.getByText("Request metadata");
expect(label.getAttribute("for")).toBe(textarea().id);
expect(textarea().id).not.toBe("");
});
it("marks the editor invalid and points it at the error text", async () => {
renderWithMantine(<Harness initial={{}} />);
expect(textarea().getAttribute("aria-invalid")).toBeNull();
await setAceText("[1]");
expect(textarea().getAttribute("aria-invalid")).toBe("true");
const describedBy = textarea().getAttribute("aria-describedby");
expect(describedBy).not.toBeNull();
// The id must resolve to the message actually on screen.
expect(document.getElementById(describedBy!)?.textContent).toMatch(
/Must be a JSON object/,
);
});
it("clears the invalid marking once the text parses again", async () => {
renderWithMantine(<Harness initial={{}} />);
await setAceText("[1]");
expect(textarea().getAttribute("aria-invalid")).toBe("true");
await setAceText('{"a":1}');
expect(textarea().getAttribute("aria-invalid")).toBeNull();
expect(textarea().getAttribute("aria-describedby")).toBeNull();
});
});
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
renderWithMantine(<Harness initial={{}} />);
// The parent re-renders with the object this text parsed to; the draft must

View file

@ -1,5 +1,6 @@
import { useRef, useState } from "react";
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";
@ -137,13 +138,47 @@ export function JsonObjectInput({
});
};
const wrapperId = useId();
const editorRef = useRef<Ace.Editor | null>(null);
const parsed = parseJsonObjectDraft(draft);
useValueChange(serialize(value), (next) => {
if (next !== echoed) setDraft(next);
if (next === echoed) return;
setDraft(next);
// `echoed` has to advance to the value now on screen, not just to what this
// component last emitted. Leaving it behind makes an external A → B → A
// sequence stick on B: the second A matches the stale `echoed` from before
// B arrived and is dismissed as our own echo.
setEchoed(next);
});
// `Input.Wrapper` labels and describes a Mantine input through context. Ace
// is not one — it renders its own DOM — so the wrapper's label and error node
// exist but point at nothing, leaving the control unassociated with the error
// a screen reader needs and its visible label inert on click.
//
// A genuine external-system sync, so an effect rather than `useValueChange`:
// it writes to a DOM node React does not own. Giving the textarea the
// wrapper's own id is what makes the `<label for>` focus the editor; the
// error wiring follows Mantine's `${id}-error` naming.
useEffect(() => {
const element = editorRef.current?.textInput.getElement();
/* v8 ignore next -- the ref is set in `onLoad`, which react-ace calls
synchronously on mount, so this effect always sees an element. */
if (!element) return;
element.id = wrapperId;
if (parsed.ok) {
element.removeAttribute("aria-invalid");
element.removeAttribute("aria-describedby");
} else {
element.setAttribute("aria-invalid", "true");
element.setAttribute("aria-describedby", `${wrapperId}-error`);
}
}, [wrapperId, parsed.ok]);
return (
<Input.Wrapper
id={wrapperId}
label={label}
description={description}
error={parsed.ok ? undefined : parsed.error}
@ -162,6 +197,7 @@ export function JsonObjectInput({
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

View file

@ -1419,6 +1419,10 @@ describe("InspectorClient", () => {
["an array", [1]],
["null", null],
["a boolean", true],
// Past `Number.MAX_SAFE_INTEGER` the float64 value is no longer the
// integer it was written as, and zod 4's `.int()` rejects it — so
// `Number.isInteger` is too loose a check here.
["an unsafe integer", Number.MAX_SAFE_INTEGER + 1],
])(
"drops a reserved progressToken that is %s rather than sending it",
async (_label, badToken) => {
@ -1511,6 +1515,8 @@ describe("InspectorClient", () => {
it.each([
["a string", "tok-1"],
["an integer", 7],
// The boundary itself is still valid.
["the largest safe integer", Number.MAX_SAFE_INTEGER],
])("keeps a valid progressToken that is %s", async (_label, token) => {
client = new InspectorClient(
{

View file

@ -387,11 +387,17 @@ function describeJsonType(value: unknown): string {
* a string or an **integer** — a float is as invalid as an object. Everything
* else in `_meta` may be any JSON (#1910), which is exactly why this one key
* needs its own check.
*
* `isSafeInteger`, not `isInteger`: zod 4's `.int()` rejects anything past
* `Number.MAX_SAFE_INTEGER`, where the float64 representation is no longer the
* integer it was written as. `Number.isInteger(2 ** 53)` is `true`, so the
* looser check would pass a token the server then rejects — verified against
* the pinned zod.
*/
function isProgressToken(value: unknown): value is ProgressToken {
return (
typeof value === "string" ||
(typeof value === "number" && Number.isInteger(value))
(typeof value === "number" && Number.isSafeInteger(value))
);
}