fix: address Copilot review round 34 — structured constants, non-required const controls

- A `const` may be an object or an array, with or without a `type`, and
  `String({...})` is "[object Object]" — which no CLI argument can equal, so
  the only value such a schema accepts never matched. Supplied text is parsed
  and compared structurally through the resolver's own comparison (exported as
  `sameJsonValue` so the two cannot disagree), for both branch selection and
  the substitution.
- The TUI's one-option const control is never marked required: its option may
  legitimately be the empty string, which ink-form's required gate can never
  accept, so submission would not even reach `decodeFormValues`. The value is
  fixed by the schema, and `missingRequiredFields` still validates the call.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 13:39:16 -04:00
commit ba91db97e2
2 changed files with 79 additions and 3 deletions

View file

@ -2,6 +2,7 @@ import type { Tool } from "@modelcontextprotocol/client";
import { normalizeNullableUnion } from "./nullableUnion.js";
import {
narrowBySuppliedNames,
sameJsonValue,
resolveRootUnion,
type RootUnionBranch,
type RootUnionSchema,
@ -253,10 +254,29 @@ function matchesConstants(
// read the inherited one and rule out every branch that pins that name.
if (!Object.hasOwn(params, name)) return true;
const supplied = params[name];
return supplied === undefined || supplied === String(constValue);
return supplied === undefined || suppliedMatchesConst(supplied, constValue);
});
}
/**
* Whether the text a CLI argument carries is the value a `const` fixes.
*
* A primitive constant is compared as text, which is all a command line has. A
* structured one — a `const` may be an object or an array, with or without a
* `type` — is parsed first: `String({...})` is `"[object Object]"`, which no
* argument can equal, so the only value the schema accepts would never match.
*/
function suppliedMatchesConst(value: string, constValue: unknown): boolean {
if (constValue === null || typeof constValue !== "object") {
return value === String(constValue);
}
try {
return sameJsonValue(JSON.parse(value), constValue);
} catch {
return false;
}
}
/**
* Convert string parameters to JSON values based on tool schema
*/
@ -288,7 +308,7 @@ export function convertToolParameters(
// user's input and is left alone.
const pinned = (paramSchema as { const?: unknown } | undefined)?.const;
const converted =
pinned !== undefined && String(pinned) === value
pinned !== undefined && suppliedMatchesConst(value, pinned)
? (pinned as JsonValue)
: paramSchema
? convertParameterValue(value, paramSchema)

View file

@ -240,7 +240,17 @@ function canonicalJson(value: unknown): string {
return JSON.stringify(value) ?? "undefined";
}
/** Structural equality, via canonical JSON — enough for schema keyword values. */
/**
* Structural equality, via canonical JSON — enough for schema keyword values.
*
* Exported because a caller comparing a *supplied* value against a schema's
* `const` has to reach the same answer this module does; two implementations
* would disagree the moment one of them met an object.
*/
export function sameJsonValue(a: unknown, b: unknown): boolean {
return sameValue(a, b);
}
function sameValue(a: unknown, b: unknown): boolean {
return a === b || canonicalJson(a) === canonicalJson(b);
}