Uh oh!
There was an error while loading. Please reload this page.
fix(web): refuse a raw-JSON tool argument the schema would retype - #2175
Conversation
There was a problem hiding this comment.
Pull request overview
Ensures JSON-authored tool arguments reach MCP servers without schema coercion across Tools, Apps, tasks, and protocol replay.
Changes:
- Adds shared
ToolCallOptions.skipArgumentCoercion. - Propagates raw-JSON mode through web execution paths.
- Updates replay behavior, tests, and documentation.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
core/mcp/types.ts | Defines shared tool-call options. |
core/mcp/inspectorClient.ts | Applies optional argument coercion consistently. |
core/mcp/index.ts | Exports the new options type. |
clients/web/src/test/integration/mcp/inspectorClient-raw-json-args.test.ts | Verifies raw and coerced wire payloads. |
clients/web/src/lib/protocolReplay.ts | Replays tool arguments without coercion. |
clients/web/src/lib/protocolReplay.test.ts | Tests uncoerced replay dispatch. |
clients/web/src/components/views/InspectorView/InspectorView.tsx | Extends execution callback contracts. |
clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx | Forwards raw-mode state for tool calls. |
clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx | Tests Tools callback propagation. |
clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx | Removes obsolete tool propagation. |
clients/web/src/components/screens/AppsScreen/AppsScreen.tsx | Forwards raw-mode state for Apps. |
clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx | Tests Apps callback propagation. |
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx | Captures raw mode during execution. |
clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx | Tests execution mode reporting. |
clients/web/src/components/groups/SchemaForm/SchemaForm.tsx | Reports raw-editor mode changes. |
clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx | Tests mode changes and cleanup. |
clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx | Removes obsolete tools prop. |
clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx | Removes replay schema plumbing. |
clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx | Removes obsolete tools forwarding. |
clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx | Allows schema-invalid JSON replay arguments. |
clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx | Tests formerly coerced argument submission. |
clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx | Reports raw mode when opening Apps. |
clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx | Tests Apps detail mode reporting. |
clients/web/src/App.tsx | Maps raw mode to core call options. |
clients/web/README.md | Documents JSON argument semantics. |
Suppressed comments (1)
clients/web/src/App.tsx:1067
- The Apps path has the same untested final handoff:
AppsScreenverifies that it emitsrawJsonArgs, and core verifies an explicitly supplied option, but no test drivesApp.onOpenAppand checks that this call combinesskipArgumentCoercion: truewithskipOutputValidation: true. A regression here would again give the app uncoerced input while sending coerced input to the server without failing the suite. Add an App-level assertion for the raw Apps callback and these options.
{
skipOutputValidation: true,
skipArgumentCoercion: rawJsonArgs === true,
},
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Uh oh!
There was an error while loading. Please reload this page.
cliffhall
commented
Aug 28, 2026
Review round 1 — addressedCopilot's one finding (and its suppressed twin on the Apps path): the App.tsx seam was untested. Correct, and it was the one place the whole change could silently stop working — the screens' tests stop at the Fixed in
The second row is the one worth keeping honest: the widget form still hands its values over as text, so this is not a one-way switch and coercion has to stay on for an ordinary call.
|
Uh oh!
There was an error while loading. Please reload this page.
cliffhall
commented
Aug 28, 2026
Review round 2 — addressedOne finding: the new test helper used Fixed in The ~15 pre-existing
|
cliffhall
commented
Aug 28, 2026
Review round 3 — cleanCopilot reviewed 26 of 26 changed files and generated no new comments. Both earlier findings are addressed:
|
) 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>
4059e95 to
9516299Comparecliffhall
commented
Aug 28, 2026
Direction reversed — the refusal now goes both waysMaintainer call on the product question #2171 left open: a raw-JSON argument the schema would retype is refused, not sent. The earlier revisions of this branch did the opposite — they made both surfaces send verbatim — so the branch has been rewritten (force-push, Everything the previous approach added is gone: no That also removes the Earlier review rounds are superseded: both findings were against Re-requesting review on the new diff. |
Uh oh!
There was an error while loading. Please reload this page.
The panel tests stopped at the disabled button, which leaves the wire claim unverified: a screen that flagged the draft and still fired its callback would have satisfied every one of them. `ToolsScreen` and `AppsScreen` now assert `onCallTool` / `onOpenApp` are not called for a refused draft, and ARE called once the value is rewritten with the declared type — so the draft is demonstrably what blocks the dispatch rather than an inert screen. The plain and "Run as task" paths need no separate case: the panel has one Execute button whose disabled state does not depend on the toggle, and the callTool/callToolStream split happens later in App.tsx, downstream of the callback a refused draft never reaches. 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>
cliffhall
commented
Aug 28, 2026
Review round 4 — addressed ( |
| Attempt | Failed on | Detail |
|---|---|---|
| 1, 2 | scripts/lib/render-smoke.test.mjs | 416ms / 407ms against a 400ms deadline |
| 3 | ServerSettingsModal … (SEP-2350) | flat 5000ms vitest timeout, 7190 others passing |
Both pass in isolation. Neither is in code this PR touches. What did execute was green — full validate:web unit suite on attempt 3 apart from that one timeout, and every new test in this PR passes.
Because validate is an && chain, each abort happened before coverage / smokes / Firefox / Storybook, so those remain unrun locally for this revision. CI is the real check here — it runs on a dedicated runner without the contention. I filed #2180 about the render-smoke margin and have since retracted its premise: I originally claimed it failed on an idle machine, which was wrong, and recommended closing it.
Flagging this rather than quietly re-running until something came up green.
Closes#2171
The decision
A raw-JSON argument the schema would retype is refused, not sent. The two JSON-authoring surfaces now give the same answer, which is what #2171's acceptance asked for — Edit-and-replay already refused, and the Tools/Apps Edit as JSON editor was silently sending the coerced value.
The issue left the direction open and named both options. This is the second one: "detect the coercion and refuse it, the way PR #2170's Edit-and-replay modal already does for the same conversion (
reshapedReplayParam) — and the two paths should then agree."The cost, stated plainly: the Inspector cannot send a deliberately schema-invalid argument from the UI. That was the other half of the issue's repro, and it is what the alternative direction would have bought. Refusing was chosen instead because the editor showing one payload while the wire carries another is the defect being fixed, and a refusal fixes it without inventing a second way for the two editors to disagree.
What changed
callToolconverts every string-valued argument to the type the tool'sinputSchemadeclares, because the widget form hands everything over as text —"2"against a numeric field has to become2. A JSON draft already carries its own types, so a value that conversion would touch is one whose visible text is not what the wire would carry.core/json/jsonUtils.ts—coercedArgumentNames(inputSchema, args)andcoercedArgumentsError(names, toolName?). The check runs the real conversion and diffs, rather than restating its rules, so no surface can drift from what would actually be sent; the message is shared so the same refusal can't be worded two ways.convertToolParametersis now a thin wrapper over a schema-keyedconvertParametersForSchema, since a form holds the schema and the client holds theTool.SchemaForm— the raw-JSON editor refuses such a draft, names the value, and reports invalid through the existingonValidityChangechannel so Execute / Open App disable. The offending object is also not emitted upward, so a submit gated on something else can't send it either.ToolDetailPanel/AppDetailPanel— opt in via the newenforceToolArgumentTypes.protocolReplay.ts— its privatecoercedToolArgscopy is deleted in favour of the shared helper. Replay's behavior is unchanged.Enforcement is opt-in rather than inferred from the schema. An elicitation renders through the same
SchemaFormand its values never go throughtools/call, so enforcing there would refuse a draft for a reason that isn't true of it. Only the two tool-argument panels pass it.Screenshots
An
addtool declaringaandbas numbers, with{"a":"01","b":2}typed into Edit as JSON."01"is the probe because it survivesNumber()as1, so the wire tells a coerced call from a verbatim one.would be converted to the type the schema declares — write the value with that type instead, Execute Tool disabledAnd what the "before" build did on that click — the editor said
"01", the wire carried1, and the call "succeeded" with{"result": 3}for an argument the user never wrote:The Edit-and-replay modal is deliberately absent from these: its behavior does not change, since it is the surface the other two are being brought into line with.
Tests
core(src/test/core/jsonUtils.test.ts) —coercedArgumentNamesagainst a retyped string, a value already of the declared type, a string the schema declares as a string, an undeclared argument, several names at once, and a type declared on a root union branch (Tool schemas with a root-levelanyOf/oneOfrender no input fields #2123), where the branch must be selected from the supplied values exactly as the conversion selects it. Plus bothcoercedArgumentsErrorforms.SchemaForm— refuses and names the value; blocks submit and unblocks when rewritten with the declared type; accepts values already correctly typed; and does not enforce when the caller has not opted in, which is the test that pins the elicitation path.ToolDetailPanel/AppDetailPanel— each panel actually opts in, asserted through the disabled Execute / Open App button rather than the prop.npm run local:gatepasses. Rebased on the latestv2/main(4d69dbf9).