fix(web): read the literal, not the parsed number; project cursors and tool args

Copilot's eighth pass, three comments, all real.

- The precision check now reads the **source text**. Inferring the literal from
  `String(value)` was wrong in both directions: it accepted every full-form
  integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
  written back as `1e+21`), and refused `2^54`, which is written out in full
  and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
  numeric tokens — skipping strings, so a digit-string value is untouched — and
  compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.

- `""` is now reported as a surviving cursor for `tools/list` only. It builds
  its params with `cursor !== undefined` and carries the empty string
  deliberately; the other four list adapters use a truthiness check and drop
  it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
  looks like a latent bug in those four, but this function's job is to describe
  what the dispatch does.

- `callTool` runs every string-valued argument through `convertToolParameters`,
  so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
  `reshapedReplayParam` now takes the tool and detects that by running the same
  conversion and comparing, rather than restating its rules — the two cannot
  drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
  unknown tool skips the check rather than blocking a replay.

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 04:56:22 -04:00
commit fcdc65b289

View file

@ -1,8 +1,9 @@
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 {
hasRoundedInteger,
hasImpreciseIntegerLiteral,
isJsonObject,
ROUNDED_INTEGER_ERROR,
} from "../../../utils/jsonObjectDraft";
@ -32,6 +33,12 @@ export interface EditReplayModalProps {
* the one thing an inspector must not do.
*/
droppedParamKeys?: string[];
/**
* The tool a `tools/call` replay would invoke, when it is known. Only used to
* detect an argument the client would coerce to the type the schema declares
* — see {@link reshapedReplayParam}.
*/
tool?: Tool;
/**
* Re-issue the request with these params. `null` means "send no params",
* which is a request an empty editor can legitimately express — see
@ -75,6 +82,7 @@ function formatDroppedKeys(keys: string[]): string {
function parseParamsDraft(
method: string,
text: string,
tool: Tool | undefined,
):
| { ok: true; value: Record<string, unknown> | null }
| { ok: false; error: string } {
@ -99,7 +107,7 @@ function parseParamsDraft(
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
};
}
if (hasRoundedInteger(parsed)) {
if (hasImpreciseIntegerLiteral(text)) {
return { ok: false, error: ROUNDED_INTEGER_ERROR };
}
// Seeding from the projection is only half of it: a key the dispatcher does
@ -118,7 +126,7 @@ function parseParamsDraft(
}
// Surviving the key check is not the same as being sent as written: see
// `reshapedReplayParam` for the two ways `arguments` does not.
const reshaped = reshapedReplayParam(method, parsed);
const reshaped = reshapedReplayParam(method, parsed, tool);
if (reshaped !== null) {
return { ok: false, error: reshaped };
}
@ -134,13 +142,14 @@ function EditReplayForm({
method,
params,
droppedParamKeys,
tool,
onSend,
onClose,
}: Omit<EditReplayModalProps, "opened">) {
const [draft, setDraft] = useState(() =>
params === undefined ? "" : JSON.stringify(params, null, 2),
);
const parsed = parseParamsDraft(method, draft);
const parsed = parseParamsDraft(method, draft, tool);
return (
<Stack gap="sm">
@ -199,6 +208,7 @@ export function EditReplayModal({
method,
params,
droppedParamKeys,
tool,
onSend,
onClose,
}: EditReplayModalProps) {
@ -208,6 +218,7 @@ export function EditReplayModal({
method={method}
params={params}
droppedParamKeys={droppedParamKeys}
tool={tool}
onSend={onSend}
onClose={onClose}
/>