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:
cliffhall 2026-08-27 22:57:29 -04:00
commit 9516299d8d
10 changed files with 435 additions and 27 deletions

View file

@ -298,6 +298,29 @@ Every surface in this client where JSON is **typed**, plus `ContentViewer`'s rea
| `groups/ImportServerJsonPanel`, `groups/ExperimentalFeaturesPanel` | The panel owns the text and validates it itself. |
| `elements/ContentViewer`'s JSON branch | Read-only — see below. |
**A raw-JSON draft is refused when the client would retype it.** `callTool`
converts every *string-valued* argument to the type the tool's `inputSchema`
declares, because the widget form hands everything over as text — so `"2"`
against a numeric field has to become `2`. 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, and showing one payload while sending another is the
one thing an inspector must not do. Both JSON-authoring surfaces therefore
refuse such a draft and name the value to rewrite
([#2171](https://github.com/modelcontextprotocol/inspector/issues/2171)):
**Edit as JSON** in the Tools and Apps tabs (which had been sending it, coerced
and silently), and **Edit and replay**, which already did.
One helper decides it for both — `coercedArgumentNames` in
[`core/json/jsonUtils.ts`](../../core/json/jsonUtils.ts), which runs the real
conversion and compares rather than restating its rules, so a surface cannot
drift from what would actually be sent. `coercedArgumentsError` is the sentence
they share, so the same refusal cannot be worded two ways.
The check is **opt-in** (`SchemaFormProps.enforceToolArgumentTypes`), not
inferred from the schema: an elicitation renders through the same `SchemaForm`
and its values are never converted, so enforcing there would refuse a draft for
a reason that is not true of it. Only the Tools and Apps panels pass it.
**Read-only mode is what `ContentViewer` renders JSON as**, and through it every payload that reaches the app that way: Protocol and Network entries, tool results, structured output, resource previews, server cards. Highlighting is *not* what that buys — JSON already highlighted, via the lazily-imported Prism grammar `CodeHighlight` loads. What Ace adds is **folding**, line numbers and a gutter on a large payload. The Prism `json` grammar was dropped in the same change rather than kept beside it: two highlighters for one language drift, and nothing else asks for `json`. Two cases stay on the plain renderer — a `wrap={false}` caller (the server card's fixed-height, single-line box) and untyped text that only *looks* like JSON but does not parse, which in an editor would frame a server's prose as a malformed document.
Two read-only JSON displays deliberately do **not** go through `ContentViewer`, and so are not on this editor — worth knowing before assuming it is the route for all of them. `ExperimentalFeaturesPanel` renders its JSON-RPC **response** in a read-only `Textarea` (its *request* box is on the editor), and `ConnectionInfoContent/OAuthTokenField` renders a decoded JWT in a `Code` block beside the raw token, where the decoded/raw toggle and the copy affordance belong to the field rather than to a viewer. Both are candidates for the same treatment; neither is in [#2151](https://github.com/modelcontextprotocol/inspector/issues/2151)'s scope.

View file

@ -136,6 +136,29 @@ describe("AppDetailPanel", () => {
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
});
// #2171: an App's arguments are a `tools/call` like any other, so its form
// opts into the same enforcement the Tools tab uses.
it("refuses a raw-JSON argument the schema would retype", async () => {
const user = userEvent.setup();
const numericTool: Tool = {
name: "chart",
inputSchema: {
type: "object",
properties: { count: { type: "number" } },
},
};
renderWithMantine(
<AppDetailPanel {...baseProps} tool={numericTool} formValues={{}} />,
);
await user.click(screen.getByLabelText("Edit as JSON"));
await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}');
expect(
screen.getByText(/`count` would be converted to the type/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open app/i })).toBeDisabled();
});
it("invokes onOpenApp when the button is clicked", async () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();

View file

@ -89,6 +89,10 @@ export function AppDetailPanel({
// SchemaFormProps.resetKey.
resetKey={tool.name}
onValidityChange={setHasInvalidDraft}
// These values become `tools/call` arguments, so a raw-JSON draft the
// client would retype is refused rather than sent as something other
// than what the editor shows (#2171).
enforceToolArgumentTypes
/>
<OpenAppButton

View file

@ -1834,11 +1834,13 @@ describe("SchemaForm raw JSON (#2151)", () => {
function RawHarness({
initial = {},
onValidityChange,
enforceToolArgumentTypes,
resetKey,
schema: override,
}: {
initial?: Record<string, unknown>;
onValidityChange?: (hasInvalidDraft: boolean) => void;
enforceToolArgumentTypes?: boolean;
resetKey?: string;
schema?: InspectorFormSchema;
}) {
@ -1850,6 +1852,7 @@ describe("SchemaForm raw JSON (#2151)", () => {
onChange={setValues}
resetKey={resetKey}
onValidityChange={onValidityChange}
enforceToolArgumentTypes={enforceToolArgumentTypes}
/>
);
}
@ -1858,6 +1861,87 @@ describe("SchemaForm raw JSON (#2151)", () => {
await user.click(screen.getByLabelText("Edit as JSON"));
}
// #2171. The widgets hand every value over as text, so `callTool` retypes a
// string to whatever the schema declares — which for a JSON draft means the
// wire would carry something other than what the editor showed. The draft is
// refused instead, and the value to rewrite is named.
describe("tool-argument type enforcement", () => {
it("refuses a draft the client would retype, and says which value", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm
schema={schema}
values={{}}
onChange={onChange}
enforceToolArgumentTypes
/>,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}');
expect(
screen.getByText(/`count` would be converted to the type/),
).toBeInTheDocument();
// Not merely flagged: the value must not reach the parent either, or a
// submit gated on something else would still send it.
expect(onChange).not.toHaveBeenCalledWith({ count: "01" });
});
it("blocks submission while such a draft is held", async () => {
const user = userEvent.setup();
const onValidityChange = vi.fn();
renderWithMantine(
<RawHarness
onValidityChange={onValidityChange}
enforceToolArgumentTypes
/>,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}');
expect(onValidityChange).toHaveBeenLastCalledWith(true);
// Rewritten with the declared type, it is sendable again.
await setAceTextByLabel(/Arguments JSON/, '{"count":1}');
expect(onValidityChange).toHaveBeenLastCalledWith(false);
});
// The conversion only ever looks at strings, so a value already written
// with its declared type is not a mismatch — and neither is a string in a
// field the schema declares as one.
it("accepts values already written with the declared type", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm
schema={schema}
values={{}}
onChange={onChange}
enforceToolArgumentTypes
/>,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"name":"a","count":2}');
expect(screen.queryByText(/would be converted/)).toBeNull();
expect(onChange).toHaveBeenLastCalledWith({ name: "a", count: 2 });
});
// Off by default, and that default is load-bearing: an elicitation renders
// through this same form and its values are never converted, so enforcing
// there would refuse a draft for a reason that is not true of it.
it("does not enforce when the caller has not opted in", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
renderWithMantine(
<SchemaForm schema={schema} values={{}} onChange={onChange} />,
);
await enableRawJson(user);
await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}');
expect(screen.queryByText(/would be converted/)).toBeNull();
expect(onChange).toHaveBeenLastCalledWith({ count: "01" });
});
});
it("replaces the widgets with one editor holding the current values", async () => {
const user = userEvent.setup();
renderWithMantine(<RawHarness initial={{ name: "a", count: 2 }} />);

View file

@ -33,6 +33,10 @@ import {
} from "../../../utils/jsonObjectDraft";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
import { useValueChange } from "../../../hooks/useValueChange";
import {
coercedArgumentNames,
coercedArgumentsError,
} from "@inspector/core/json/jsonUtils.js";
import type {
InspectorFormSchema,
JsonSchemaConst,
@ -626,6 +630,7 @@ function SchemaNumberInput({
*/
function parseRawArgumentsDraft(
text: string,
coercionSchema?: InspectorFormSchema,
): { ok: true; value: Record<string, unknown> } | { ok: false; error: string } {
if (text.trim() === "") return { ok: true, value: {} };
let parsed: unknown;
@ -661,6 +666,21 @@ function parseRawArgumentsDraft(
if (duplicate !== null) {
return { ok: false, error: duplicateKeyError(duplicate) };
}
// Last, because it is the only check that needs a *valid* object to run
// against — and the only one that is about the schema rather than the JSON.
// `callTool` retypes every string-valued argument to what the schema
// declares, so a draft it would touch is one whose visible text is not what
// the wire would carry; refuse it here instead, and say which value to
// rewrite (#2171). Skipped entirely when no schema is supplied: only the
// Tools and Apps forms feed `tools/call`, and an elicitation's values are
// never converted, so imposing this there would block a submission for a
// reason that is not true of it.
if (coercionSchema) {
const coerced = coercedArgumentNames(coercionSchema, parsed);
if (coerced.length > 0) {
return { ok: false, error: coercedArgumentsError(coerced) };
}
}
return { ok: true, value: parsed };
}
@ -670,6 +690,12 @@ interface RawArgumentsFieldProps {
disabled: boolean;
/** Mirrors {@link SchemaFormProps.onValidityChange} for this one editor. */
onInvalidChange: (hasInvalidDraft: boolean) => void;
/**
* The tool `inputSchema` this draft's values will be sent against, when they
* will be — see {@link SchemaFormProps.enforceToolArgumentTypes}. Undefined
* turns the type check off.
*/
coercionSchema?: InspectorFormSchema;
}
/**
@ -697,6 +723,7 @@ function RawArgumentsField({
onChange,
disabled,
onInvalidChange,
coercionSchema,
}: RawArgumentsFieldProps) {
const [draft, setDraft] = useState(() => serializeJson(values));
// Canonical JSON of the last object this editor emitted, so the re-sync below
@ -705,7 +732,7 @@ function RawArgumentsField({
// change would then look external. See `JsonObjectInput` for the long form.
const [echoed, setEchoed] = useState(() => serializeJson(values));
const parsed = parseRawArgumentsDraft(draft);
const parsed = parseRawArgumentsDraft(draft, coercionSchema);
useValueChange(serializeJson(values), (next) => {
if (next === echoed) return;
@ -732,7 +759,7 @@ function RawArgumentsField({
maxLines={24}
onChange={(text) => {
setDraft(text);
const next = parseRawArgumentsDraft(text);
const next = parseRawArgumentsDraft(text, coercionSchema);
if (!next.ok) return;
setEchoed(serializeJson(next.value));
onChange(next.value);
@ -790,6 +817,30 @@ export interface SchemaFormProps {
* inside the first the moment both were on.
*/
allowRawJson?: boolean;
/**
* Whether these values become `tools/call` arguments, and so are subject to
* the string-to-declared-type conversion `InspectorClient.callTool` applies
* (#2171).
*
* That conversion exists for the widgets, which hand every value over as
* text: `"2"` against a numeric field has to become `2`. A **raw-JSON**
* draft already carries its own types, so a value the conversion would touch
* is one whose visible text is not what the wire would carry — and showing
* one payload while sending another is the one thing an inspector must not
* do. With this on, the raw editor refuses such a draft and names the value
* to rewrite, exactly as the Edit-and-replay modal does for the same
* conversion.
*
* Off by default, and deliberately opt-in rather than inferred from the
* schema: an elicitation renders through this same form and its values are
* never converted, so the check would be refusing those drafts for a reason
* that is not true of them. Only the Tools and Apps panels pass it.
*
* Read against {@link SchemaFormProps.schema} — the tool's own `inputSchema`,
* root composition included, which is what the client resolves the
* conversion against too.
*/
enforceToolArgumentTypes?: boolean;
}
function getDefaultValue(fieldSchema: InspectorFormSchema): unknown {
@ -817,6 +868,7 @@ export function SchemaForm({
resetKey,
onValidityChange,
allowRawJson = true,
enforceToolArgumentTypes = false,
}: SchemaFormProps) {
// Composition at the root of the schema, flattened before anything is
// rendered (#2123). `allOf` is folded into `base`; a top-level `oneOf`/`anyOf`
@ -1524,6 +1576,11 @@ export function SchemaForm({
onChange={handleRawArgumentsChange}
disabled={disabled}
onInvalidChange={reportRawJsonValidity}
// The schema as the CLIENT resolves it — the whole `inputSchema`,
// root composition and all — not the branch this form happens to be
// rendering. `coercedArgumentNames` does its own branch selection
// from the supplied values, the same way the conversion does.
coercionSchema={enforceToolArgumentTypes ? schema : undefined}
/>
) : (
Object.entries(properties).map(([fieldName, fieldSchema]) =>

View file

@ -246,6 +246,30 @@ describe("ToolDetailPanel", () => {
expect(onExecute).toHaveBeenCalledTimes(1);
});
// #2171: the panel opts its form into tool-argument type enforcement, so a
// raw-JSON draft the client would retype is refused here rather than sent as
// something other than what the editor showed.
it("refuses a raw-JSON argument the schema would retype", async () => {
const user = userEvent.setup();
const numericTool: Tool = {
name: "add",
inputSchema: {
type: "object",
properties: { count: { type: "number" } },
},
};
renderWithMantine(
<ToolDetailPanel {...baseProps} tool={numericTool} formValues={{}} />,
);
await user.click(screen.getByLabelText("Edit as JSON"));
await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}');
expect(
screen.getByText(/`count` would be converted to the type/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Execute Tool" })).toBeDisabled();
});
it("disables the Execute Tool button while executing and renders Cancel", () => {
renderWithMantine(
<ToolDetailPanel {...baseProps} tool={simpleTool} isExecuting={true} />,

View file

@ -355,6 +355,10 @@ export function ToolDetailPanel({
// tool's in-progress field text. See SchemaFormProps.resetKey.
resetKey={resetKey ?? name}
onValidityChange={setHasInvalidDraft}
// These values become `tools/call` arguments, so a raw-JSON draft the
// client would retype is refused rather than sent as something other
// than what the editor shows (#2171).
enforceToolArgumentTypes
/>
{progress && <ProgressDisplay params={progress} />}

View file

@ -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,

View file

@ -3,6 +3,8 @@ import {
convertParameterValue,
convertToolParameters,
convertPromptArguments,
coercedArgumentNames,
coercedArgumentsError,
toRecord,
} from "@inspector/core/json/jsonUtils.js";
import type { Tool } from "@modelcontextprotocol/client";
@ -61,6 +63,122 @@ describe("JSON Utils", () => {
});
});
// #2171: the predicate both raw-JSON editors refuse on. It must agree with
// the conversion exactly — a false negative sends a payload other than the
// one shown, and a false positive blocks a draft that was fine.
describe("coercedArgumentNames", () => {
const numeric: Tool = {
name: "add",
inputSchema: {
type: "object",
properties: { count: { type: "number" }, label: { type: "string" } },
},
};
it("names a string the schema would retype", () => {
expect(
coercedArgumentNames(numeric.inputSchema, { count: "01" }),
).toEqual(["count"]);
});
// The conversion only inspects strings, so a value already written with
// its declared type is untouched and must not be reported.
it("ignores a value already of the declared type", () => {
expect(coercedArgumentNames(numeric.inputSchema, { count: 1 })).toEqual(
[],
);
});
it("ignores a string the schema declares as a string", () => {
expect(
coercedArgumentNames(numeric.inputSchema, { label: "01" }),
).toEqual([]);
});
// An argument the schema does not declare is passed through by the
// conversion, so there is nothing to warn about.
it("ignores an undeclared argument", () => {
expect(coercedArgumentNames(numeric.inputSchema, { extra: "x" })).toEqual(
[],
);
});
it("reports every offending name, in supplied order", () => {
const two: Tool = {
name: "pair",
inputSchema: {
type: "object",
properties: { a: { type: "number" }, b: { type: "boolean" } },
},
};
expect(
coercedArgumentNames(two.inputSchema, { a: "1", b: "true" }),
).toEqual(["a", "b"]);
});
// Root composition (#2123): the declared type can live on a branch, and
// the conversion selects that branch from the supplied values — so this
// must too, or a branch's arguments would look unconvertible.
it("resolves a type declared on a root union branch", () => {
const union: Tool = {
name: "u",
inputSchema: {
type: "object",
oneOf: [
{
type: "object",
properties: {
kind: { const: "n" },
value: { type: "number" },
},
required: ["kind"],
},
{
type: "object",
properties: {
kind: { const: "s" },
value: { type: "string" },
},
required: ["kind"],
},
],
},
};
expect(
coercedArgumentNames(union.inputSchema, { kind: "n", value: "2" }),
).toEqual(["value"]);
expect(
coercedArgumentNames(union.inputSchema, { kind: "s", value: "2" }),
).toEqual([]);
});
it("says nothing when no argument is a string", () => {
expect(coercedArgumentNames(numeric.inputSchema, { count: 1 })).toEqual(
[],
);
});
});
describe("coercedArgumentsError", () => {
// Both surfaces refuse the same drafts, so they must not word it two ways;
// the tool name is included only where the caller knows it.
it("names the tool when it is known", () => {
expect(coercedArgumentsError(["count"], "add")).toBe(
"`count` would be converted to the type add's schema declares — write the value with that type instead",
);
});
it("omits the tool when the caller is already rendering one", () => {
expect(coercedArgumentsError(["count"])).toBe(
"`count` would be converted to the type the schema declares — write the value with that type instead",
);
});
it("lists several names", () => {
expect(coercedArgumentsError(["a", "b"], "pair")).toContain("`a`, `b`");
});
});
describe("convertToolParameters", () => {
const tool: Tool = {
name: "test-tool",

View file

@ -294,12 +294,32 @@ function suppliedMatchesConst(value: string, constValue: unknown): boolean {
export function convertToolParameters(
tool: Tool,
params: Record<string, string>,
): Record<string, JsonValue> {
return convertParametersForSchema(tool.inputSchema, params);
}
/**
* {@link convertToolParameters} against a bare `inputSchema` rather than a
* `Tool`.
*
* Split out because the two callers hold different things: the client has the
* `Tool`, while a form holds only the schema it is rendering (which is that
* same object, structurally narrowed). Both must reach the SAME conversion —
* one of them decides what goes on the wire and the other decides whether to
* let the user send it, so a second implementation would let them disagree
* about which values are convertible.
*/
export function convertParametersForSchema(
inputSchema: unknown,
params: Record<string, string>,
): Record<string, JsonValue> {
const result: Record<string, JsonValue> = {};
// A property's schema can live on a root composition branch rather than on
// the root itself (#2123); see `coercionProperties` for how the branch is
// identified when it does.
const { base, branches } = resolveRootUnion(tool.inputSchema ?? {});
const { base, branches } = resolveRootUnion(
(inputSchema ?? {}) as RootUnionSchema,
);
const properties = coercionProperties(base, branches, params);
for (const [key, value] of Object.entries(params)) {
const declared = properties[key];
@ -338,6 +358,62 @@ export function convertToolParameters(
return result;
}
/**
* The argument names a `tools/call` would silently retype, per the tool's
* schema — empty when every value is already the type the schema declares.
*
* Runs the conversion the client runs and compares, rather than restating its
* rules: {@link convertParametersForSchema} is the function on the other side,
* so asking it is the only way a caller cannot drift from what is actually
* sent.
*
* Only string-valued arguments can be retyped, because that is all the
* conversion looks at — a JSON draft that already writes `2` as a number is
* passed through untouched, and is not reported here.
*
* Used by the two places a payload is authored as JSON rather than through
* widgets — the Tools/Apps form's "Edit as JSON" switch and the
* Edit-and-replay modal — each of which refuses a draft this returns anything
* for, so what the editor shows is what the wire carries (#2171).
*/
export function coercedArgumentNames(
inputSchema: unknown,
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 = convertParametersForSchema(inputSchema, stringArgs);
// `Object.keys` of the *supplied* names, so an argument the schema does not
// declare (which the conversion passes through) cannot be reported.
return Object.keys(stringArgs).filter(
(key) => converted[key] !== stringArgs[key],
);
}
/**
* The refusal {@link coercedArgumentNames} justifies, as one sentence.
*
* Shared for the same reason the check is: the Tools/Apps raw-JSON editor and
* the Edit-and-replay modal refuse the same drafts, so they must not word it
* two different ways. `toolName` is included when the caller knows which tool
* the draft targets — the replay modal does, because its `name` is editable
* and the schema is looked up from it; a form is already rendering one tool
* and would only be repeating itself.
*/
export function coercedArgumentsError(
names: string[],
toolName?: string,
): string {
const quoted = names.map((name) => `\`${name}\``).join(", ");
const whose = toolName
? `the type ${toolName}'s schema declares`
: "the type the schema declares";
return `${quoted} would be converted to ${whose} — write the value with that type instead`;
}
/**
* Convert prompt arguments (JsonValue) to strings for prompt API
*/