feat(web): use the Ace JSON editor everywhere, and restore the raw-JSON toggle

Extracts the Ace integration #2094 carried inline into a shared
`elements/JsonEditor`, and renders it on every surface where the web
client types or displays JSON.

The element is deliberately text in, text out. The two editing contracts
above it disagree about what an unparseable draft means — JsonObjectInput
keeps the last valid object and says nothing to its parent, SchemaJsonField
reports `undefined` and blocks submission (#2020) — so parsing, and the
draft/value split that goes with it, stays with each caller.

Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a
new "Edit as JSON" switch for the whole arguments object, the server.json
File Contents box, the raw JSON-RPC request box, ContentViewer's JSON
branch (read-only, inherited by its eleven consumers), and a new "Edit and
replay" modal on a Protocol entry.

Ace read-only replaces the CodeHighlight JSON path and the Prism `json`
grammar loader is dropped: folding, not colorization, is what it adds, and
two highlighters for one language drift. `wrap={false}` callers and untyped
text that only looks like JSON keep the plain renderer.

Closes #2151

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-27 02:24:22 -04:00
commit 1a2a36f0b8
24 changed files with 2026 additions and 448 deletions

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,121 @@
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 { EditReplayModal } from "./EditReplayModal";
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();
});
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 sitting the user opened it in. Reopening must show
// the entry's params again, not whatever they 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,133 @@
import { useState } from "react";
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import { isJsonObject } from "../../../utils/jsonObjectDraft";
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. */
params?: Record<string, unknown>;
/**
* 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" });
/**
* 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.
*/
function parseParamsDraft(
text: string,
):
| { 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 (`{ … }`)" };
}
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,
onSend,
onClose,
}: Omit<EditReplayModalProps, "opened">) {
const [draft, setDraft] = useState(() =>
params === undefined ? "" : JSON.stringify(params, null, 2),
);
const parsed = parseParamsDraft(draft);
return (
<Stack gap="sm">
<MethodText>{method}</MethodText>
<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,
onSend,
onClose,
}: EditReplayModalProps) {
return (
<Modal opened={opened} onClose={onClose} title="Edit and replay" size="lg">
<EditReplayForm
method={method}
params={params}
onSend={onSend}
onClose={onClose}
/>
</Modal>
);
}

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { setAceText } from "../../../test/aceEditor";
import { ExperimentalFeaturesPanel } from "./ExperimentalFeaturesPanel";
import type { RequestHistoryItem } from "./ExperimentalFeaturesPanel";
@ -273,8 +274,7 @@ describe("ExperimentalFeaturesPanel", () => {
expect(onRemoveHeader).toHaveBeenCalledWith(1);
});
it("invokes onRequestChange when the request textarea changes", async () => {
const user = userEvent.setup();
it("invokes onRequestChange when the request editor changes", async () => {
const onRequestChange = vi.fn();
renderWithMantine(
<ExperimentalFeaturesPanel
@ -283,8 +283,7 @@ describe("ExperimentalFeaturesPanel", () => {
onRequestChange={onRequestChange}
/>,
);
const textarea = screen.getByLabelText("Request");
await user.type(textarea, "x");
await setAceText("x");
expect(onRequestChange).toHaveBeenCalledWith("x");
});

View file

@ -15,6 +15,7 @@ import {
Title,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import type {
ClientCapabilities,
JSONRPCErrorResponse,
@ -107,14 +108,6 @@ const HeaderValueInput = TextInput.withProps({
rightSectionPointerEvents: "auto",
});
const RequestTextarea = Textarea.withProps({
label: "Request",
ff: "monospace",
autosize: true,
minRows: 6,
rightSectionPointerEvents: "auto",
});
const ResponseTextarea = Textarea.withProps({
"aria-label": "Response",
ff: "monospace",
@ -311,17 +304,25 @@ export function ExperimentalFeaturesPanel({
<CompactButton onClick={onAddHeader}>+ Add Header</CompactButton>
</Group>
<RequestTextarea
<JsonEditor
label="Request"
ariaLabel="Request"
value={requestDraft}
onChange={(e) => onRequestChange(e.currentTarget.value)}
rightSection={
requestDraft ? (
<ClearButton onClick={() => onRequestChange("")} />
) : null
}
onChange={onRequestChange}
minLines={6}
maxLines={20}
/>
<Button onClick={onSendRequest}>Send Request</Button>
<Group>
<Button onClick={onSendRequest}>Send Request</Button>
{/* Beside Send rather than inside the editor: Ace renders its own
DOM and has no `rightSection` to hang a control in. */}
{requestDraft ? (
<Button variant="default" onClick={() => onRequestChange("")}>
Clear
</Button>
) : null}
</Group>
{response && (
<>

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { InspectorServerJsonDraft } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { setAceText } from "../../../test/aceEditor";
import {
ImportServerJsonPanel,
type EnvVarInfo,
@ -48,8 +49,7 @@ describe("ImportServerJsonPanel", () => {
).not.toBeInTheDocument();
});
it("invokes onJsonChange when typing in the textarea", async () => {
const user = userEvent.setup();
it("invokes onJsonChange when the JSON editor changes", async () => {
const onJsonChange = vi.fn();
renderWithMantine(
<ImportServerJsonPanel
@ -60,10 +60,7 @@ describe("ImportServerJsonPanel", () => {
envVars={[]}
/>,
);
// The first textbox is the JSON textarea
const allTextboxes = screen.getAllByRole("textbox");
// userEvent.type treats `{` as a key-descriptor delimiter; escape with `{{`
await user.type(allTextboxes[0], "x");
await setAceText("x");
expect(onJsonChange).toHaveBeenCalledWith("x");
});

View file

@ -8,10 +8,10 @@ import {
Stack,
Text,
TextInput,
Textarea,
Title,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import type { InspectorServerJsonDraft } from "@inspector/core/mcp/types.js";
export interface ValidationResult {
@ -89,18 +89,23 @@ const HeaderRow = Group.withProps({
wrap: "nowrap",
});
// The header's trailing controls. `nowrap` keeps Clear beside Choose file…
// rather than wrapping one under the other in a narrow modal.
const HeaderActions = Group.withProps({ gap: "xs", wrap: "nowrap" });
const ChooseFileButton = Button.withProps({
variant: "default",
size: "xs",
});
const FileContentsTextarea = Textarea.withProps({
"aria-label": "File Contents",
ff: "monospace",
autosize: true,
minRows: 8,
maxRows: 15,
rightSectionPointerEvents: "auto",
// Clears the pasted document. It sits in the header row rather than inside the
// editor: Ace renders its own DOM and has no `rightSection` to hang a control
// in, and an absolutely-positioned button over a scrolling editor would sit on
// top of the text it is meant to help with.
const ClearContentsButton = Button.withProps({
variant: "subtle",
size: "xs",
color: "gray",
});
const DefaultNameInput = TextInput.withProps({
@ -143,13 +148,20 @@ export function ImportServerJsonPanel({
<Stack gap="md">
<HeaderRow>
<HintText>Paste server.json content, or load it from a file:</HintText>
{onPickFile ? (
<FileButton accept="application/json,.json" onChange={onPickFile}>
{(props) => (
<ChooseFileButton {...props}>Choose file…</ChooseFileButton>
)}
</FileButton>
) : null}
<HeaderActions>
{hasContent && (
<ClearContentsButton onClick={() => onJsonChange("")}>
Clear
</ClearContentsButton>
)}
{onPickFile ? (
<FileButton accept="application/json,.json" onChange={onPickFile}>
{(props) => (
<ChooseFileButton {...props}>Choose file…</ChooseFileButton>
)}
</FileButton>
) : null}
</HeaderActions>
</HeaderRow>
{/* Accordions here stay inline: Accordion is a compound,
@ -177,14 +189,12 @@ export function ImportServerJsonPanel({
File Contents
</Accordion.Control>
<Accordion.Panel>
<FileContentsTextarea
<JsonEditor
ariaLabel="File Contents"
value={draft.rawText}
onChange={(e) => onJsonChange(e.currentTarget.value)}
rightSection={
draft.rawText ? (
<ClearButton onClick={() => onJsonChange("")} />
) : null
}
onChange={onJsonChange}
minLines={8}
maxLines={20}
/>
</Accordion.Panel>
</Accordion.Item>

View file

@ -8,6 +8,7 @@ import {
Stack,
Text,
} from "@mantine/core";
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
@ -26,7 +27,7 @@ export interface MrtrConversationProps {
isListExpanded: boolean;
/** Compact per-round layout for the narrow monitoring column. */
embedded?: boolean;
onReplay: (id: string) => void;
onReplay: (id: string, overrideParams?: ReplayParamsOverride) => void;
onTogglePin: (id: string) => void;
}
@ -182,7 +183,9 @@ export function MrtrConversation({
isPinned={pinnedIds.has(round.id)}
isListExpanded={isListExpanded}
embedded={embedded}
onReplay={() => onReplay(round.id)}
onReplay={(overrideParams) =>
onReplay(round.id, overrideParams)
}
onTogglePin={() => onTogglePin(round.id)}
/>
</Stack>

View file

@ -7,6 +7,7 @@ import {
screen,
waitFor,
} from "../../../test/renderWithMantine";
import { getAceText } from "../../../test/aceEditor";
import { NetworkEntry } from "./NetworkEntry";
const baseEntry: FetchRequestEntry = {
@ -232,28 +233,29 @@ describe("NetworkEntry", () => {
token_type: "Bearer",
}),
};
const { container } = renderWithMantine(
<NetworkEntry entry={authEntry} isListExpanded={true} />,
);
// Masked by default: the reveal affordance is present and the raw secret
// is nowhere in the DOM, but non-secret fields still render.
renderWithMantine(<NetworkEntry entry={authEntry} isListExpanded={true} />);
// Masked by default: the reveal affordance is present and the raw secret is
// nowhere in the rendered body, but non-secret fields still render. Read
// through the JSON editor rather than the DOM — Ace virtualizes its lines,
// so what the document holds is a function of a viewport happy-dom has no
// layout to give it.
expect(screen.getByText("Secrets hidden")).toBeInTheDocument();
expect(container.textContent).not.toContain("super-secret-token");
expect(container.textContent).toContain("••••••••");
expect(container.textContent).toContain("Bearer");
expect(getAceText()).not.toContain("super-secret-token");
expect(getAceText()).toContain("••••••••");
expect(getAceText()).toContain("Bearer");
await user.click(
screen.getByRole("button", { name: "Reveal secrets in body" }),
);
expect(screen.getByText("Secrets revealed")).toBeInTheDocument();
expect(container.textContent).toContain("super-secret-token");
expect(getAceText()).toContain("super-secret-token");
// Toggling back re-masks.
await user.click(
screen.getByRole("button", { name: "Hide secrets in body" }),
);
expect(container.textContent).not.toContain("super-secret-token");
expect(getAceText()).not.toContain("super-secret-token");
});
it("masks a form-encoded request body (code/verifier) until revealed", async () => {

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { MessageEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText, setAceText } from "../../../test/aceEditor";
import { ProtocolEntry } from "./ProtocolEntry";
const successEntry: MessageEntry = {
@ -203,6 +204,84 @@ describe("ProtocolEntry", () => {
expect(onReplay).toHaveBeenCalledTimes(1);
});
// Edit-and-replay is a second way to *supply* the params, not a second replay
// implementation — it dispatches through the same `onReplay` (#2151).
describe("edit and replay (#2151)", () => {
async function openEditor(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole("button", { name: "Edit and replay" }));
}
it("seeds the editor with the entry's params", async () => {
const user = userEvent.setup();
renderWithMantine(<ProtocolEntry {...baseProps} entry={successEntry} />);
await openEditor(user);
expect(JSON.parse(getAceText())).toEqual({
name: "get_weather",
arguments: { city: "San Francisco" },
});
});
it("replays with the edited params", async () => {
const user = userEvent.setup();
const onReplay = vi.fn();
renderWithMantine(
<ProtocolEntry
{...baseProps}
entry={successEntry}
onReplay={onReplay}
/>,
);
await openEditor(user);
await setAceText('{"name":"get_weather","arguments":{"city":"Boston"}}');
await user.click(screen.getByRole("button", { name: "Send" }));
expect(onReplay).toHaveBeenCalledWith({
name: "get_weather",
arguments: { city: "Boston" },
});
// And the modal is gone — the entry itself is unchanged.
expect(screen.queryByRole("button", { name: "Send" })).toBeNull();
});
it("replays verbatim from the plain Replay button", async () => {
const user = userEvent.setup();
const onReplay = vi.fn();
renderWithMantine(
<ProtocolEntry
{...baseProps}
entry={successEntry}
onReplay={onReplay}
/>,
);
await user.click(screen.getByRole("button", { name: "Replay" }));
// No argument at all: "not edited" has to be distinguishable from
// "edited to no params" one level up.
expect(onReplay).toHaveBeenCalledWith();
});
it("is offered only where Replay is", () => {
const notification: MessageEntry = {
id: "note-1",
timestamp: new Date(),
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
};
renderWithMantine(<ProtocolEntry {...baseProps} entry={notification} />);
expect(
screen.queryByRole("button", { name: "Edit and replay" }),
).toBeNull();
});
it("is offered in the compact layout too", () => {
renderWithMantine(
<ProtocolEntry {...baseProps} entry={successEntry} embedded />,
);
expect(
screen.getByRole("button", { name: "Edit and replay" }),
).toBeInTheDocument();
});
});
it("hides the Replay button for a method that can't be replayed", () => {
// A notification isn't a replayable client→server request.
renderWithMantine(

View file

@ -21,6 +21,8 @@ import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge";
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
import { PinToggle } from "../../elements/PinToggle/PinToggle";
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
import { EditReplayButton } from "../../elements/EditReplayButton/EditReplayButton";
import { EditReplayModal } from "../EditReplayModal/EditReplayModal";
import { useValueChange } from "../../../hooks/useValueChange";
import {
classifyProtocolSpecError,
@ -32,12 +34,19 @@ import {
extractSubscriptionId,
} from "../protocolUtils.js";
import { isReplayableProtocolMethod } from "../../../utils/replayableProtocolMethods";
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
export interface ProtocolEntryProps {
entry: MessageEntry;
isPinned: boolean;
isListExpanded: boolean;
onReplay: () => void;
/**
* Re-issue this entry's request. Called with no argument by the Replay
* button (the entry's own params, verbatim), and with an edited params object
* by the Edit-and-replay modal — one dispatch path, two ways to supply the
* params (#2151).
*/
onReplay: (overrideParams?: ReplayParamsOverride) => void;
onTogglePin: () => void;
/**
* Compact two-line header for the narrow monitoring sidebar (#1616): line 1 is
@ -194,6 +203,17 @@ function formatTimestampCompact(date: Date): string {
return date.toISOString().slice(11, 19);
}
// The request's `params` object, which the Edit-and-replay editor is seeded
// from and the expanded detail renders. `undefined` for a frame that carries
// none — a bare `tools/list`, or a response.
function extractParams(
entry: MessageEntry,
): Record<string, unknown> | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
return msg.params as Record<string, unknown>;
}
function extractTarget(entry: MessageEntry): string | undefined {
const msg = entry.message;
if (!("params" in msg) || !msg.params) return undefined;
@ -306,10 +326,12 @@ export function ProtocolEntry({
correlatedHttpStatus,
}: ProtocolEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded);
const [isEditingReplay, setIsEditingReplay] = useState(false);
const method = extractMethod(entry);
const target = extractTarget(entry);
const resourceUri = extractResourceUri(entry);
const status = extractStatus(entry);
const requestParams = extractParams(entry);
const canReplay = isReplayableProtocolMethod(method);
const resultType = extractResultType(entry);
const subscriptionId = extractSubscriptionId(entry);
@ -363,6 +385,14 @@ export function ProtocolEntry({
const durationText = entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
);
// Both replay affordances are gated on the same predicate, so a method that
// cannot be replayed does not offer an editor that would fail on Send.
const replayControls = canReplay && (
<>
<ReplayButton onReplay={() => onReplay()} />
<EditReplayButton onClick={() => setIsEditingReplay(true)} />
</>
);
return (
<EntryContainer>
@ -401,7 +431,7 @@ export function ProtocolEntry({
)}
</HeaderCluster>
<ControlsCluster>
{canReplay && <ReplayButton onReplay={onReplay} />}
{replayControls}
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
@ -436,7 +466,7 @@ export function ProtocolEntry({
</HeaderRow>
<ToggleRow>
{canReplay && <ReplayButton onReplay={onReplay} />}
{replayControls}
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
@ -446,6 +476,16 @@ export function ProtocolEntry({
</>
)}
{canReplay && (
<EditReplayModal
opened={isEditingReplay}
method={method}
params={requestParams}
onSend={onReplay}
onClose={() => setIsEditingReplay(false)}
/>
)}
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
@ -464,13 +504,13 @@ export function ProtocolEntry({
}
/>
)}
{"params" in entry.message && entry.message.params && (
{requestParams && (
<Stack gap="xs">
<Text size="sm">Parameters:</Text>
<ContentViewer
block={{
type: "text",
text: serializeMessage(entry.message.params),
text: serializeMessage(requestParams),
}}
copyable
/>

View file

@ -321,7 +321,8 @@ describe("ProtocolListPanel", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Replay" }));
expect(onReplay).toHaveBeenCalledWith("req-1");
// The second argument is the Edit-and-replay override, absent here (#2151).
expect(onReplay).toHaveBeenCalledWith("req-1", undefined);
});
it("invokes onTogglePin with the entry id when Pin is clicked", async () => {
@ -364,7 +365,7 @@ describe("ProtocolListPanel", () => {
/>,
);
await user.click(screen.getByRole("button", { name: "Replay" }));
expect(onReplay).toHaveBeenCalledWith("req-1");
expect(onReplay).toHaveBeenCalledWith("req-1", undefined);
await user.click(screen.getByRole("button", { name: "Unpin" }));
expect(onTogglePin).toHaveBeenCalledWith("req-1");
});

View file

@ -9,6 +9,7 @@ import {
Title,
UnstyledButton,
} from "@mantine/core";
import type { ReplayParamsOverride } from "../../../lib/protocolReplay";
import type { ProtocolEra } from "@modelcontextprotocol/client";
import type {
MessageEntry,
@ -47,7 +48,7 @@ export interface ProtocolListPanelProps {
onClearSection: (section: ProtocolSectionName) => void;
/** Export just one section's entries. */
onExportSection: (section: ProtocolSectionName) => void;
onReplay: (id: string) => void;
onReplay: (id: string, overrideParams?: ReplayParamsOverride) => void;
onTogglePin: (id: string) => void;
sortDirection: SortDirection;
onSortChange: (next: SortDirection) => void;
@ -326,7 +327,7 @@ export function ProtocolListPanel({
isPinned={sectionPinned}
isListExpanded={!compact}
embedded={embedded}
onReplay={() => onReplay(row.entry.id)}
onReplay={(overrideParams) => onReplay(row.entry.id, overrideParams)}
onTogglePin={() => onTogglePin(row.entry.id)}
onRevealInNetwork={
onRevealInNetwork && revealableIds?.has(row.entry.id)

View file

@ -90,8 +90,15 @@ export const LargeResult: Story = {
);
// The `"blob"` key appears only in the expanded read result's JSON (not in
// the metadata badge), so it confirms the inline result rendered.
await waitFor(() =>
expect(canvas.getByText(/"blob":/)).toBeInTheDocument(),
);
//
// Read through the editor rather than with `getByText`: the JSON renders in
// Ace (#2151), which puts every token in its own element — so a matcher
// spanning the `"blob"`/`:` boundary can never match a single node.
await waitFor(async () => {
const editor = canvasElement.querySelector(".ace_editor") as
| (HTMLElement & { env?: { editor?: { getValue(): string } } })
| null;
await expect(editor?.env?.editor?.getValue() ?? "").toContain('"blob":');
});
},
};

View file

@ -6,6 +6,7 @@ import {
screen,
waitFor,
} from "../../../test/renderWithMantine";
import { getAceText } from "../../../test/aceEditor";
import { ResourceLink } from "./ResourceLink";
const URI = "file:///docs/readme.md";
@ -46,9 +47,9 @@ describe("ResourceLink", () => {
expect(onReadResource).toHaveBeenCalledWith(URI);
// The full read result is rendered inline as formatted JSON.
await waitFor(() =>
expect(screen.getByText(/"hello body"/)).toBeInTheDocument(),
);
// The body lands in the read-only JSON editor, whose lines are
// virtualized — so it is read through the editor, not the DOM.
await waitFor(() => expect(getAceText()).toContain('"hello body"'));
// The toggle flips to the collapse control once expanded.
expect(
screen.getByRole("button", { name: `Collapse resource ${URI}` }),
@ -65,9 +66,9 @@ describe("ResourceLink", () => {
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() =>
expect(screen.getByText(/"cached body"/)).toBeInTheDocument(),
);
// The body lands in the read-only JSON editor, whose lines are
// virtualized — so it is read through the editor, not the DOM.
await waitFor(() => expect(getAceText()).toContain('"cached body"'));
// Collapse — the toggle flips back to the expand control. (The read result
// stays mounted inside the animated Collapse, so it isn't re-read.)
@ -82,7 +83,7 @@ describe("ResourceLink", () => {
await user.click(
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
expect(screen.getByText(/"cached body"/)).toBeInTheDocument();
expect(getAceText()).toContain('"cached body"');
expect(onReadResource).toHaveBeenCalledTimes(1);
});
@ -146,9 +147,9 @@ describe("ResourceLink", () => {
screen.getByRole("button", { name: `Expand resource ${URI}` }),
);
await waitFor(() =>
expect(screen.getByText(/"recovered body"/)).toBeInTheDocument(),
);
// The body lands in the read-only JSON editor, whose lines are
// virtualized — so it is read through the editor, not the DOM.
await waitFor(() => expect(getAceText()).toContain('"recovered body"'));
expect(
screen.queryByText("Failed to read resource"),
).not.toBeInTheDocument();
@ -183,8 +184,8 @@ describe("ResourceLink", () => {
expect(onReadResource).toHaveBeenCalledTimes(1);
resolveRead(readResult("in flight body"));
await waitFor(() =>
expect(screen.getByText(/"in flight body"/)).toBeInTheDocument(),
);
// The body lands in the read-only JSON editor, whose lines are
// virtualized — so it is read through the editor, not the DOM.
await waitFor(() => expect(getAceText()).toContain('"in flight body"'));
});
});

View file

@ -6,6 +6,7 @@ import type {
TextResourceContents,
} from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { getAceText } from "../../../test/aceEditor";
import { ResourcePreviewPanel } from "./ResourcePreviewPanel";
// Stub the lazy highlighter so JSON/XML/CSS branches render synchronously
@ -483,7 +484,7 @@ describe("ResourcePreviewPanel", () => {
expect(screen.getByText("text/csv")).toBeInTheDocument();
});
it("infers application/json from a .json URI and highlights", () => {
it("infers application/json from a .json URI and renders the editor", () => {
renderWithMantine(
<ResourcePreviewPanel
{...baseProps}
@ -491,10 +492,7 @@ describe("ResourcePreviewPanel", () => {
contents={[{ uri: "file:///cfg.json", text: '{"a":1}' }]}
/>,
);
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
"data-language",
"json",
);
expect(getAceText()).toBe('{\n "a": 1\n}');
});
it("infers application/xml from a .xml URI and highlights", () => {

View file

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { expect, fn, within } from "storybook/test";
import { SchemaForm } from "./SchemaForm";
const meta: Meta<typeof SchemaForm> = {
@ -218,3 +218,44 @@ export const RootUnion: Story = {
values: { kind: "email" },
},
};
/**
* The v1 escape hatch, restored (#2151): flip the form over and edit the whole
* arguments object as JSON.
*
* The play function is where the real behavior lives, because Ace's keyboard
* path only works in a real browser — the unit suite drives the editor through
* its API instead.
*/
export const RawJsonToggle: Story = {
args: {
schema: {
type: "object",
properties: {
name: { type: "string", title: "Name" },
count: { type: "integer", title: "Count" },
},
},
values: { name: "Example", count: 3 },
},
play: async ({ canvasElement, userEvent }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Edit as JSON"));
// The widgets are gone, replaced by one editor holding the same values.
await expect(canvas.queryByLabelText(/^Name$/)).toBeNull();
const editor = canvasElement.querySelector(".ace_editor") as HTMLElement & {
env: { editor: { getValue(): string } };
};
await expect(JSON.parse(editor.env.editor.getValue())).toEqual({
name: "Example",
count: 3,
});
// And back: the switch is the only way out, and it leaves the widgets on
// screen. (This story's `onChange` is a spy, so the values it renders are
// the ones it was given.)
await userEvent.click(canvas.getByLabelText("Edit as JSON"));
await expect(canvas.getByLabelText(/^Name$/)).toBeVisible();
},
};

View file

@ -9,6 +9,7 @@ import {
screen,
waitFor,
} from "../../../test/renderWithMantine";
import { getAceTextByLabel, setAceTextByLabel } from "../../../test/aceEditor";
import { SchemaForm } from "./SchemaForm";
describe("SchemaForm", () => {
@ -719,8 +720,7 @@ describe("SchemaForm", () => {
expect(screen.getByText("Config")).toBeInTheDocument();
});
it("invokes onChange via JsonInput when valid JSON is pasted", async () => {
const user = userEvent.setup();
it("invokes onChange via the JSON editor when valid JSON is pasted", async () => {
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
@ -731,9 +731,7 @@ describe("SchemaForm", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
jsonInput.focus();
await user.paste("[1,2]");
await setAceTextByLabel(/Config/, "[1,2]");
expect(onChange).toHaveBeenCalled();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
expect(lastCall.config).toEqual([1, 2]);
@ -746,7 +744,6 @@ describe("SchemaForm", () => {
// more since #2007, whose fix deliberately routes object unions to this
// editor: a fallback nobody can type into is not a fallback.
it("shows an error while the JSON draft does not parse", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
@ -761,13 +758,12 @@ describe("SchemaForm", () => {
}
renderWithMantine(<Harness />);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "[[1,");
await setAceTextByLabel(/Config/, "[1,");
// Invalid text yields no value, so without this the field would submit as
// absent while the user is still looking at what they typed.
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
await user.type(jsonInput, "2]");
await setAceTextByLabel(/Config/, "[1,2]");
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
});
@ -785,7 +781,6 @@ describe("SchemaForm", () => {
});
it("reports no value, not raw text, while the JSON is mid-edit", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
@ -796,13 +791,17 @@ describe("SchemaForm", () => {
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "x");
await setAceTextByLabel(/Config/, "x");
expect(onChange).toHaveBeenLastCalledWith({ config: undefined });
});
it("lets an array literal be typed one character at a time", async () => {
const user = userEvent.setup();
// Ace is not typed into here — it reads keystrokes through an offscreen
// textarea plus selection state happy-dom does not implement, so a simulated
// keypress produces no edit at all (see `test/aceEditor.ts`). What this can
// still pin is the mechanism that broke: each settled draft is displayed
// verbatim rather than re-serialized. Real character-by-character typing is
// covered by the `SchemaForm` play function, which runs in Chromium.
it("shows each JSON draft verbatim as it is built up", async () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
@ -820,16 +819,15 @@ describe("SchemaForm", () => {
}
renderWithMantine(<Harness />);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
// `[` is a userEvent keyboard descriptor, so it is escaped as `[[`.
await user.type(jsonInput, '[[1,"a"]');
// The box shows exactly what was typed — no injected quotes or backslashes.
expect(jsonInput.value).toBe('[1,"a"]');
for (const step of ["[", "[1", '[1,"', '[1,"a', '[1,"a"]']) {
await setAceTextByLabel(/Config/, step);
// The box shows exactly what was typed — no injected quotes, no
// backslashes, at any point along the way.
expect(getAceTextByLabel(/Config/)).toBe(step);
}
});
it("keeps an in-progress draft visible instead of rewriting it", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
@ -844,13 +842,12 @@ describe("SchemaForm", () => {
}
renderWithMantine(<Harness />);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "[[1,");
await setAceTextByLabel(/Config/, "[1,");
// Unparseable so far, and it must survive the re-render untouched.
expect(jsonInput.value).toBe("[1,");
expect(getAceTextByLabel(/Config/)).toBe("[1,");
await user.type(jsonInput, "2]");
expect(jsonInput.value).toBe("[1,2]");
await setAceTextByLabel(/Config/, "[1,2]");
expect(getAceTextByLabel(/Config/)).toBe("[1,2]");
});
it("uses default values when value is undefined", () => {
@ -1334,7 +1331,6 @@ describe("SchemaForm draft validity (#2020)", () => {
});
it("reports an unparseable JSON draft, then clears it once the text parses", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
renderWithMantine(
<ValidityHarness
@ -1343,11 +1339,10 @@ describe("SchemaForm draft validity (#2020)", () => {
/>,
);
const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement;
await user.type(jsonInput, "[[1,");
await setAceTextByLabel(/Config/, "[1,");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
await user.type(jsonInput, "2]");
await setAceTextByLabel(/Config/, "[1,2]");
expect(onValidityChange).toHaveBeenLastCalledWith(false);
});
@ -1380,7 +1375,6 @@ describe("SchemaForm draft validity (#2020)", () => {
});
it("stays invalid while any one field is invalid", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
@ -1393,20 +1387,19 @@ describe("SchemaForm draft validity (#2020)", () => {
<ValidityHarness schema={schema} onValidityChange={onValidityChange} />,
);
await user.type(screen.getByLabelText(/First/), "x");
await user.type(screen.getByLabelText(/Second/), "y");
await setAceTextByLabel(/First/, "x");
await setAceTextByLabel(/Second/, "y");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
// One of the two recovering is not enough.
await user.clear(screen.getByLabelText(/First/));
await setAceTextByLabel(/First/, "");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
await user.clear(screen.getByLabelText(/Second/));
await setAceTextByLabel(/Second/, "");
expect(onValidityChange).toHaveBeenLastCalledWith(false);
});
it("surfaces a nested object's invalid draft through the outer form", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
@ -1424,12 +1417,11 @@ describe("SchemaForm draft validity (#2020)", () => {
<ValidityHarness schema={schema} onValidityChange={onValidityChange} />,
);
await user.type(screen.getByLabelText(/Config/), "x");
await setAceTextByLabel(/Config/, "x");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
});
it("clears a stale invalid draft when the form moves to another entity", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
const { rerender } = renderWithMantine(
<ValidityHarness
@ -1439,7 +1431,7 @@ describe("SchemaForm draft validity (#2020)", () => {
/>,
);
await user.type(screen.getByLabelText(/Config/), "x");
await setAceTextByLabel(/Config/, "x");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
// Switching entities remounts the field, discarding its draft — so text
@ -1455,7 +1447,6 @@ describe("SchemaForm draft validity (#2020)", () => {
});
it("reports valid when the form unmounts holding an invalid draft", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
const { unmount } = renderWithMantine(
<ValidityHarness
@ -1464,7 +1455,7 @@ describe("SchemaForm draft validity (#2020)", () => {
/>,
);
await user.type(screen.getByLabelText(/Config/), "x");
await setAceTextByLabel(/Config/, "x");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
unmount();
@ -1472,11 +1463,10 @@ describe("SchemaForm draft validity (#2020)", () => {
});
it("renders without a validity callback", async () => {
const user = userEvent.setup();
renderWithMantine(
<SchemaForm schema={jsonSchema} values={{}} onChange={vi.fn()} />,
);
await user.type(screen.getByLabelText(/Config/), "x");
await setAceTextByLabel(/Config/, "x");
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
});
});
@ -1508,7 +1498,6 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
// #1853: `batch_process_items`, typed character by character.
it("types a string array through without escaping it (#1853)", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
@ -1522,19 +1511,16 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
};
renderWithMantine(<EscapingHarness schema={schema} />);
const jsonInput = screen.getByLabelText(/Item Ids/) as HTMLTextAreaElement;
// `[` opens a userEvent keyboard descriptor, so it is escaped as `[[`.
await user.type(jsonInput, '[["item-1","item-2"]');
await setAceTextByLabel(/Item Ids/, '["item-1","item-2"]');
expect(jsonInput.value).toBe('["item-1","item-2"]');
expect(jsonInput.value).not.toContain("\\");
expect(getAceTextByLabel(/Item Ids/)).toBe('["item-1","item-2"]');
expect(getAceTextByLabel(/Item Ids/)).not.toContain("\\");
});
// #1853 again, from the comment thread: the caret sitting *outside* the
// quotes of a `""` value. One keystroke there made the draft invalid, which
// is all it took — `""` + `a` rendered as `"\"\"a"`.
it("keeps a keystroke typed outside a string's quotes literal (#1853)", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
// No `type`, so the field lands on the JSON editor holding a string.
@ -1544,18 +1530,14 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
<EscapingHarness schema={schema} initial={{ note: "" }} />,
);
const jsonInput = screen.getByRole("textbox", {
name: /Note/,
}) as HTMLTextAreaElement;
await user.click(jsonInput);
await user.keyboard("{End}a");
expect(getAceTextByLabel(/Note/)).toBe('""');
await setAceTextByLabel(/Note/, '""a');
expect(jsonInput.value).toBe('""a');
expect(getAceTextByLabel(/Note/)).toBe('""a');
});
// #1856: `sum_numbers`, Backspace with the caret after the closing `]`.
it("keeps the draft raw when Backspace invalidates an array (#1856)", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
@ -1571,22 +1553,21 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
<EscapingHarness schema={schema} initial={{ numbers: [1, 2] }} />,
);
const jsonInput = screen.getByLabelText(/Numbers/) as HTMLTextAreaElement;
await user.click(jsonInput);
await user.keyboard("{End}{Backspace}");
expect(getAceTextByLabel(/Numbers/)).toBe("[\n 1,\n 2\n]");
// Exactly the seeded text minus its last character — the draft the reporter
// expected, where the field instead showed a quoted, escaped string.
expect(jsonInput.value).toBe("[\n 1,\n 2\n");
await setAceTextByLabel(/Numbers/, "[\n 1,\n 2\n");
expect(getAceTextByLabel(/Numbers/)).toBe("[\n 1,\n 2\n");
// Each further edit compounded the escaping, so keep going.
await user.keyboard("{Backspace}{Backspace}");
expect(jsonInput.value).not.toContain("\\");
expect(jsonInput.value.startsWith('"')).toBe(false);
await setAceTextByLabel(/Numbers/, "[\n 1,\n ");
expect(getAceTextByLabel(/Numbers/)).not.toContain("\\");
expect(getAceTextByLabel(/Numbers/).startsWith('"')).toBe(false);
// And the draft is still live: closing it back up produces a real array.
await user.keyboard("2]");
expect(jsonInput.value).toBe("[\n 1,\n 2]");
await setAceTextByLabel(/Numbers/, "[\n 1,\n 2]");
expect(getAceTextByLabel(/Numbers/)).toBe("[\n 1,\n 2]");
});
// #1885: FastMCP's `b: int | None = None`, which compiles to an `anyOf` with
@ -1626,7 +1607,6 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
// sends to the JSON editor (an array union). Backspacing the `null` token
// there is the exact keystroke sequence from #1885's recording.
it("backspaces a null default in the JSON editor without escaping (#1885)", async () => {
const user = userEvent.setup();
const schema: InspectorFormSchema = {
type: "object",
properties: {
@ -1641,16 +1621,14 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => {
// being resolved — the state the reporter's recording starts from.
renderWithMantine(<EscapingHarness schema={schema} />);
const jsonInput = screen.getByLabelText(/Cfg/) as HTMLTextAreaElement;
expect(jsonInput.value).toBe("null");
expect(getAceTextByLabel(/Cfg/)).toBe("null");
await user.click(jsonInput);
await user.keyboard("{End}{Backspace}");
expect(jsonInput.value).toBe("nul");
await setAceTextByLabel(/Cfg/, "nul");
expect(getAceTextByLabel(/Cfg/)).toBe("nul");
await user.keyboard("{Backspace}{Backspace}{Backspace}");
expect(jsonInput.value).toBe("");
expect(jsonInput.value).not.toContain("\\");
await setAceTextByLabel(/Cfg/, "");
expect(getAceTextByLabel(/Cfg/)).toBe("");
expect(getAceTextByLabel(/Cfg/)).not.toContain("\\");
});
});
@ -1691,13 +1669,10 @@ describe("SchemaForm defaulted fields (#2026)", () => {
it("opens the JSON editor on the schema default", () => {
renderWithMantine(<DefaultHarness schema={arrayWithDefault} />);
expect((screen.getByLabelText(/Tags/) as HTMLTextAreaElement).value).toBe(
'[\n "a"\n]',
);
expect(getAceTextByLabel(/Tags/)).toBe('[\n "a"\n]');
});
it("keeps a keystroke typed into a defaulted JSON field", async () => {
const user = userEvent.setup();
// Seeded with a value that is equal to the schema default but is not the
// same object — how values parsed off the wire or out of a deep link
// arrive. Sharing the reference (what `collectSchemaDefaults` produces) is
@ -1706,26 +1681,21 @@ describe("SchemaForm defaulted fields (#2026)", () => {
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
);
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
await user.click(jsonInput);
await user.keyboard("{End}x");
await setAceTextByLabel(/Tags/, '[\n "a"\n]x');
// The invalid draft is the user's, not the default reasserting itself.
expect(jsonInput.value).toBe('[\n "a"\n]x');
expect(getAceTextByLabel(/Tags/)).toBe('[\n "a"\n]x');
});
it("lets a defaulted JSON field be edited to a new value", async () => {
const user = userEvent.setup();
// Distinct-object seed again, for the reason above.
renderWithMantine(
<DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />,
);
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
await user.clear(jsonInput);
await user.type(jsonInput, '[["b"]');
await setAceTextByLabel(/Tags/, '["b"]');
expect(jsonInput.value).toBe('["b"]');
expect(getAceTextByLabel(/Tags/)).toBe('["b"]');
expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument();
});
@ -1737,7 +1707,9 @@ describe("SchemaForm defaulted fields (#2026)", () => {
};
renderWithMantine(<DefaultHarness schema={schema} initial={{ n: 30 }} />);
const n = screen.getByLabelText(/N/) as HTMLInputElement;
// Anchored: the form now also renders an "Edit as JSON" switch, which a
// bare /N/ matches too.
const n = screen.getByLabelText(/^N$/) as HTMLInputElement;
expect(n.value).toBe("30");
await user.click(n);
@ -1790,9 +1762,224 @@ describe("SchemaForm defaulted fields (#2026)", () => {
const user = userEvent.setup();
renderWithMantine(<ExternalHarness />);
const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement;
await user.click(screen.getByRole("button", { name: "load example" }));
expect(jsonInput.value).toBe('[\n "z"\n]');
expect(getAceTextByLabel(/Tags/)).toBe('[\n "z"\n]');
});
});
// The v1 escape hatch, restored (#2151): a rendered form can be flipped over to
// editing its whole arguments object as JSON, so a value the widgets cannot
// express — or a payload the user wants to paste in whole — has a route in.
describe("SchemaForm raw JSON (#2151)", () => {
const schema: InspectorFormSchema = {
type: "object",
properties: {
name: { type: "string", title: "Name" },
count: { type: "integer", title: "Count" },
},
};
function RawHarness({
initial = {},
onValidityChange,
resetKey,
schema: override,
}: {
initial?: Record<string, unknown>;
onValidityChange?: (hasInvalidDraft: boolean) => void;
resetKey?: string;
schema?: InspectorFormSchema;
}) {
const [values, setValues] = useState<Record<string, unknown>>(initial);
return (
<SchemaForm
schema={override ?? schema}
values={values}
onChange={setValues}
resetKey={resetKey}
onValidityChange={onValidityChange}
/>
);
}
async function enableRawJson(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByLabelText("Edit as JSON"));
}
it("replaces the widgets with one editor holding the current values", async () => {
const user = userEvent.setup();
renderWithMantine(<RawHarness initial={{ name: "a", count: 2 }} />);
expect(screen.getByLabelText(/^Name$/)).toBeInTheDocument();
await enableRawJson(user);
expect(screen.queryByLabelText(/^Name$/)).toBeNull();
expect(getAceTextByLabel(/Arguments JSON/)).toBe(
'{\n "name": "a",\n "count": 2\n}',
);
});
it("emits the edited object, and the widgets show it on the way back", async () => {
const user = userEvent.setup();
renderWithMantine(<RawHarness initial={{ name: "a", count: 2 }} />);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"name":"b","count":9}');
await user.click(screen.getByLabelText("Edit as JSON"));
expect((screen.getByLabelText(/^Name$/) as HTMLInputElement).value).toBe(
"b",
);
expect((screen.getByLabelText(/^Count$/) as HTMLInputElement).value).toBe(
"9",
);
});
// A field the schema does not declare has no widget, which is the case the
// escape hatch exists for — so it must survive the round trip.
it("carries a value no widget can express", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"extra":{"deep":[1,2]}}');
expect(onChange).toHaveBeenLastCalledWith({ extra: { deep: [1, 2] } });
});
it("blocks submission while the draft does not parse, and clears it after", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
renderWithMantine(<RawHarness onValidityChange={onValidityChange} />);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"a":');
expect(onValidityChange).toHaveBeenLastCalledWith(true);
expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument();
await setAceTextByLabel(/Arguments JSON/, '{"a":1}');
expect(onValidityChange).toHaveBeenLastCalledWith(false);
});
it("blocks submission for JSON that is not an object", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
renderWithMantine(<RawHarness onValidityChange={onValidityChange} />);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, "[1,2]");
expect(onValidityChange).toHaveBeenLastCalledWith(true);
expect(screen.getByText(/must be a JSON object/i)).toBeInTheDocument();
});
// Turning the switch back off unmounts the editor, so text that can no longer
// be seen must not keep the submit button disabled.
it("clears the block when switched back to the widgets", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
renderWithMantine(<RawHarness onValidityChange={onValidityChange} />);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"a":');
expect(onValidityChange).toHaveBeenLastCalledWith(true);
await user.click(screen.getByLabelText("Edit as JSON"));
expect(onValidityChange).toHaveBeenLastCalledWith(false);
});
it("clears an empty editor to no arguments rather than erroring", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm schema={schema} values={{ name: "a" }} onChange={onChange} />,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, "");
expect(onChange).toHaveBeenLastCalledWith({});
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
});
// #2123: switching a root union drops the outgoing branch's values. The raw
// editor is seeded from what the form holds, so a round trip through it must
// not resurrect them.
it("does not resurrect a pruned branch's values", async () => {
const user = userEvent.setup();
const union: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
title: "Email",
properties: { address: { type: "string", title: "Address" } },
},
{
type: "object",
title: "SMS",
properties: { phone: { type: "string", title: "Phone" } },
},
],
};
renderWithMantine(
<RawHarness schema={union} initial={{ address: "a@b.c" }} />,
);
// Switch branches — `address` belongs to the outgoing shape and is dropped.
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "SMS" }));
await enableRawJson(user);
expect(getAceTextByLabel(/Arguments JSON/)).not.toContain("address");
});
// A draft is typed for one entity. Left in the box, it would be submitted for
// the next — the same reasoning `resetKey` documents for every other
// draft-holding field.
it("discards the draft when the form moves to another entity", async () => {
const user = userEvent.setup();
// Uncontrolled on purpose: the values must stay put so the only thing that
// could carry the text across is the editor's own draft.
const { rerender } = renderWithMantine(
<SchemaForm
schema={schema}
values={{}}
onChange={vi.fn()}
resetKey="first_tool"
/>,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"name":"typed for the first"}');
rerender(
<SchemaForm
schema={schema}
values={{}}
onChange={vi.fn()}
resetKey="second_tool"
/>,
);
expect(getAceTextByLabel(/Arguments JSON/)).toBe("{}");
});
// A per-field switch would offer to edit a fragment of the payload the outer
// switch already covers whole — and would nest one JSON editor inside another
// the moment both were on.
it("offers no switch on a nested object's form", async () => {
const user = userEvent.setup();
const nested: InspectorFormSchema = {
type: "object",
properties: {
outer: {
type: "object",
title: "Outer",
properties: { inner: { type: "string", title: "Inner" } },
},
},
};
renderWithMantine(<RawHarness schema={nested} />);
expect(screen.getAllByLabelText("Edit as JSON")).toHaveLength(1);
await enableRawJson(user);
expect(screen.getAllByLabelText("Edit as JSON")).toHaveLength(1);
});
});

View file

@ -1,11 +1,11 @@
import {
Checkbox,
Group,
JsonInput,
MultiSelect,
NumberInput,
Select,
Stack,
Switch,
Text,
rem,
Textarea,
@ -23,6 +23,8 @@ import {
} from "react";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import { isJsonObject } from "../../../utils/jsonObjectDraft";
import { useValueChange } from "../../../hooks/useValueChange";
import type {
InspectorFormSchema,
@ -62,9 +64,13 @@ const BranchSelect = Select.withProps({
allowDeselect: false,
});
const SchemaJsonInput = JsonInput.withProps({
formatOnBlur: true,
autosize: true,
// The "Edit as JSON" flip for the whole arguments object (#2151). Right-aligned
// on its own row so it reads as a mode for the form rather than as its first
// field.
const RawJsonSwitch = Switch.withProps({
label: "Edit as JSON",
size: "sm",
labelPosition: "left",
});
// A string field after its enlarge button has been used (#2042). `autosize`
@ -416,14 +422,17 @@ function SchemaJsonField({
useDraftValidity(fieldName, !hasInvalidDraft, onValidityChange);
return (
<SchemaJsonInput
<JsonEditor
{...inputProps}
ariaLabel={inputProps.label}
value={draft}
error={
hasInvalidDraft
? "Not valid JSON — this field will be omitted"
: undefined
}
minLines={4}
maxLines={16}
onChange={(text) => {
setDraft(text);
onChange(parseJsonDraft(text));
@ -548,6 +557,113 @@ function SchemaNumberInput({
);
}
/**
* What the raw-JSON editor's draft text currently means: the arguments object
* to emit, or why it cannot be emitted.
*
* Empty text is `{}` rather than an error — clearing the box is how "send no
* arguments" is spelled, and an empty editor wearing a red error would read as
* broken. A non-object (`[1,2]`, `"a"`, `42`) is reported separately from text
* that is not JSON at all, because they are different mistakes: the second is
* usually mid-edit, the first is a shape the form cannot use however finished
* it is.
*/
function parseRawArgumentsDraft(
text: string,
): { ok: true; value: Record<string, unknown> } | { ok: false; error: string } {
if (text.trim() === "") return { ok: true, value: {} };
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: "Not valid JSON — this cannot be submitted" };
}
if (!isJsonObject(parsed)) {
return {
ok: false,
error: "Arguments must be a JSON object (`{ … }`)",
};
}
return { ok: true, value: parsed };
}
interface RawArgumentsFieldProps {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
disabled: boolean;
/** Mirrors {@link SchemaFormProps.onValidityChange} for this one editor. */
onInvalidChange: (hasInvalidDraft: boolean) => void;
}
/**
* The whole arguments object as one JSON document (#2151).
*
* v1 let a rendered form be flipped over to editing its arguments as JSON, and
* v2 shipped without the escape hatch — so a value the widgets cannot express,
* or a payload the user wants to paste in whole, had no route in.
*
* Same draft/value split as {@link SchemaJsonField}, and for the same reason:
* the text is the source of truth for what is displayed, the parent only ever
* sees a parsed object. Unlike `JsonObjectInput`, invalid text is **reported
* upward** rather than quietly dropped — this editor sits in front of a commit
* gesture (Execute / Open App / Submit), and #2020 says a draft that cannot be
* sent must disable it rather than silently submitting the last value that
* parsed.
*
* Note what this deliberately does *not* do: it never re-imposes the form's
* defaults or a branch's fields on the text. Switching a root union prunes the
* outgoing branch's values (#2123), and the pruned object is what arrives here
* — so a round trip through JSON cannot resurrect them.
*/
function RawArgumentsField({
values,
onChange,
disabled,
onInvalidChange,
}: RawArgumentsFieldProps) {
const [draft, setDraft] = useState(() => serializeJson(values));
// Canonical JSON of the last object this editor emitted, so the re-sync below
// can tell an external change from the parent echoing back our own — the
// draft alone cannot, since invalid text parses to nothing and every parent
// change would then look external. See `JsonObjectInput` for the long form.
const [echoed, setEchoed] = useState(() => serializeJson(values));
const parsed = parseRawArgumentsDraft(draft);
useValueChange(serializeJson(values), (next) => {
if (next === echoed) return;
setDraft(next);
setEchoed(next);
});
// An effect, not a render-time call: validity also moves when the *parent*
// replaces the values, and reporting during that render would update another
// component mid-render. The cleanup clears the block, so toggling back to the
// widgets cannot leave submission disabled on text no longer on screen.
useEffect(() => {
onInvalidChange(!parsed.ok);
return () => onInvalidChange(false);
}, [parsed.ok, onInvalidChange]);
return (
<JsonEditor
ariaLabel="Arguments JSON"
value={draft}
disabled={disabled}
error={parsed.ok ? undefined : parsed.error}
minLines={8}
maxLines={24}
onChange={(text) => {
setDraft(text);
const next = parseRawArgumentsDraft(text);
if (!next.ok) return;
setEchoed(serializeJson(next.value));
onChange(next.value);
}}
/>
);
}
export interface SchemaFormProps {
schema: InspectorFormSchema;
values: Record<string, unknown>;
@ -587,6 +703,16 @@ export interface SchemaFormProps {
* called when the answer changes, not on every render.
*/
onValidityChange?: (hasInvalidDraft: boolean) => void;
/**
* Whether to offer the "Edit as JSON" switch that flips the whole arguments
* object over to a raw editor (#2151).
*
* Defaults to on. Passed `false` for a **nested** object field's form, where
* a per-field switch would offer to edit a fragment of the payload the outer
* switch already covers whole — and would put a second, narrower JSON editor
* inside the first the moment both were on.
*/
allowRawJson?: boolean;
}
function getDefaultValue(fieldSchema: InspectorFormSchema): unknown {
@ -613,6 +739,7 @@ export function SchemaForm({
disabled = false,
resetKey,
onValidityChange,
allowRawJson = true,
}: SchemaFormProps) {
// Composition at the root of the schema, flattened before anything is
// rendered (#2123). `allOf` is folded into `base`; a top-level `oneOf`/`anyOf`
@ -670,6 +797,20 @@ export function SchemaForm({
() => new Set(),
);
// Whether the whole arguments object is being edited as JSON (#2151), and
// whether that editor's text can be sent. Kept apart from `invalidFields`
// rather than reported under a reserved name: field names come straight out
// of a server's schema, so any sentinel this form invented could collide with
// a real argument and clear a block the user cannot see.
const [rawJsonMode, setRawJsonMode] = useState(false);
const [rawJsonInvalid, setRawJsonInvalid] = useState(false);
// Stable so `RawArgumentsField`'s reporting effect subscribes once rather
// than per render — the same reason `reportFieldValidity` below is memoized.
const reportRawJsonValidity = useCallback((invalid: boolean) => {
setRawJsonInvalid(invalid);
}, []);
// The names of string fields the user has enlarged into a multiline text area
// (#2042). One-way by design — see EnlargeButton — so a name only ever enters
// this set.
@ -791,7 +932,7 @@ export function SchemaForm({
// rendered holds no drafts, so an unmounted nested form must not leave the
// outer one blocked. It runs in the same commit as the re-report, so the
// transient `false` is never rendered.
const hasInvalidDraft = invalidFields.size > 0;
const hasInvalidDraft = invalidFields.size > 0 || rawJsonInvalid;
useEffect(() => {
notifyValidity.current?.(hasInvalidDraft);
return () => notifyValidity.current?.(false);
@ -1217,6 +1358,9 @@ export function SchemaForm({
onValidityChange={(nestedInvalid) =>
reportFieldValidity(fieldName, !nestedInvalid)
}
// The outer switch already edits this object as part of the whole
// payload; a second one here would nest a JSON editor inside it.
allowRawJson={false}
/>
</IndentedStack>
</Stack>
@ -1245,6 +1389,15 @@ export function SchemaForm({
return (
<Stack gap="sm">
{allowRawJson && (
<Group justify="flex-end">
<RawJsonSwitch
checked={rawJsonMode}
disabled={disabled}
onChange={(event) => setRawJsonMode(event.currentTarget.checked)}
/>
</Group>
)}
{activeBranch && branches.length > 1 && (
<BranchSelect
data={branches.map((branch, index) => ({
@ -1259,8 +1412,21 @@ export function SchemaForm({
}
/>
)}
{Object.entries(properties).map(([fieldName, fieldSchema]) =>
renderField(fieldName, fieldSchema),
{rawJsonMode ? (
// Keyed by the entity and branch, like every draft-holding field: text
// typed for one tool must not be left in the box for the next, and
// switching a root union prunes values the stale draft would restore.
<RawArgumentsField
key={draftKey ?? "raw-json"}
values={values}
onChange={onChange}
disabled={disabled}
onInvalidChange={reportRawJsonValidity}
/>
) : (
Object.entries(properties).map(([fieldName, fieldSchema]) =>
renderField(fieldName, fieldSchema),
)
)}
</Stack>
);

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