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
2 changed files with 336 additions and 75 deletions
|
|
@ -103,6 +103,45 @@ describe("replayableParams", () => {
|
|||
).toEqual({ params: { cursor: "abc" }, dropped: ["_meta"] });
|
||||
});
|
||||
|
||||
// `listTools` builds its params with `cursor !== undefined`, carrying `""`
|
||||
// deliberately — its own comment says dropping it asks for page one again.
|
||||
it("keeps an empty cursor on tools/list, which preserves it", () => {
|
||||
expect(replayableParams("tools/list", { cursor: "" })).toEqual({
|
||||
params: { cursor: "" },
|
||||
dropped: [],
|
||||
});
|
||||
});
|
||||
|
||||
// The other four adapters build theirs with a truthiness check, so `""` never
|
||||
// reaches the wire. Reporting it as kept would show `{"cursor":""}` in the
|
||||
// editor while `{}` was sent.
|
||||
it.each([
|
||||
"prompts/list",
|
||||
"resources/list",
|
||||
"resources/templates/list",
|
||||
"tasks/list",
|
||||
])("drops an empty cursor on %s, which does not preserve it", (method) => {
|
||||
expect(replayableParams(method, { cursor: "" })).toEqual({
|
||||
params: undefined,
|
||||
dropped: ["cursor"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a non-empty cursor on every list method", () => {
|
||||
for (const method of [
|
||||
"tools/list",
|
||||
"prompts/list",
|
||||
"resources/list",
|
||||
"resources/templates/list",
|
||||
"tasks/list",
|
||||
]) {
|
||||
expect(replayableParams(method, { cursor: "abc" })).toEqual({
|
||||
params: { cursor: "abc" },
|
||||
dropped: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 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", () => {
|
||||
|
|
@ -199,6 +238,71 @@ describe("reshapedReplayParam", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
// `callTool` runs every *string* entry through `convertToolParameters`,
|
||||
// because the Tools form hands everything over as text — so a string typed
|
||||
// against a numeric schema is sent as a number.
|
||||
it("rejects a tool argument the schema would coerce", () => {
|
||||
const tool: Tool = {
|
||||
name: "add",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
reshapedReplayParam(
|
||||
"tools/call",
|
||||
{ name: "add", arguments: { count: "2" } },
|
||||
tool,
|
||||
),
|
||||
).toMatch(/`count` would be converted/);
|
||||
});
|
||||
|
||||
it("accepts a tool argument already written with the declared type", () => {
|
||||
const tool: Tool = {
|
||||
name: "add",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { count: { type: "number" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
reshapedReplayParam(
|
||||
"tools/call",
|
||||
{ name: "add", arguments: { count: 2 } },
|
||||
tool,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a string argument the schema declares as a string", () => {
|
||||
const tool: Tool = {
|
||||
name: "echo",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
},
|
||||
};
|
||||
expect(
|
||||
reshapedReplayParam(
|
||||
"tools/call",
|
||||
{ name: "echo", arguments: { message: "hi" } },
|
||||
tool,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// Nothing can be said about a coercion whose schema is unknown, and refusing
|
||||
// on that basis would block a replay that is very likely fine.
|
||||
it("skips the coercion check when the tool is not known", () => {
|
||||
expect(
|
||||
reshapedReplayParam("tools/call", {
|
||||
name: "add",
|
||||
arguments: { count: "2" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("has nothing to say about methods that take no arguments", () => {
|
||||
expect(reshapedReplayParam("tools/list", { cursor: "a" })).toBeNull();
|
||||
expect(reshapedReplayParam("resources/read", { uri: "x://y" })).toBeNull();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
|||
// `components → lib → utils` direction still holds: `LogEntryData` is the shape
|
||||
// the Logs screen renders, and this module exists to produce exactly that.
|
||||
import type { LogEntryData } from "../components/elements/LogEntry/LogEntry";
|
||||
import { convertToolParameters } from "@inspector/core/json/jsonUtils.js";
|
||||
import { isReplayableProtocolMethod } from "../utils/replayableProtocolMethods";
|
||||
|
||||
// Derive `LogEntryData[]` from the MessageLog by filtering for the
|
||||
|
|
@ -113,7 +114,19 @@ export function replayableParams(
|
|||
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"] : [];
|
||||
//
|
||||
// And an **empty** one survives on `tools/list` alone. `listTools` builds
|
||||
// its params with `cursor !== undefined`, carrying `""` deliberately —
|
||||
// its own comment explains that dropping it asks for page one again. The
|
||||
// other four adapters use a truthiness check and drop it. That asymmetry
|
||||
// looks like a latent bug in those four rather than an intention, but
|
||||
// this function's job is to describe what the dispatch *does*, so it
|
||||
// reports the empty cursor as dropped where it would be dropped.
|
||||
kept =
|
||||
typeof params.cursor === "string" &&
|
||||
(method === "tools/list" || params.cursor !== "")
|
||||
? ["cursor"]
|
||||
: [];
|
||||
break;
|
||||
default:
|
||||
// `ping` takes nothing, and an unreplayable method never reaches here.
|
||||
|
|
@ -147,13 +160,19 @@ export function replayableParams(
|
|||
* - `arguments: [1,2]` or `arguments: 4` is not nullish, so the cast carries it
|
||||
* through unchanged into a call whose type says it is a named-argument
|
||||
* record. Nothing reshapes it, but nothing can send it either.
|
||||
* - For **`prompts/get` only**, a non-string *value*. `getPrompt` runs
|
||||
* - For **`prompts/get`**, a non-string *value*. `getPrompt` runs
|
||||
* `convertPromptArguments`, which `JSON.stringify`s anything that is not
|
||||
* already a string — so `{"count": 2}` is sent as `{"count": "2"}` while the
|
||||
* editor still shows the number. This is not a quirk to route around: the
|
||||
* spec types `GetPromptRequest.params.arguments` as `Record<string, string>`,
|
||||
* so a string is the only thing a prompt argument can be. `tools/call` is
|
||||
* unaffected — `callTool` sends its arguments as given.
|
||||
* so a string is the only thing a prompt argument can be.
|
||||
* - For **`tools/call`**, the mirror image. `callTool` runs every *string*
|
||||
* entry through `convertToolParameters`, because the Tools form hands
|
||||
* everything over as text — so `{"count": "2"}` against a schema declaring a
|
||||
* number is sent as `{"count": 2}`. Detected by running that same conversion
|
||||
* and comparing, rather than by reimplementing its rules, so the two cannot
|
||||
* drift. Needs the `tool`; without one this check is skipped, since nothing
|
||||
* can be said about a coercion whose schema is unknown.
|
||||
*
|
||||
* `name` and `uri` are deliberately not checked here: the dispatch already
|
||||
* refuses a missing or non-string one with a reason the caller surfaces as a
|
||||
|
|
@ -162,6 +181,7 @@ export function replayableParams(
|
|||
export function reshapedReplayParam(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
tool?: Tool,
|
||||
): string | null {
|
||||
if (!params) return null;
|
||||
if (method !== "tools/call" && method !== "prompts/get") return null;
|
||||
|
|
@ -182,9 +202,35 @@ export function reshapedReplayParam(
|
|||
return `A prompt argument is always a string — ${coerced.join(", ")} would be sent as JSON text`;
|
||||
}
|
||||
}
|
||||
if (method === "tools/call" && tool) {
|
||||
const coerced = coercedToolArgs(tool, args as Record<string, unknown>);
|
||||
if (coerced.length > 0) {
|
||||
return `${coerced.join(", ")} would be converted to the type ${tool.name}'s schema declares — write the value with that type instead`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The string-valued argument names `callTool` would convert, per the tool's
|
||||
* schema.
|
||||
*
|
||||
* Runs the conversion the client runs and compares, rather than restating its
|
||||
* rules: `convertToolParameters` is the function on the other side, so asking
|
||||
* it is the only way this cannot drift from what is actually sent.
|
||||
*/
|
||||
function coercedToolArgs(tool: Tool, args: Record<string, unknown>): string[] {
|
||||
const stringArgs: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (typeof value === "string") stringArgs[key] = value;
|
||||
}
|
||||
if (Object.keys(stringArgs).length === 0) return [];
|
||||
const converted = convertToolParameters(tool, stringArgs);
|
||||
return Object.keys(stringArgs)
|
||||
.filter((key) => converted[key] !== stringArgs[key])
|
||||
.map((key) => `\`${key}\``);
|
||||
}
|
||||
|
||||
export async function replayProtocolRequest(
|
||||
client: ReplayClient,
|
||||
method: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue