fix(web): refuse a raw-JSON tool argument the schema would retype (#2171)
The Tools and Apps tabs' "Edit as JSON" editor sent its draft through the
string-to-declared-type conversion `callTool` applies for the widget form, so
`{"count":"01"}` against a numeric schema left as `{"count":1}` — the editor
showed one payload and the wire carried another.
Both JSON-authoring surfaces now refuse such a draft and name the value to
rewrite. Edit-and-replay already did; this makes the Tools and Apps forms
agree with it, which is the consistency #2171's acceptance asks for.
`coercedArgumentNames` and `coercedArgumentsError` move into core/json so the
two surfaces share one predicate and one sentence — the check runs the real
conversion and compares rather than restating its rules, so it cannot drift
from what would actually be sent. Enforcement is opt-in
(`SchemaFormProps.enforceToolArgumentTypes`): an elicitation renders through
the same form and its values are never converted, so enforcing there would
refuse a draft for a reason that is not true of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
4d69dbf999
commit
9516299d8d
1 changed files with 435 additions and 27 deletions
|
|
@ -9,7 +9,10 @@ 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 {
|
||||
coercedArgumentNames,
|
||||
coercedArgumentsError,
|
||||
} from "@inspector/core/json/jsonUtils.js";
|
||||
import { isReplayableProtocolMethod } from "../utils/replayableProtocolMethods";
|
||||
|
||||
// Derive `LogEntryData[]` from the MessageLog by filtering for the
|
||||
|
|
@ -167,13 +170,19 @@ export function replayableParams(
|
|||
* spec types `GetPromptRequest.params.arguments` as `Record<string, string>`,
|
||||
* 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
|
||||
* entry through `convertToolParameters`, because the widget 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.
|
||||
*
|
||||
* The Tools and Apps tabs' own "Edit as JSON" switch refuses the identical
|
||||
* draft, through the identical helper (#2171). Both are JSON documents whose
|
||||
* values already carry their types, so in both the conversion would send
|
||||
* something other than what the editor showed — and one of the two answering
|
||||
* differently is the inconsistency #2171 was filed to remove.
|
||||
*
|
||||
* `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
|
||||
* toast, so those fail visibly rather than silently.
|
||||
|
|
@ -203,34 +212,20 @@ export function reshapedReplayParam(
|
|||
}
|
||||
}
|
||||
if (method === "tools/call" && tool) {
|
||||
const coerced = coercedToolArgs(tool, args as Record<string, unknown>);
|
||||
// The same check the Tools/Apps form's raw-JSON editor makes, from the same
|
||||
// helper, so the two JSON-authoring surfaces cannot disagree about which
|
||||
// drafts are sendable (#2171).
|
||||
const coerced = coercedArgumentNames(
|
||||
tool.inputSchema,
|
||||
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 coercedArgumentsError(coerced, tool.name);
|
||||
}
|
||||
}
|
||||
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