fix(web): seed Edit-and-replay from what replay sends, name each JSON editor

Copilot's fourth pass, one inline plus two suppressed. All three were right.

- The modal presented the whole captured frame as editable, but replay does
  not re-send a frame: it dispatches through the typed InspectorClient
  methods, whose signatures have no room for `_meta` (a captured `tools/call`
  carries `_meta.progressToken`) or for any list-request key but `cursor`. So
  editing `_meta` and pressing Send transmitted something other than what was
  on screen. `replayableParams` now projects the frame down to what the
  dispatcher actually reads, the modal seeds from that, and it names what was
  left out rather than leaving the difference to be discovered. The projection
  lives beside the dispatch switch because the two are halves of one contract.

- `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what
  it holds. Every one of them announced "JSON content", and an expanded
  Protocol entry has two while the list has many pairs — leaving a screen
  reader with indistinguishable textboxes and only the visible headings to
  tell them apart. Protocol entries now name theirs per method and direction,
  Network entries per request/response body.

- `isJsonDocument` became `formatJsonDocument`, returning the formatted text
  instead of a boolean. The heuristic path parsed the payload to decide and
  then parsed it again to render; Protocol and Network hand over serialized
  bodies as untyped text and are not virtualized, so a long history paid twice
  per payload per render.

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 03:37:03 -04:00
commit 5abe289caa
2 changed files with 413 additions and 31 deletions

View file

@ -4,6 +4,7 @@ import type { MessageEntry } from "@inspector/core/mcp/types.js";
import {
messagesToLogEntries,
replayProtocolRequest,
replayableParams,
type ReplayClient,
} from "./protocolReplay";
@ -61,6 +62,79 @@ describe("messagesToLogEntries", () => {
});
});
// The projection the Edit-and-replay editor seeds from. It is the other half
// of the dispatch switch: replay re-issues through the typed client methods
// rather than re-sending the frame, so anything those signatures have no room
// for is dropped — and an editor that offered it would invite an edit that Send
// silently discards.
describe("replayableParams", () => {
it("keeps the name and arguments of a tool call, and drops _meta", () => {
expect(
replayableParams("tools/call", {
name: "get_weather",
arguments: { city: "Boston" },
_meta: { progressToken: 2 },
}),
).toEqual({
params: { name: "get_weather", arguments: { city: "Boston" } },
dropped: ["_meta"],
});
});
it("keeps the name and arguments of a prompt get", () => {
expect(
replayableParams("prompts/get", { name: "greet", arguments: { a: 1 } }),
).toEqual({
params: { name: "greet", arguments: { a: 1 } },
dropped: [],
});
});
it("keeps only the uri of a resource read", () => {
expect(
replayableParams("resources/read", { uri: "file:///a", _meta: {} }),
).toEqual({ params: { uri: "file:///a" }, dropped: ["_meta"] });
});
it("keeps only a string cursor on a list request", () => {
expect(
replayableParams("tools/list", { cursor: "abc", _meta: {} }),
).toEqual({ params: { cursor: "abc" }, dropped: ["_meta"] });
});
// The dispatcher ignores a non-string cursor, so keeping it would put a value
// in the editor that changes nothing about what is sent.
it("drops a cursor that is not a string", () => {
expect(replayableParams("resources/list", { cursor: 7 })).toEqual({
params: undefined,
dropped: ["cursor"],
});
});
it("keeps nothing for ping", () => {
expect(replayableParams("ping", { _meta: {} })).toEqual({
params: undefined,
dropped: ["_meta"],
});
});
it("reports no params for a request that carried none", () => {
expect(replayableParams("tools/list", undefined)).toEqual({
params: undefined,
dropped: [],
});
});
// A name only counts as dropped if it was there: an absent `arguments` is not
// something the user is being told they will lose.
it("does not report absent names as dropped", () => {
expect(replayableParams("tools/call", { name: "echo" })).toEqual({
params: { name: "echo" },
dropped: [],
});
});
});
describe("replayProtocolRequest", () => {
const tool: Tool = { name: "echo", inputSchema: { type: "object" } };

View file

@ -70,6 +70,69 @@ export type ReplayClient = Pick<
*/
export type ReplayParamsOverride = Record<string, unknown> | null | undefined;
/**
* The params of a replayed request that `replayProtocolRequest` actually reads,
* per method.
*
* This is the other half of the switch below, and the two must move together.
* Replay does not re-send a captured frame verbatim — it dispatches through the
* typed `InspectorClient` methods, which take named arguments. Everything those
* signatures have no room for is dropped: `_meta` on any method (a captured
* `tools/call` carries `_meta.progressToken`), and every key of a list request
* other than `cursor`.
*
* That is invisible and harmless for one-click Replay, which re-sends what it
* was given. It is neither once the params are put in front of the user to
* edit (#2151): an editor showing `_meta` invites them to change it, and Send
* would then transmit something other than what they were looking at. So the
* Edit-and-replay modal seeds from *this* rather than from the raw frame, and
* names what it left out.
*/
export function replayableParams(
method: string,
params: Record<string, unknown> | undefined,
): { params: Record<string, unknown> | undefined; dropped: string[] } {
if (!params) return { params: undefined, dropped: [] };
const keep = (...names: string[]): string[] =>
names.filter((name) => Object.hasOwn(params, name));
let kept: string[];
switch (method) {
case "tools/call":
case "prompts/get":
kept = keep("name", "arguments");
break;
case "resources/read":
kept = keep("uri");
break;
case "tools/list":
case "prompts/list":
case "resources/list":
case "resources/templates/list":
case "tasks/list":
// Only a *string* cursor survives: the dispatcher ignores any other type,
// so keeping it would put a value in the editor that changes nothing.
kept = typeof params.cursor === "string" ? ["cursor"] : [];
break;
default:
// `ping` takes nothing, and an unreplayable method never reaches here.
kept = [];
break;
}
const dropped = Object.keys(params).filter((name) => !kept.includes(name));
// `fromEntries`, never an assignment loop: an argument legitimately named
// `__proto__` would otherwise reach the prototype setter.
return {
params:
kept.length > 0
? Object.fromEntries(kept.map((name) => [name, params[name]]))
: undefined,
dropped,
};
}
export async function replayProtocolRequest(
client: ReplayClient,
method: string,