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:
parent
8f4aa7f8af
commit
fcdc65b289
7 changed files with 336 additions and 75 deletions
|
|
@ -204,7 +204,7 @@ describe("EditReplayModal", () => {
|
|||
await setAceText('{"name":"echo","arguments":{"id":9007199254740993}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/Whole numbers must be within/),
|
||||
screen.getByText(/A whole number written out in full must be within/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
Badge,
|
||||
Collapse,
|
||||
|
|
@ -28,6 +29,12 @@ export interface MrtrConversationProps {
|
|||
/** Compact per-round layout for the narrow monitoring column. */
|
||||
embedded?: boolean;
|
||||
onReplay: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||
/**
|
||||
* The connected server's tools. Forwarded to each entry only so an edited
|
||||
* `tools/call` replay can tell whether an argument would be coerced by the
|
||||
* schema on the way out (#2151).
|
||||
*/
|
||||
tools?: Tool[];
|
||||
onTogglePin: (id: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +130,7 @@ function formatRoundLabel(index: number): string {
|
|||
}
|
||||
|
||||
export function MrtrConversation({
|
||||
tools,
|
||||
requestState,
|
||||
rounds,
|
||||
pinnedIds,
|
||||
|
|
@ -187,6 +195,7 @@ export function MrtrConversation({
|
|||
onReplay(round.id, overrideParams)
|
||||
}
|
||||
onTogglePin={() => onTogglePin(round.id)}
|
||||
tools={tools}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Text,
|
||||
} from "@mantine/core";
|
||||
import { RiErrorWarningLine } from "react-icons/ri";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
|
|
@ -70,6 +71,13 @@ export interface ProtocolEntryProps {
|
|||
* correlated HTTP record.
|
||||
*/
|
||||
correlatedHttpStatus?: number;
|
||||
/**
|
||||
* The connected server's tools, used only to tell whether an edited
|
||||
* `tools/call` argument would be coerced by the schema on the way out
|
||||
* (#2151). Optional: without it that one check is skipped, which is the right
|
||||
* answer when the tool list is not known rather than a reason to block.
|
||||
*/
|
||||
tools?: Tool[];
|
||||
}
|
||||
|
||||
const EntryContainer = Card.withProps({
|
||||
|
|
@ -327,6 +335,7 @@ export function ProtocolEntry({
|
|||
embedded = false,
|
||||
onRevealInNetwork,
|
||||
correlatedHttpStatus,
|
||||
tools,
|
||||
}: ProtocolEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
const [isEditingReplay, setIsEditingReplay] = useState(false);
|
||||
|
|
@ -337,6 +346,12 @@ export function ProtocolEntry({
|
|||
const requestParams = extractParams(entry);
|
||||
const canReplay = isReplayableProtocolMethod(method);
|
||||
const editableParams = replayableParams(method, requestParams);
|
||||
// The tool this entry called, when it called one — the schema an edited
|
||||
// argument would be coerced against.
|
||||
const replayedTool =
|
||||
method === "tools/call" && typeof requestParams?.name === "string"
|
||||
? tools?.find((candidate) => candidate.name === requestParams.name)
|
||||
: undefined;
|
||||
const resultType = extractResultType(entry);
|
||||
const subscriptionId = extractSubscriptionId(entry);
|
||||
|
||||
|
|
@ -488,6 +503,7 @@ export function ProtocolEntry({
|
|||
// the editor must not offer a field that Send would drop.
|
||||
params={editableParams.params}
|
||||
droppedParamKeys={editableParams.dropped}
|
||||
tool={replayedTool}
|
||||
onSend={onReplay}
|
||||
onClose={() => setIsEditingReplay(false)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
|
|
@ -49,6 +50,12 @@ export interface ProtocolListPanelProps {
|
|||
/** Export just one section's entries. */
|
||||
onExportSection: (section: ProtocolSectionName) => void;
|
||||
onReplay: (id: string, overrideParams?: ReplayParamsOverride) => void;
|
||||
/**
|
||||
* The connected server's tools. Forwarded to each entry only so an edited
|
||||
* `tools/call` replay can tell whether an argument would be coerced by the
|
||||
* schema on the way out (#2151).
|
||||
*/
|
||||
tools?: Tool[];
|
||||
onTogglePin: (id: string) => void;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (next: SortDirection) => void;
|
||||
|
|
@ -236,6 +243,7 @@ function matchesFilters(
|
|||
}
|
||||
|
||||
export function ProtocolListPanel({
|
||||
tools,
|
||||
entries,
|
||||
pinnedIds,
|
||||
searchText,
|
||||
|
|
@ -319,6 +327,7 @@ export function ProtocolListPanel({
|
|||
embedded={embedded}
|
||||
onReplay={onReplay}
|
||||
onTogglePin={onTogglePin}
|
||||
tools={tools}
|
||||
/>
|
||||
) : (
|
||||
<ProtocolEntry
|
||||
|
|
@ -335,6 +344,7 @@ export function ProtocolListPanel({
|
|||
: undefined
|
||||
}
|
||||
correlatedHttpStatus={correlatedStatusById?.get(row.entry.id)}
|
||||
tools={tools}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1925,7 +1925,7 @@ describe("SchemaForm raw JSON (#2151)", () => {
|
|||
await setAceTextByLabel(/Arguments JSON/, '{"id":9007199254740993}');
|
||||
expect(onValidityChange).toHaveBeenLastCalledWith(true);
|
||||
expect(
|
||||
screen.getByText(/Whole numbers must be within/),
|
||||
screen.getByText(/A whole number written out in full must be within/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
|||
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
|
||||
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
||||
import {
|
||||
hasRoundedInteger,
|
||||
hasImpreciseIntegerLiteral,
|
||||
isJsonObject,
|
||||
ROUNDED_INTEGER_ERROR,
|
||||
} from "../../../utils/jsonObjectDraft";
|
||||
|
|
@ -300,7 +300,7 @@ function parseJsonDraft(text: string): unknown {
|
|||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isSerializableJson(parsed) || hasRoundedInteger(parsed)) {
|
||||
if (!isSerializableJson(parsed) || hasImpreciseIntegerLiteral(text)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
|
|
@ -619,7 +619,7 @@ function parseRawArgumentsDraft(
|
|||
}
|
||||
// The quieter half of the same defect: a whole number past 2^53−1 parses to
|
||||
// the nearest double, so the draft shows digits the wire will not carry.
|
||||
if (hasRoundedInteger(parsed)) {
|
||||
if (hasImpreciseIntegerLiteral(text)) {
|
||||
return { ok: false, error: ROUNDED_INTEGER_ERROR };
|
||||
}
|
||||
return { ok: true, value: parsed };
|
||||
|
|
|
|||
Loading…
Reference in a new issue