inspector/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx
cliffhall 03bf9ece42 fix(web): resolve the replay tool from the draft, and say why a draft is stuck
Copilot's ninth pass, three comments, two of them consequences of round 8.

- The coercion check resolved the tool from the *entry's* name while the modal
  lets `name` be edited, so retargeting an `echo` replay at `add` validated
  against `echo`. The modal now takes the whole tool list and resolves from the
  draft's current name, which is the one the dispatch will look up.

- `IMPRECISE_INTEGER_ERROR` (was `ROUNDED_INTEGER_ERROR`) no longer names the
  safe-integer range, because the range is not the rule: a whole number past
  ±(2^53 − 1) that is exactly representable round-trips and is accepted. It
  talks about exact representability instead, and its doc links the helper that
  actually exists.

- `SchemaJsonField` carries the parser's reason instead of flattening every
  case to "Not valid JSON", which misdiagnosed a document that parses fine and
  holds a number this client cannot send — leaving the user reading about
  syntax while looking at syntactically fine JSON.

The last one had a bug the gate caught rather than the tests: empty text is not
a *problem*, so routing the value through the reason check let `JSON.parse("")`
throw out of the change handler. It surfaced as five unhandled errors across
four suites with every assertion still green, which is why `npm run ci` and not
`npm run test` is the gate. Excluded explicitly, with a regression test.

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>
2026-08-27 05:16:23 -04:00

238 lines
8.3 KiB
TypeScript

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