@cyanheads/mcp-ts-core 0.13.13 → 0.13.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/AGENTS.md +4 -4
  2. package/CLAUDE.md +4 -4
  3. package/README.md +1 -1
  4. package/changelog/0.13.x/0.13.14.md +68 -0
  5. package/dist/core/app.d.ts +4 -3
  6. package/dist/core/app.d.ts.map +1 -1
  7. package/dist/core/app.js.map +1 -1
  8. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  9. package/dist/mcp-server/prompts/prompt-registration.js +12 -5
  10. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  11. package/dist/mcp-server/prompts/utils/promptDefinition.d.ts +4 -1
  12. package/dist/mcp-server/prompts/utils/promptDefinition.d.ts.map +1 -1
  13. package/dist/mcp-server/prompts/utils/promptDefinition.js.map +1 -1
  14. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +309 -26
  15. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  16. package/dist/mcp-server/tools/utils/inputPrevalidation.js +870 -106
  17. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  18. package/dist/mcp-server/tools/utils/schemaShape.d.ts +127 -5
  19. package/dist/mcp-server/tools/utils/schemaShape.d.ts.map +1 -1
  20. package/dist/mcp-server/tools/utils/schemaShape.js +432 -4
  21. package/dist/mcp-server/tools/utils/schemaShape.js.map +1 -1
  22. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +7 -3
  23. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  24. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  25. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +47 -23
  26. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  27. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +777 -236
  28. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  29. package/dist/utils/telemetry/attributes.d.ts +3 -2
  30. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  31. package/dist/utils/telemetry/attributes.js +3 -2
  32. package/dist/utils/telemetry/attributes.js.map +1 -1
  33. package/framework-skills/add-tool/SKILL.md +15 -7
  34. package/framework-skills/api-errors/SKILL.md +8 -8
  35. package/framework-skills/api-telemetry/SKILL.md +4 -4
  36. package/framework-skills/api-testing/SKILL.md +2 -2
  37. package/framework-skills/design-mcp-server/SKILL.md +3 -2
  38. package/framework-skills/field-test/SKILL.md +3 -2
  39. package/package.json +3 -3
@@ -19,8 +19,8 @@ import { measureToolExecution, recordToolRejection } from '../../../utils/intern
19
19
  import { requestContextService, withExtra, } from '../../../utils/internal/requestContext.js';
20
20
  import { sanitization } from '../../../utils/security/sanitization.js';
21
21
  import { ATTR_MCP_TOOL_ENRICHED } from '../../../utils/telemetry/attributes.js';
22
- import { countCoerced, prevalidateAliasFirst, prevalidateToolArguments, recordPrevalidation, repairRepresentations, } from './inputPrevalidation.js';
23
- import { isZodObjectSchema, zodDef } from './schemaShape.js';
22
+ import { applyRepairs, countCoerced, heldRepairs, prevalidateAliasFirst, prevalidateToolArguments, recordPrevalidation, repairAsSent, repairRepresentations, sameValue, } from './inputPrevalidation.js';
23
+ import { argumentAt, isZodObjectSchema, objectSchemaAt, } from './schemaShape.js';
24
24
  // ---------------------------------------------------------------------------
25
25
  // Default formatter
26
26
  // ---------------------------------------------------------------------------
@@ -74,7 +74,7 @@ function extractRecoveryHint(data) {
74
74
  * `data` outside a request, as `runToolContract` builds it — leaving the text
75
75
  * as it was. The numeric `code` and `data.issues` stay JSON-only on purpose:
76
76
  * the code is the one envelope field a model cannot act on, and the message
77
- * already renders each issue as a sentence.
77
+ * already renders the issues as sentences.
78
78
  */
79
79
  function renderBranchableTerms(data) {
80
80
  const terms = [];
@@ -136,42 +136,92 @@ export function buildToolErrorResult(code, message, data) {
136
136
  }
137
137
  /** The framework-owned `data.reason` on every argument rejection (#445). */
138
138
  const INVALID_ARGUMENTS_REASON = 'invalid_arguments';
139
- /** What {@link readArgumentAt} returns when the raw arguments carry no value there. */
140
- const ABSENT = Symbol('absent');
141
139
  /**
142
- * The value the raw arguments carry at `path`, or {@link ABSENT}.
140
+ * What a rejected call's arguments hold at `path`, as the schema there
141
+ * received them.
143
142
  *
144
- * The one resolver behind #378's missing-vs-wrong rendering and #445's
145
- * missing-required hint, which ask the same question of the same arguments.
146
- * Zod's `invalid_value` issue names an expected set and nothing else, so an
147
- * omitted field and a wrong choice are otherwise indistinguishable. Resolving
148
- * it here keeps the caller's value in-process: only the absent/present bit and
149
- * the arriving *type* reach a rendered sentence, unlike Zod's `reportInput`
150
- * option, which would copy every rejected value onto `data.issues`.
143
+ * The one resolver behind #378's missing-vs-wrong rendering, #445's
144
+ * missing-required hint, and the wrong-type sentence, which ask the same
145
+ * question of the same arguments. Zod's `invalid_value` issue names an expected
146
+ * set and nothing else, so an omitted field and a wrong choice are otherwise
147
+ * indistinguishable. Resolving it here keeps the caller's value in-process:
148
+ * only the absent/present bit and the arriving *type* reach a rendered
149
+ * sentence, unlike Zod's `reportInput` option, which would copy every rejected
150
+ * value onto `data.issues`.
151
151
  *
152
- * A key present with an explicit `null` is present — the caller supplied a
153
- * value, it was the wrong one. A key present with `undefined` is absent, which
154
- * is how Zod itself reads it.
152
+ * Below a `z.preprocess()` or a `.transform().pipe()`, an issue path names the
153
+ * transform's output — `items.0` for a lone object the transform wrapped — so
154
+ * the path is walked through the schema, with the transform re-applied
155
+ * in-process ({@link argumentAt}), rather than read off the arguments as sent.
155
156
  */
156
- function readArgumentAt(args, path) {
157
- const value = path.reduce(stepInto, args);
158
- return value === undefined ? ABSENT : value;
157
+ function argumentOf(rejected, path) {
158
+ return argumentAt(rejected.input, path, rejected.args, rejected.transforms);
159
159
  }
160
- /** The caller's value one step down, or `undefined` when nothing owns one there. */
161
- function stepInto(value, step) {
162
- return value !== null && typeof value === 'object' && Object.hasOwn(value, step)
163
- ? value[step]
164
- : undefined;
160
+ /**
161
+ * Whether the schema at a path received no value because the caller sent
162
+ * none: it left the value out, or sent `null` or a blank string that a
163
+ * transform there made `undefined` of (a blank a preprocess maps to unset). A
164
+ * transform that makes `undefined` of anything else — a name its lookup does
165
+ * not know — rejected a value the caller did send, so it is not absent. A key
166
+ * present with an explicit `null` and no transform is present — the caller
167
+ * supplied a value, it was the wrong one. A key present with `undefined` is
168
+ * absent, which is how Zod itself reads it. A path a re-applied transform threw
169
+ * on is never absent: what it holds is unknown.
170
+ */
171
+ function isAbsent(at) {
172
+ if (!at.known || at.received !== undefined)
173
+ return false;
174
+ const { sent } = at;
175
+ return sent === undefined || sent === null || (typeof sent === 'string' && sent.trim() === '');
165
176
  }
166
- /** The accepted-value half of an `invalid_value` sentence, from the issue's own values. */
167
- function expectedValuesText(values) {
177
+ /** `"a"`, or `one of "a"|"b"` — the values an `invalid_value` sentence names. */
178
+ function acceptedValuesText(values) {
168
179
  const rendered = values.map((value) => JSON.stringify(value)).join('|');
169
- return values.length === 1 ? `Expected ${rendered}` : `Expected one of ${rendered}`;
180
+ return values.length === 1 ? rendered : `one of ${rendered}`;
170
181
  }
171
182
  /** The branch's only issue, when it has exactly one. */
172
183
  function onlyIssue(branch) {
173
184
  return branch.length === 1 ? branch[0] : undefined;
174
185
  }
186
+ /**
187
+ * The branch's only issue when it is a single-valued `invalid_value`: the
188
+ * branch failed on one literal (#417).
189
+ */
190
+ function oneLiteral(branch) {
191
+ const only = onlyIssue(branch);
192
+ return only?.code === 'invalid_value' && only.values.length === 1 ? only : undefined;
193
+ }
194
+ /**
195
+ * The one issue a union renders as when every branch failed on a single
196
+ * literal at the same path — the tag of literal-tagged object branches, or a
197
+ * union of bare literals: that literal's `invalid_value` at that path, naming
198
+ * every branch's value as Zod names an enum's (`Invalid option: expected one
199
+ * of "a"|"b"`), the way a `z.discriminatedUnion()` names every discriminator.
200
+ * Its `path` is the shared branch-relative one. `undefined` for any other
201
+ * union, one whose literals sit at different paths included.
202
+ */
203
+ function literalTagIssue(issue) {
204
+ // A discriminated union's unmatched tag carries no branches; its own message names the values.
205
+ if (issue.code !== 'invalid_union' || issue.errors.length === 0)
206
+ return;
207
+ const literals = issue.errors.map(oneLiteral);
208
+ const path = literals[0]?.path ?? [];
209
+ const at = JSON.stringify(path);
210
+ const values = [];
211
+ for (const literal of literals) {
212
+ if (!literal || JSON.stringify(literal.path) !== at)
213
+ return;
214
+ if (!values.includes(literal.values[0]))
215
+ values.push(literal.values[0]);
216
+ }
217
+ const kind = values.length === 1 ? 'Invalid input' : 'Invalid option';
218
+ return {
219
+ code: 'invalid_value',
220
+ message: `${kind}: expected ${acceptedValuesText(values)}`,
221
+ path,
222
+ values,
223
+ };
224
+ }
175
225
  /** Whether any of a branch's issues names a path below the branch's root. */
176
226
  function failsBelowRoot(branch) {
177
227
  return branch.some((issue) => issue.path.length > 0);
@@ -184,18 +234,28 @@ function failsBelowRoot(branch) {
184
234
  * `invalid_value`. That shape is the `z.literal('')` blank-field sentinel of
185
235
  * the form-client convention — never the branch that says what would have
186
236
  * been accepted. A one-entry `z.enum([...])`, which Zod reports identically,
187
- * is filtered too, and the caller falls back to the union's own message.
237
+ * is filtered too. When that leaves nothing — every branch failed on one
238
+ * literal, as literal-tagged branches all do on an unknown tag — the union
239
+ * renders every value instead ({@link literalTagIssue}). The filter's
240
+ * premise is a value the caller sent, the other branch's tag: so when it
241
+ * leaves no branch failing below its root, a branch whose one literal sits
242
+ * below its root at a path `leftOut` says the caller sent nothing at is kept
243
+ * — `{ q: 5 }` with `format` omitted, beside a list branch that only says
244
+ * the value is not a list.
188
245
  * - **#492** — once some branch fails below its root, drop every branch whose
189
246
  * only issue is a root `invalid_type`. In a one-or-many field
190
247
  * (`z.union([z.array(Item), Item])`) that branch merely says the value is
191
248
  * the other shape; the branch that failed inside the value is the one that
192
249
  * says what to change. When every branch fails at its root, none is dropped.
193
250
  */
194
- function selectUnionBranches(branches) {
195
- const selected = branches.filter((branch) => {
196
- const only = onlyIssue(branch);
197
- return !(only?.code === 'invalid_value' && only.values.length === 1);
198
- });
251
+ function selectUnionBranches(branches, leftOut) {
252
+ let selected = branches.filter((branch) => !oneLiteral(branch));
253
+ if (leftOut && !selected.some(failsBelowRoot)) {
254
+ selected = branches.filter((branch) => {
255
+ const literal = oneLiteral(branch);
256
+ return !literal || (literal.path.length > 0 && leftOut(literal.path));
257
+ });
258
+ }
199
259
  if (!selected.some(failsBelowRoot))
200
260
  return selected;
201
261
  return selected.filter((branch) => {
@@ -204,42 +264,355 @@ function selectUnionBranches(branches) {
204
264
  });
205
265
  }
206
266
  /**
207
- * The issues a rejection renders, in order: Zod's list, except that a union
208
- * left with one selected branch that fails below its root is replaced by that
209
- * branch's issues under the union's path (#492) — so a one-or-many field
210
- * reports a list element's field error exactly as a list-only field does
211
- * (`items.1.name: …`). Recursive, so a one-or-many union nested in another, or
212
- * inside a list element, resolves the same way at every level.
267
+ * The issues a rejection renders, in order, each at the full path it renders
268
+ * under: Zod's list, except that a union left with one selected branch that
269
+ * fails below its root is replaced by that branch's issues under the union's
270
+ * path (#492) — so a one-or-many field reports a list element's field error
271
+ * exactly as a list-only field does (`items.1.name: …`). Recursive, so a
272
+ * one-or-many union nested in another, or inside a list element, resolves the
273
+ * same way at every level.
213
274
  *
214
275
  * The message ({@link formatInputValidationMessage}) and the hint
215
- * ({@link buildArgumentRecoveryHint}) both render from this list, which keeps
216
- * the hint's restatements identical to the message's lines. `data.issues` is
217
- * never rebuilt from it: it ships Zod's own list.
276
+ * ({@link buildArgumentRecoveryHint}) both render from this list, through
277
+ * {@link issueLines}, which keeps the hint's restatements identical to the
278
+ * message's lines, and the repair reads it too (#570), so a value is repaired
279
+ * exactly where the hint would name it — the tag {@link issueLines} names for a
280
+ * union every branch of which failed on it included, which the repair reads
281
+ * below that union's entry (#714). A lifted entry
282
+ * carries the one-literal issues dropped branches raised at its own path as
283
+ * `rivals`, which only the repair reads. `data.issues` is never rebuilt from
284
+ * it: it carries Zod's own list, bounded (#648).
285
+ *
286
+ * `leftOut`, given only by {@link issueLines}, reads the caller's arguments
287
+ * at a full path, for the branch {@link selectUnionBranches} keeps on a
288
+ * literal left out. The repair goes without it, so which branch it lifts —
289
+ * and so which calls validate — never rests on it; such a branch holds
290
+ * nothing to repair.
218
291
  */
219
- function renderedIssues(issues, prefix = []) {
292
+ function renderedIssues(issues, prefix = [], leftOut) {
220
293
  return issues.flatMap((issue) => {
221
294
  const path = [...prefix, ...issue.path];
222
295
  if (issue.code === 'invalid_union') {
223
- const [branch, ...others] = selectUnionBranches(issue.errors);
296
+ const [branch, ...others] = selectUnionBranches(issue.errors, leftOut && ((at) => leftOut([...path, ...at])));
224
297
  if (branch && others.length === 0 && failsBelowRoot(branch)) {
225
- return renderedIssues(branch, path);
298
+ return withRivals(renderedIssues(branch, path, leftOut), issue.errors, path);
226
299
  }
227
300
  }
228
301
  return [{ issue, path }];
229
302
  });
230
303
  }
304
+ /**
305
+ * `entries`, each given the one-literal issue any other branch of the union at
306
+ * `path` raised at the entry's own path — a branch {@link selectUnionBranches}
307
+ * dropped under #417 (`v: 5` beside the lifted branch's `v: string`). That
308
+ * branch still takes the literal's type there, so the repair reads the value
309
+ * as the plain union field `z.union([z.literal(5), z.string()])` would.
310
+ *
311
+ * Every entry sits below `path`, so each is matched on its path below it, and
312
+ * only when that is as long as some literal's: a union lifted at every level
313
+ * of a recursive schema then reads each entry's path once in all rather than
314
+ * once per level (#648).
315
+ */
316
+ function withRivals(entries, branches, path) {
317
+ const rivals = new Map();
318
+ const depths = new Set();
319
+ for (const branch of branches) {
320
+ const literal = oneLiteral(branch);
321
+ if (!literal || literal.path.length === 0)
322
+ continue;
323
+ const at = JSON.stringify(literal.path);
324
+ rivals.set(at, [...(rivals.get(at) ?? []), literal]);
325
+ depths.add(literal.path.length);
326
+ }
327
+ if (rivals.size === 0)
328
+ return entries;
329
+ return entries.map((entry) => {
330
+ if (!depths.has(entry.path.length - path.length))
331
+ return entry;
332
+ const found = rivals.get(JSON.stringify(entry.path.slice(path.length)));
333
+ return found ? { ...entry, rivals: [...(entry.rivals ?? []), ...found] } : entry;
334
+ });
335
+ }
336
+ /**
337
+ * The lines the message and the hint render: {@link renderedIssues}, except
338
+ * that a union every branch of which failed on one literal at one path renders
339
+ * as that literal's issue at its full path, naming every branch's value
340
+ * ({@link literalTagIssue}) — `target.kind: Invalid option: expected one of
341
+ * "a"|"b"`, or `Provide target.kind.` when the tag was left out. The repair
342
+ * reads {@link renderedIssues} itself, where such a union stays one value, and
343
+ * reads its tag there as a field holding every branch's literal (#714), as it
344
+ * reads an unknown discriminator: `1` sent for tags `"1"` and `"2"` becomes
345
+ * `"1"`. `rejected` tells a literal the caller left out from one it sent wrong.
346
+ */
347
+ function issueLines(issues, rejected) {
348
+ const leftOut = (path) => isAbsent(argumentOf(rejected, path));
349
+ return renderedIssues(issues, [], leftOut).map((entry) => {
350
+ const tag = literalTagIssue(entry.issue);
351
+ return tag ? { issue: tag, path: [...entry.path, ...tag.path] } : entry;
352
+ });
353
+ }
231
354
  /** `items.1.name` — the dotted form a path takes in the message and the hint. */
232
355
  function dottedPath(path) {
233
356
  return path.map(String).join('.');
234
357
  }
358
+ /**
359
+ * The most entries an argument rejection keeps, in its result (#648) and its
360
+ * log record (#631) alike: issue lines of the message and the hint, entries of
361
+ * every array its `data` carries, and own keys of every object. The caller
362
+ * sets every count and length a rejection reports — how many issues, how many
363
+ * keys, how long a key — and an author's refinement can copy the caller's
364
+ * value into a custom issue, so neither surface grows with them.
365
+ */
366
+ const REJECTION_ENTRIES = 10;
367
+ /**
368
+ * A message line or hint sentence as a rejection carries it (#648): its first
369
+ * {@link OBSERVABILITY_MAX_STRING_LENGTH} characters, ending `…` when that cut
370
+ * removed something. One line grows with the caller's keys and paths, a
371
+ * union's branches, and an author's enum list, so a line count alone does not
372
+ * bound the text.
373
+ */
374
+ function cutLine(text) {
375
+ if (text.length <= OBSERVABILITY_MAX_STRING_LENGTH)
376
+ return text;
377
+ return `${capForObservability(text).value}…`;
378
+ }
379
+ /**
380
+ * How much of an issue's rendering a rejection reads (#648): one character
381
+ * past the longest line {@link cutLine} returns, so a line it cuts reads the
382
+ * same as the whole rendering, and a line short enough to keep is whole.
383
+ */
384
+ const RENDERED_LENGTH = OBSERVABILITY_MAX_STRING_LENGTH + 2;
385
+ /** `text` as far as a rejection reads it: its first {@link RENDERED_LENGTH} characters. */
386
+ function readable(text) {
387
+ return text.length > RENDERED_LENGTH ? text.slice(0, RENDERED_LENGTH) : text;
388
+ }
389
+ /** `(+N more)` for the issue lines past the first {@link REJECTION_ENTRIES}, or `undefined`. */
390
+ function moreLines(lines) {
391
+ const left = lines.length - REJECTION_ENTRIES;
392
+ return left > 0 ? `(+${left} more)` : undefined;
393
+ }
394
+ /**
395
+ * The most issues `data.issues` keeps in all (#648), counted in document order
396
+ * through every union's branches. A union's issue lists each branch's issues,
397
+ * and Zod hands every branch that parsed the same value the same issues — a
398
+ * recursive union's `and` and `or` branches both list the clause they share —
399
+ * so the tree a caller's nesting raises doubles per level while its distinct
400
+ * issues grow only with the nesting. A per-array cap alone leaves that tree
401
+ * whole: no array in it is long.
402
+ */
403
+ const REJECTION_ISSUES = 30;
404
+ /**
405
+ * The most levels of arrays and objects a projection keeps below an object an
406
+ * array holds — an issue, in `data.issues` — or below its root. An issue's own
407
+ * structure reaches two: a union's `errors` and the branch lists in it. Caller
408
+ * data an author's refinement copies into a custom issue keeps the same two,
409
+ * so its nesting no more grows a rejection than its width does; a list or an
410
+ * object one level deeper keeps none of its entries.
411
+ */
412
+ const REJECTION_LEVELS = 2;
413
+ /** How many entries a list or an object `level` levels below an issue keeps: none past {@link REJECTION_LEVELS}. */
414
+ function entriesAt(level) {
415
+ return level > REJECTION_LEVELS ? 0 : REJECTION_ENTRIES;
416
+ }
417
+ /**
418
+ * Whether `budget` lets an array keep `entry`, which then takes its share: an
419
+ * object takes one, an array takes none but is kept only while one is left,
420
+ * and any other value is always kept.
421
+ */
422
+ function admits(budget, entry) {
423
+ if (entry === null || typeof entry !== 'object')
424
+ return true;
425
+ if (budget.left === 0)
426
+ return false;
427
+ if (!Array.isArray(entry))
428
+ budget.left--;
429
+ return true;
430
+ }
431
+ /**
432
+ * How many levels below an issue an array's entry sits, `level` being the
433
+ * array's: an object it holds is counted like an issue, from zero again, and
434
+ * takes its share of the budget for it ({@link admits}).
435
+ */
436
+ function entryLevel(entry, level) {
437
+ return entry !== null && typeof entry === 'object' && !Array.isArray(entry) ? 0 : level + 1;
438
+ }
439
+ /**
440
+ * The containers {@link cutToCaps} built. A projection writes each cut's
441
+ * record beside the field it cut, and a second projection would read those
442
+ * records as fields of the caller's and cut them again — past 10 keys, or a
443
+ * `<key>KeyLength` past 1,024 characters — so a container the projection built
444
+ * passes through a later one unchanged: the argument rejection's log record
445
+ * projects a `data` whose `data.issues` and `data.input` it already built
446
+ * (#631).
447
+ */
448
+ const projections = new WeakSet();
449
+ /**
450
+ * Whether {@link cutToCaps} leaves `value` as it is: nothing in it past the
451
+ * caps — no string or key past 1,024 characters, no array past 10 entries, no
452
+ * object past 10 own keys, no list or object holding anything past
453
+ * {@link REJECTION_LEVELS} levels below an issue — and its arrays holding no
454
+ * more objects in all than `budget` admits. Walked without copying, since
455
+ * nearly every rejection is within them, and stopped at the first value past
456
+ * them, so a finite budget bounds the walk however many times Zod lists one
457
+ * issue. `level` is `value`'s, below the nearest object an array holds.
458
+ */
459
+ function withinCaps(value, budget, level = 0) {
460
+ if (typeof value === 'string')
461
+ return value.length <= OBSERVABILITY_MAX_STRING_LENGTH;
462
+ if (value === null || typeof value !== 'object' || projections.has(value))
463
+ return true;
464
+ if (Array.isArray(value)) {
465
+ if (value.length > entriesAt(level))
466
+ return false;
467
+ for (const entry of value) {
468
+ if (!admits(budget, entry) || !withinCaps(entry, budget, entryLevel(entry, level))) {
469
+ return false;
470
+ }
471
+ }
472
+ return true;
473
+ }
474
+ const keys = Object.keys(value);
475
+ if (keys.length > entriesAt(level))
476
+ return false;
477
+ for (const key of keys) {
478
+ if (key.length > OBSERVABILITY_MAX_STRING_LENGTH)
479
+ return false;
480
+ if (!withinCaps(value[key], budget, level + 1))
481
+ return false;
482
+ }
483
+ return true;
484
+ }
485
+ /**
486
+ * `value` bounded as an argument rejection carries it, on the wire (#648) and
487
+ * in its log record (#631): `value` itself when nothing in it is past the
488
+ * caps, so a rejection within them is carried uncut, and otherwise its
489
+ * {@link cutToCaps} projection. `objects` is the most objects its arrays keep
490
+ * in all — {@link REJECTION_ISSUES} for `data.issues`, where every such object
491
+ * is an issue. `value` is an object the framework builds, whose own few keys
492
+ * are never cut, so the records of its own cut have no field to sit beside.
493
+ */
494
+ function boundedProjection(value, objects = Number.POSITIVE_INFINITY) {
495
+ return withinCaps(value, { left: objects })
496
+ ? value
497
+ : cutToCaps(value, { left: objects }, 0).value;
498
+ }
499
+ /** The records of a value nothing was cut from. */
500
+ const UNCUT = [];
501
+ /**
502
+ * `value` with a string cut to its first {@link OBSERVABILITY_MAX_STRING_LENGTH}
503
+ * characters, an array to its first {@link REJECTION_ENTRIES} entries and
504
+ * before the first entry `budget` no longer {@link admits}, an object to its
505
+ * first 10 own keys ({@link cutObject}), and a list or an object
506
+ * {@link REJECTION_LEVELS} levels below an issue to none of its entries, read
507
+ * in document order, and every nested value the same. A value the projection
508
+ * built passes through unchanged ({@link projections}).
509
+ */
510
+ function cutToCaps(value, budget, level) {
511
+ if (typeof value === 'string') {
512
+ const { value: kept, length } = capForObservability(value);
513
+ return { value: kept, records: length === undefined ? UNCUT : [['Length', length]] };
514
+ }
515
+ if (value === null || typeof value !== 'object' || projections.has(value)) {
516
+ return { value, records: UNCUT };
517
+ }
518
+ const cut = Array.isArray(value)
519
+ ? cutArray(value, budget, level)
520
+ : cutObject(value, budget, level);
521
+ projections.add(cut.value);
522
+ return cut;
523
+ }
524
+ /**
525
+ * An array's first {@link entriesAt} entries, ending before the first one
526
+ * `budget` no longer {@link admits}, each cut in turn: `Count` when entries
527
+ * were left out, and `Lengths` when a kept entry's own string, entries, or
528
+ * keys were cut.
529
+ */
530
+ function cutArray(value, budget, level) {
531
+ const kept = [];
532
+ let entryCut = false;
533
+ for (const entry of value.slice(0, entriesAt(level))) {
534
+ if (!admits(budget, entry))
535
+ break;
536
+ const cut = cutToCaps(entry, budget, entryLevel(entry, level));
537
+ kept.push(cut.value);
538
+ entryCut ||= cut.records.some(([suffix]) => suffix !== 'Lengths');
539
+ }
540
+ const records = [];
541
+ if (kept.length < value.length)
542
+ records.push(['Count', value.length]);
543
+ if (entryCut)
544
+ records.push(['Lengths', kept.map((_, i) => sizeOf(value[i]) ?? null)]);
545
+ return { value: kept, records };
546
+ }
547
+ /**
548
+ * An object's first {@link entriesAt} own keys, each key cut to its first
549
+ * 1,024 characters with `<key>KeyLength`, the uncut length, beside a cut one,
550
+ * and each value cut in turn with its records beside it, in the object's own
551
+ * order. A record never shares a name with a kept key: a key a record's name
552
+ * takes, or one that cutting makes equal to an earlier key, is left out with
553
+ * the keys past the first 10, and `Count` records them all. A `<key>KeyLength`
554
+ * is longer than any kept key, so no kept key can take its name. Nor can two
555
+ * records share one: the only suffix that ends another, `KeyLength` ending
556
+ * `Length`, follows a cut key, and the field that would share it, `<key>Key`,
557
+ * is longer than any key kept.
558
+ */
559
+ function cutObject(value, budget, level) {
560
+ const keys = Object.keys(value);
561
+ const fields = [];
562
+ const names = new Set();
563
+ for (const key of keys.slice(0, entriesAt(level))) {
564
+ const { value: name, length } = capForObservability(key);
565
+ if (names.has(name))
566
+ continue;
567
+ names.add(name);
568
+ const cut = cutToCaps(value[key], budget, level + 1);
569
+ const records = cut.records.map(([suffix, record]) => [
570
+ `${name}${suffix}`,
571
+ record,
572
+ ]);
573
+ if (length !== undefined)
574
+ records.unshift([`${name}KeyLength`, length]);
575
+ fields.push({ name, records, value: cut.value });
576
+ }
577
+ const recorded = new Set(fields.flatMap(({ records }) => records.map(([name]) => name)));
578
+ const bounded = {};
579
+ let kept = 0;
580
+ for (const field of fields) {
581
+ if (recorded.has(field.name))
582
+ continue;
583
+ kept++;
584
+ bounded[field.name] = field.value;
585
+ for (const [name, record] of field.records)
586
+ bounded[name] = record;
587
+ }
588
+ return { value: bounded, records: kept < keys.length ? [['Count', keys.length]] : UNCUT };
589
+ }
590
+ /**
591
+ * What {@link cutToCaps} can cut of `value` itself: a string's length, an
592
+ * array's entry count, an object's key count, `undefined` for anything else.
593
+ */
594
+ function sizeOf(value) {
595
+ if (typeof value === 'string' || Array.isArray(value))
596
+ return value.length;
597
+ return value !== null && typeof value === 'object' ? Object.keys(value).length : undefined;
598
+ }
235
599
  /**
236
600
  * One line of the rendered detail: `path: message`, or the bare message at the
237
- * root. `args` decides the absent/present bit {@link renderIssueMessage} reads.
601
+ * root. `absent` is the bit {@link renderIssueMessage} reads ({@link isAbsent}).
238
602
  */
239
- function renderIssueLine({ issue, path }, args) {
240
- const message = renderIssueMessage(issue, readArgumentAt(args, path) === ABSENT);
603
+ function renderIssueLine({ issue, path }, absent) {
604
+ const message = renderIssueMessage(issue, absent);
241
605
  return path.length > 0 ? `${dottedPath(path)}: ${message}` : message;
242
606
  }
607
+ /**
608
+ * {@link renderIssueMessage}'s renderings, by issue, for each `absent` bit.
609
+ * Zod hands every union branch that parsed the same value the same issue
610
+ * objects, so keying on the issue renders a shared one once.
611
+ */
612
+ const renderings = {
613
+ absent: new WeakMap(),
614
+ present: new WeakMap(),
615
+ };
243
616
  /**
244
617
  * The readable half of one issue's rendered line.
245
618
  *
@@ -248,9 +621,14 @@ function renderIssueLine({ issue, path }, args) {
248
621
  * - **#417** — Zod reports a union whose every branch aborted as one
249
622
  * `invalid_union` issue whose own message is the placeholder `Invalid input`;
250
623
  * what would have been accepted lives on the nested branch issues. Render the
251
- * selected branches instead, joined by ` or `. (When exactly one branch
252
- * matched the base type and failed only a check, Zod returns that branch's
253
- * issues directly and this never fires.)
624
+ * selected branches instead, joined by ` or `. When the selection leaves
625
+ * none, every branch failed on one literal: render the literal's issue
626
+ * naming every value where they share a path ({@link literalTagIssue}), and
627
+ * every branch where they do not. Only a union with no branches, a
628
+ * discriminated union's unmatched tag, keeps Zod's own message, which names
629
+ * the accepted values itself. (When exactly one branch matched the base type
630
+ * and failed only a check, Zod returns that branch's issues directly and
631
+ * this never fires.)
254
632
  * - **#447** — an object branch's issues carry their own branch-relative path,
255
633
  * so {@link renderBranchIssue} prefixes each one with it and no alternative
256
634
  * goes unnamed. Issues *within* a branch join on `; ` rather than the `, `
@@ -264,20 +642,69 @@ function renderIssueLine({ issue, path }, args) {
264
642
  *
265
643
  * On a required union field both compose: the branch is selected first, then
266
644
  * the absence check decides how that branch's message renders.
645
+ *
646
+ * Read only as far as a rejection reads it (#648): the first
647
+ * {@link RENDERED_LENGTH} characters, a branch or issue past them never
648
+ * rendered, and each issue rendered once per `absent` bit however many
649
+ * branches list it ({@link renderings}). A union's rendering grows with every
650
+ * branch it names, so one whose branches share a recursive clause doubles per
651
+ * level of the caller's nesting; read this way, its cost grows with the
652
+ * distinct issues alone.
267
653
  */
268
654
  function renderIssueMessage(issue, absent) {
655
+ const rendered = renderings[absent ? 'absent' : 'present'];
656
+ let message = rendered.get(issue);
657
+ if (message === undefined) {
658
+ message = readable(composeIssueMessage(issue, absent));
659
+ rendered.set(issue, message);
660
+ }
661
+ return message;
662
+ }
663
+ /**
664
+ * The rendering {@link renderIssueMessage} reads from, stopped once it is
665
+ * {@link RENDERED_LENGTH} characters long: the selected branches, each
666
+ * rendered once and joined on ` or `, a branch already rendered skipped.
667
+ */
668
+ function composeIssueMessage(issue, absent) {
269
669
  if (issue.code === 'invalid_union') {
270
- const branches = selectUnionBranches(issue.errors);
670
+ const tag = literalTagIssue(issue);
671
+ if (tag)
672
+ return renderBranchIssue(tag, absent);
673
+ const selected = selectUnionBranches(issue.errors);
674
+ const branches = selected.length > 0 ? selected : issue.errors;
271
675
  if (branches.length === 0)
272
676
  return issue.message;
273
- const rendered = branches.map((branch) => branch.map((branchIssue) => renderBranchIssue(branchIssue, absent)).join('; '));
274
- return [...new Set(rendered)].join(' or ');
677
+ const rendered = new Set();
678
+ let length = 0;
679
+ for (const branch of branches) {
680
+ const text = joinReadable(branch, '; ', (branchIssue) => renderBranchIssue(branchIssue, absent));
681
+ if (rendered.has(text))
682
+ continue;
683
+ length += (rendered.size > 0 ? ' or '.length : 0) + text.length;
684
+ rendered.add(text);
685
+ if (length >= RENDERED_LENGTH)
686
+ break;
687
+ }
688
+ return [...rendered].join(' or ');
275
689
  }
276
690
  if (absent && issue.code === 'invalid_value') {
277
- return `Missing required field. ${expectedValuesText(issue.values)}`;
691
+ return `Missing required field. Expected ${acceptedValuesText(issue.values)}`;
278
692
  }
279
693
  return issue.message;
280
694
  }
695
+ /**
696
+ * `items` rendered and joined on `separator` as far as a rejection reads them
697
+ * ({@link readable}): an item past that point is never rendered.
698
+ */
699
+ function joinReadable(items, separator, render) {
700
+ let text = '';
701
+ for (const [index, item] of items.entries()) {
702
+ text += `${index > 0 ? separator : ''}${render(item)}`;
703
+ if (text.length >= RENDERED_LENGTH)
704
+ return readable(text);
705
+ }
706
+ return text;
707
+ }
281
708
  /**
282
709
  * One issue of a union branch, prefixed with the branch-relative path it names.
283
710
  *
@@ -300,14 +727,24 @@ function renderBranchIssue(issue, absent) {
300
727
  * rejection (see `deferInputValidation`) purely so it can also carry
301
728
  * `structuredContent.error`.
302
729
  *
303
- * `args` are the caller's raw arguments, read only through
304
- * {@link readArgumentAt}; see {@link renderIssueMessage} for what that decides.
730
+ * `lines` are the rejection's {@link issueLines}, and `rejected.args` the
731
+ * arguments its parse ran on — the caller's, with any repairs that held
732
+ * (#706) — read only through {@link argumentOf}; see
733
+ * {@link renderIssueMessage} for what that decides.
734
+ *
735
+ * Bounded whatever the caller sends (#648): the first 10 issue lines, each
736
+ * {@link cutLine cut} to its first 1,024 characters, then ` (+N more)` for the
737
+ * lines left out — the suffix `formatZodErrorMessage` closes other `ZodError`
738
+ * messages with. A rejection with 10 lines or fewer, none past 1,024
739
+ * characters, is not cut.
305
740
  */
306
- export function formatInputValidationMessage(toolName, error, args) {
307
- const detail = renderedIssues(error.issues)
308
- .map((entry) => renderIssueLine(entry, args))
741
+ function formatInputValidationMessage(toolName, lines, rejected) {
742
+ const detail = lines
743
+ .slice(0, REJECTION_ENTRIES)
744
+ .map((entry) => cutLine(renderIssueLine(entry, isAbsent(argumentOf(rejected, entry.path)))))
309
745
  .join(', ');
310
- return `Input validation error: Invalid arguments for tool ${toolName}: ${detail}`;
746
+ const more = moreLines(lines);
747
+ return `Input validation error: Invalid arguments for tool ${toolName}: ${more === undefined ? detail : `${detail} ${more}`}`;
311
748
  }
312
749
  /** `a number`, `an array` — the indefinite article a type name reads with. */
313
750
  function withArticle(typeName) {
@@ -337,67 +774,6 @@ function joinNames(names) {
337
774
  function rootPropertyNames(input) {
338
775
  return isZodObjectSchema(input) ? Object.keys(input.shape) : [];
339
776
  }
340
- /**
341
- * The `z.object()` a nested unknown-key issue sits in, found by walking its
342
- * rendered path down from `schema` beside the caller's own `value` — or
343
- * `undefined` when the path does not land on exactly one object (#566).
344
- *
345
- * Wrappers (`optional`, `nullable`, `default`, …), `pipe`, and `z.lazy()` are
346
- * looked through. A numeric step enters an array element or tuple item; a
347
- * string step, a property or a record value. A discriminated union follows the
348
- * variant the argument's own discriminator selects, as Zod did. A plain union
349
- * follows the one option under which the rest of the path still lands on an
350
- * object — the branch {@link renderedIssues} lifted under #492. Anything else,
351
- * an intersection or a union two options satisfy, resolves to nothing.
352
- */
353
- function objectSchemaAt(schema, path, value) {
354
- const def = zodDef(schema);
355
- if (!def)
356
- return undefined;
357
- if (def.type === 'lazy' && def.getter)
358
- return objectSchemaAt(def.getter(), path, value);
359
- if (def.type === 'pipe')
360
- return objectSchemaAt(def.in, path, value);
361
- if (def.innerType !== undefined)
362
- return objectSchemaAt(def.innerType, path, value);
363
- if (def.type === 'union')
364
- return unionOptionAt(def, path, value);
365
- const [step, ...rest] = path;
366
- if (step === undefined)
367
- return isZodObjectSchema(schema) ? schema : undefined;
368
- const next = stepInto(value, step);
369
- switch (def.type) {
370
- case 'object':
371
- return typeof step === 'string' && def.shape && Object.hasOwn(def.shape, step)
372
- ? objectSchemaAt(def.shape[step], rest, next)
373
- : undefined;
374
- case 'record':
375
- return objectSchemaAt(def.valueType, rest, next);
376
- case 'array':
377
- return typeof step === 'number' ? objectSchemaAt(def.element, rest, next) : undefined;
378
- case 'tuple':
379
- return typeof step === 'number'
380
- ? objectSchemaAt(def.items?.[step] ?? def.rest, rest, next)
381
- : undefined;
382
- default:
383
- return undefined;
384
- }
385
- }
386
- /** {@link objectSchemaAt} at a union: the one option the path resolves through. */
387
- function unionOptionAt(def, path, value) {
388
- const options = def.options ?? [];
389
- const { discriminator } = def;
390
- if (typeof discriminator === 'string') {
391
- const tag = stepInto(value, discriminator);
392
- const selected = options.find((option) => {
393
- const field = isZodObjectSchema(option) ? option.shape[discriminator] : undefined;
394
- return field?.safeParse(tag).success === true;
395
- });
396
- return selected === undefined ? undefined : objectSchemaAt(selected, path, value);
397
- }
398
- const resolved = options.flatMap((option) => objectSchemaAt(option, path, value) ?? []);
399
- return resolved.length === 1 ? resolved[0] : undefined;
400
- }
401
777
  /**
402
778
  * The unknown-key sentence for one `unrecognized_keys` issue.
403
779
  *
@@ -406,25 +782,40 @@ function unionOptionAt(def, path, value) {
406
782
  * accepts (#566) — the root list there would send the caller to move the key to
407
783
  * the root or rename it after a root field. Neither list is given when there is
408
784
  * none to give: a discriminated-union root, a nested object declaring no keys,
409
- * or a path {@link objectSchemaAt} cannot resolve.
785
+ * or a path {@link objectSchemaAt} cannot resolve. A nested path is resolved
786
+ * through a transforming pipe's output (#599), so an item a `z.preprocess()`
787
+ * wrapped in a list names its own keys.
410
788
  */
411
- function unknownKeySentence(input, keys, path, args) {
789
+ function unknownKeySentence(rejected, keys, path) {
412
790
  const label = keys.length === 1 ? 'Unknown key' : 'Unknown keys';
413
791
  if (path.length === 0) {
414
- const accepted = rootPropertyNames(input);
792
+ const accepted = rootPropertyNames(rejected.input);
415
793
  return accepted.length > 0
416
794
  ? `${label} ${keys.join(', ')}. This tool accepts: ${accepted.join(', ')}.`
417
795
  : `${label} ${keys.join(', ')}.`;
418
796
  }
419
797
  const where = dottedPath(path);
420
798
  const named = keys.map((key) => `${where}.${key}`).join(', ');
421
- const accepted = Object.keys(objectSchemaAt(input, path, args)?.shape ?? {});
799
+ const { args, input, transforms } = rejected;
800
+ const accepted = Object.keys(objectSchemaAt(input, path, args, transforms)?.shape ?? {});
422
801
  return accepted.length > 0
423
802
  ? `${label} ${named}. ${where} accepts: ${accepted.join(', ')}.`
424
803
  : `${label} ${named}.`;
425
804
  }
426
805
  /**
427
- * The wrong-type sentence for one `invalid_type` issue.
806
+ * The sentence for a declared key the caller sent with an alias of it (#639):
807
+ * the aliases, in argument order, and the choice left to make — between two
808
+ * keys, or among three or more.
809
+ */
810
+ function collisionSentence({ keys, target }) {
811
+ const aliases = keys.filter((key) => key !== target);
812
+ const subject = aliases.length === 1 ? `${aliases[0]} is an alias of` : `${joinNames(aliases)} are aliases of`;
813
+ const choice = keys.length > 2 ? 'send only one of them.' : 'send one of them, not both.';
814
+ return `${subject} ${target}; ${choice}`;
815
+ }
816
+ /**
817
+ * The wrong-type sentence for one `invalid_type` issue, from the value the
818
+ * schema there received.
428
819
  *
429
820
  * `int` is the one expectation a JSON number fails by type — `.int()`,
430
821
  * `z.int()`, `z.int32()`, and `z.uint32()` all report it, while range and
@@ -433,52 +824,102 @@ function unknownKeySentence(input, keys, path, args) {
433
824
  * rather than the type names `int` and `number`, which the value already
434
825
  * satisfies (#499).
435
826
  */
436
- function wrongTypeSentence(subject, expected, arrived) {
437
- if (arrived === ABSENT)
827
+ function wrongTypeSentence(subject, expected, at) {
828
+ if (isAbsent(at))
438
829
  return `Send ${subject} as ${withArticle(expected)}.`;
830
+ const arrived = at.received;
439
831
  if (expected === 'int' && typeof arrived === 'number') {
440
832
  return `Send ${subject} as an integer, not a fractional number.`;
441
833
  }
442
834
  return `Send ${subject} as ${withArticle(expected)}, not ${arrivedTypeText(arrived)}.`;
443
835
  }
444
836
  /**
445
- * Synthesizes `data.recovery.hint` from the Zod issues, the raw arguments, and
446
- * the root schema (#445) — so the one failure a weaker model hits most often
447
- * carries the same next step every handler-thrown error does, instead of
448
- * costing a round trip for the schema.
837
+ * Synthesizes `data.recovery.hint` from the Zod issues, the arguments their
838
+ * parse ran on, and the root schema (#445) — so the one failure a weaker
839
+ * model hits most often carries the same next step every handler-thrown error
840
+ * does, instead of costing a round trip for the schema.
449
841
  *
450
- * One sentence per {@link renderedIssues} entry, joined into a single hint,
842
+ * One sentence per {@link issueLines} entry, joined into a single hint,
451
843
  * except that every missing required field collapses into one `Provide …`
452
844
  * sentence at the first of their positions. An issue no bucket claims is
453
845
  * restated as its message line — `start: Must be …`, path included, so
454
846
  * identical constraints on different fields stay distinguishable (#493).
455
847
  *
848
+ * The wrong-type sentence names the type the schema received, which says
849
+ * what to send only where that is the caller's own value. A path that ends on
850
+ * a transform (#599) — a `z.preprocess()` that falls through on a string it
851
+ * cannot parse, in front of `z.number()` — is restated instead: the raw type
852
+ * says nothing about which forms the transform accepts. So is a path whose
853
+ * transform threw when re-applied, since what it received is unknown, and one
854
+ * a transform made `undefined` of a value the caller sent: a lookup that does
855
+ * not know the name is asked for nothing it can `Provide` ({@link isAbsent}).
856
+ *
456
857
  * When every sentence is a restatement, the hint is the message's issue text
457
858
  * verbatim, which is what lets {@link buildToolErrorResult} drop the
458
859
  * `Recovery:` line (#459). When restatements share the hint with the
459
860
  * framework's own sentences, each is terminated so it cannot run into the next
460
861
  * one, and a sentence already stated is not repeated.
461
862
  *
462
- * `report` closes the hint with what the pre-validation step changed before
463
- * the parse (#468) — `Validated query as targetQuery.` for a rewritten key,
464
- * `Dropped undeclared key _max.` for an underscore-rule drop — since the issues
465
- * name only the keys that were validated. Both are framework sentences, never
466
- * restatements, so a hint carrying one keeps its `Recovery:` line.
863
+ * The attempt's `report` closes the hint with what the pre-validation step
864
+ * changed before the parse (#468) — `Validated query as targetQuery.` for a
865
+ * rewritten key, `Dropped undeclared key _max.` for an underscore-rule drop —
866
+ * since the issues name only the keys that were validated. Both are framework
867
+ * sentences, never restatements, so a hint carrying one keeps its `Recovery:`
868
+ * line.
869
+ *
870
+ * Its `collisions` name an alias the caller sent beside its target as one
871
+ * (#639) — `maxResults is an alias of pageSize; send one of them, not both.` —
872
+ * in place of the sentence that would otherwise call it an unknown root key
873
+ * or a dropped key, once per target, at the first place either would have
874
+ * named it. The keys around it keep their own sentences.
875
+ *
876
+ * Bounded like the message (#648): sentences for the first 10 entries only,
877
+ * each {@link cutLine cut} to its first 1,024 characters, the issue sentences
878
+ * closed with the message's ` (+N more)` before what the pre-validation step
879
+ * changed. An `unrecognized_keys` entry is one of the 10 however many keys and
880
+ * alias collisions it names, and a missing field past them is counted there,
881
+ * not named. A cut restatement is the message's cut line itself, so a hint
882
+ * made only of restatements is still the message's issue text, suffix
883
+ * included.
467
884
  */
468
- function buildArgumentRecoveryHint(def, error, args, report) {
885
+ function buildArgumentRecoveryHint(lines, rejected, { collisions, report }) {
469
886
  const sentences = [];
470
887
  const restatements = [];
471
888
  const missing = [];
472
889
  let missingSlot = -1;
473
- for (const entry of renderedIssues(error.issues)) {
890
+ const collidedBy = new Map();
891
+ for (const collision of collisions?.() ?? []) {
892
+ for (const key of collision.declined)
893
+ collidedBy.set(key, collision);
894
+ }
895
+ const named = new Set();
896
+ const nameCollisions = (keys, into) => {
897
+ for (const key of keys) {
898
+ const collision = collidedBy.get(key);
899
+ if (!collision || named.has(collision))
900
+ continue;
901
+ named.add(collision);
902
+ into.push(cutLine(collisionSentence(collision)));
903
+ }
904
+ };
905
+ const uncollided = (keys) => collidedBy.size === 0 ? keys : keys.filter((key) => !collidedBy.has(key));
906
+ for (const entry of lines.slice(0, REJECTION_ENTRIES)) {
474
907
  const { issue } = entry;
475
908
  const path = dottedPath(entry.path);
476
- const arrived = readArgumentAt(args, entry.path);
477
909
  if (issue.code === 'unrecognized_keys') {
478
- sentences.push(unknownKeySentence(def.input, issue.keys, entry.path, args));
910
+ // Aliases are root keys, so only the root's unknown keys can be one.
911
+ const root = entry.path.length === 0;
912
+ if (root)
913
+ nameCollisions(issue.keys, sentences);
914
+ const unknown = root ? uncollided(issue.keys) : issue.keys;
915
+ if (unknown.length > 0) {
916
+ sentences.push(cutLine(unknownKeySentence(rejected, unknown, entry.path)));
917
+ }
479
918
  continue;
480
919
  }
481
- if (path.length > 0 && arrived === ABSENT) {
920
+ const at = argumentOf(rejected, entry.path);
921
+ const absent = isAbsent(at);
922
+ if (path.length > 0 && absent) {
482
923
  if (missingSlot < 0) {
483
924
  missingSlot = sentences.length;
484
925
  sentences.push('');
@@ -486,27 +927,39 @@ function buildArgumentRecoveryHint(def, error, args, report) {
486
927
  missing.push(path);
487
928
  continue;
488
929
  }
489
- if (issue.code === 'invalid_type') {
490
- sentences.push(wrongTypeSentence(path.length > 0 ? path : 'the arguments', issue.expected, arrived));
930
+ // A value a transform made undefined of what the caller sent names no type to send instead.
931
+ const typed = absent || at.received !== undefined;
932
+ if (issue.code === 'invalid_type' && at.known && !at.transformed && typed) {
933
+ sentences.push(cutLine(wrongTypeSentence(path.length > 0 ? path : 'the arguments', issue.expected, at)));
491
934
  continue;
492
935
  }
493
- const line = renderIssueLine(entry, args);
936
+ const rendered = renderIssueLine(entry, absent);
937
+ const line = cutLine(rendered);
494
938
  restatements.push(line);
495
- sentences.push(terminateSentence(line));
939
+ sentences.push(line === rendered ? terminateSentence(rendered) : line);
496
940
  }
941
+ const changed = [];
942
+ if (report)
943
+ nameCollisions(report.ignored, changed);
497
944
  if (report && report.aliased.length > 0) {
498
945
  const rewrites = report.aliased.map(({ alias, target }) => `${alias} as ${target}`);
499
- sentences.push(`Validated ${joinNames(rewrites)}.`);
946
+ changed.push(cutLine(`Validated ${joinNames(rewrites)}.`));
947
+ }
948
+ const dropped = report ? uncollided(report.ignored) : [];
949
+ if (dropped.length > 0) {
950
+ const label = dropped.length === 1 ? 'key' : 'keys';
951
+ changed.push(cutLine(`Dropped undeclared ${label} ${joinNames(dropped)}.`));
500
952
  }
501
- if (report && report.ignored.length > 0) {
502
- const label = report.ignored.length === 1 ? 'key' : 'keys';
503
- sentences.push(`Dropped undeclared ${label} ${joinNames(report.ignored)}.`);
953
+ const more = moreLines(lines);
954
+ if (restatements.length === sentences.length && changed.length === 0) {
955
+ const text = restatements.join(', ');
956
+ return more === undefined ? text : `${text} ${more}`;
504
957
  }
505
- if (restatements.length === sentences.length)
506
- return restatements.join(', ');
507
958
  if (missingSlot >= 0)
508
- sentences[missingSlot] = `Provide ${joinNames(missing)}.`;
509
- return [...new Set(sentences)].join(' ');
959
+ sentences[missingSlot] = cutLine(`Provide ${joinNames(missing)}.`);
960
+ if (more !== undefined)
961
+ sentences.push(more);
962
+ return [...new Set([...sentences, ...changed])].join(' ');
510
963
  }
511
964
  /**
512
965
  * Validates raw tool arguments against the definition's `input` schema, or
@@ -517,25 +970,60 @@ function buildArgumentRecoveryHint(def, error, args, report) {
517
970
  * synthesizes (#445). {@link buildToolErrorResult} mirrors that hint into
518
971
  * `content[]`, so it reaches format()-only clients with no extra work.
519
972
  *
973
+ * The rejection is bounded whatever the caller sends (#648), here at the
974
+ * throw, so every path through this function carries the bound: the message
975
+ * and the hint render the first 10 issue lines, each cut to 1,024 characters,
976
+ * and `data.issues` and `data.input` are {@link boundedProjection}s — the
977
+ * first 10 entries of every array and own keys of every object, the first
978
+ * 1,024 characters of every string and key, and {@link REJECTION_LEVELS} levels
979
+ * of nesting below each issue, the uncut count or length beside each cut — with
980
+ * `data.issues` keeping at most {@link REJECTION_ISSUES} issues in all through
981
+ * every union's branches. `data.issuesCount`, how many issues Zod raised at
982
+ * the top level, sits beside `data.issues` whenever it keeps fewer of them —
983
+ * past the first 10, or sooner when that budget runs out — as `errorsCount`
984
+ * sits beside a cut branch list. A rejection within those caps carries Zod's
985
+ * list and the report exactly. Its cost grows
986
+ * with the distinct issues Zod raised, not with how many branches list one:
987
+ * every reader of the issues walks a shared one once or stops at a bound.
988
+ *
520
989
  * An ordered pre-validation step wraps the parse. Before it,
521
990
  * {@link prevalidateToolArguments} drops client-added keys (#453) and rewrites
522
991
  * key aliases (#452); after a failure — and only then —
523
- * {@link repairRepresentations} undoes a stringified array or object or an
524
- * integer sent for a string, and the arguments are parsed once more (#234,
525
- * #479, #487), the repair kept only if the author's own schema now accepts it.
992
+ * {@link repairRepresentations} undoes a stringified array or object, an
993
+ * integer sent for a string, a string sent for a number or boolean, or a lone
994
+ * string sent for an array, and deletes `null` sent for an optional field, at
995
+ * the paths {@link renderedIssues} names — inside a union field's one surviving
996
+ * branch (#570), at a discriminator or literal tag no variant accepts (#714),
997
+ * and inside a `z.preprocess()` output (#599) included — and the
998
+ * arguments are parsed once more (#234, #479, #487, #707, #602, #616), the
999
+ * repair kept only if the author's own schema now accepts it. The repair
1000
+ * 0.13.13 made, of Zod's own issues in the arguments as sent
1001
+ * ({@link repairAsSent}), is parsed before it, so every call that repair
1002
+ * validated keeps its value. Every path the
1003
+ * repair, the message, and the hint read is walked through the input schema,
1004
+ * a transform on the way re-applied once per value for the whole call.
526
1005
  * When that attempt still fails and its drop discarded a key,
527
1006
  * {@link prevalidateAliasFirst} reruns the stages alias-first and the same
528
1007
  * parse-then-repair runs on the result, kept only if it validates (#563).
529
1008
  * {@link recordPrevalidation} then counts and logs the attempt the handler
530
1009
  * receives, so a call the first attempt validates is untouched by the retry.
531
- * When nothing validates, the *original* rejection of the last attempt is
532
- * thrown — the retry's when it ran, since there every key the drop discarded
533
- * reached its target and the issues name what is wrong with the value it
534
- * carried — built from the arguments that produced it, identical to the one
535
- * the same call gets under `input: { coerce: false }`. It carries the rewrites
536
- * and underscore-rule drops that attempt made as `data.input`, and as
537
- * sentences closing the hint (#468); a call with neither gains no
538
- * `data.input`.
1010
+ * When nothing validates, the last attempt's rejection is thrown — the
1011
+ * retry's when it ran, since there every key the drop discarded reached its
1012
+ * target and the issues name what is wrong with the value it carried. It is a
1013
+ * parse of that attempt's arguments with only the repairs that held applied
1014
+ * (#706, {@link repairAttempt}), rendered from those same arguments: a value
1015
+ * the schema accepted once repaired is not reported — one that held only
1016
+ * written in place below a transform that reorders or rewrites it excepted —
1017
+ * and one whose repair the schema refused is reported as sent, so a call no
1018
+ * repair helped gets exactly
1019
+ * the rejection it gets under `input: { coerce: false }` — as does one the
1020
+ * held repairs alone would validate (only a union or a cross-field refinement
1021
+ * allows that), held values included. It carries the rewrites and
1022
+ * underscore-rule drops that attempt made as `data.input`, and as sentences
1023
+ * closing the hint (#468); a call with neither gains no `data.input`, and
1024
+ * nothing in it says a repair held. An alias that attempt
1025
+ * declined because its target was already present is named in the hint as an
1026
+ * alias of that target (#639), never as an unknown or a dropped key.
539
1027
  *
540
1028
  * The single argument-rejection path. {@link createToolHandler} and the
541
1029
  * `runToolContract` test helper both route through it, so a test written to
@@ -547,45 +1035,135 @@ function buildArgumentRecoveryHint(def, error, args, report) {
547
1035
  */
548
1036
  export function parseToolArguments(def, input, options = {}) {
549
1037
  const first = prevalidateToolArguments(def, input, options.input);
550
- const parsed = parseAttempt(def, first.args, options.input);
1038
+ const parsed = def.input.safeParse(first.args);
1039
+ if (!parsed.success)
1040
+ return repairOrReject(def, input, first, parsed.error, options);
1041
+ return accept(def, first, { success: true, data: parsed.data, coerced: NO_COERCIONS }, options.context);
1042
+ }
1043
+ /** No repair applied. */
1044
+ const NO_COERCIONS = [];
1045
+ /**
1046
+ * {@link parseToolArguments} once the first parse has failed with
1047
+ * `firstError`: the repair, the alias-first retry, and the rejection. Kept out
1048
+ * of the function a valid call runs, which then allocates nothing past the
1049
+ * parse.
1050
+ */
1051
+ function repairOrReject(def, input, first, firstError, options) {
1052
+ // One cache for the whole call: the repair, the message, and the hint each
1053
+ // read paths through the same transforms, which then run once per value (#599).
1054
+ const transforms = new Map();
1055
+ const parsed = repairAttempt(def, first.args, firstError, options.input, transforms);
551
1056
  if (parsed.success)
552
1057
  return accept(def, first, parsed, options.context);
553
- let rejected = { attempt: first, error: parsed.error };
1058
+ let failed = { attempt: first, parsed };
554
1059
  const retry = prevalidateAliasFirst(def, input, first, options.input);
555
1060
  if (retry) {
556
- const retried = parseAttempt(def, retry.args, options.input);
1061
+ const retryParse = def.input.safeParse(retry.args);
1062
+ const retried = retryParse.success
1063
+ ? { success: true, data: retryParse.data, coerced: NO_COERCIONS }
1064
+ : repairAttempt(def, retry.args, retryParse.error, options.input, transforms);
557
1065
  if (retried.success)
558
1066
  return accept(def, retry, retried, options.context);
559
- rejected = { attempt: retry, error: retried.error };
1067
+ failed = { attempt: retry, parsed: retried };
560
1068
  }
561
- const { attempt, error } = rejected;
1069
+ const { attempt, parsed: { args, error }, } = failed;
562
1070
  recordPrevalidation(def, attempt, options.context);
563
1071
  const { report } = attempt;
564
- throw new McpError(JsonRpcErrorCode.InvalidParams, formatInputValidationMessage(def.name, error, attempt.args), {
565
- issues: error.issues,
1072
+ const rejected = { args, input: def.input, transforms };
1073
+ const lines = issueLines(error.issues, rejected);
1074
+ throw new McpError(JsonRpcErrorCode.InvalidParams, formatInputValidationMessage(def.name, lines, rejected), {
1075
+ ...boundedProjection({ issues: error.issues }, REJECTION_ISSUES),
566
1076
  reason: INVALID_ARGUMENTS_REASON,
567
- ...(report && { input: report }),
568
- recovery: { hint: buildArgumentRecoveryHint(def, error, attempt.args, report) },
1077
+ ...(report && { input: boundedProjection(report) }),
1078
+ recovery: { hint: buildArgumentRecoveryHint(lines, rejected, attempt) },
569
1079
  });
570
1080
  }
571
1081
  /**
572
- * Parses one attempt's arguments, and on failure repairs them once and
573
- * re-parses, keeping the repair only if the schema then accepts it. A failure
574
- * carries the first parse's error: a discarded repair leaves no trace.
1082
+ * Repairs one attempt's arguments, whose parse failed with `error`, once and
1083
+ * re-parses, keeping the repair only if the schema then accepts it. The
1084
+ * original repair — Zod's own issues read and written at their paths in the
1085
+ * arguments as sent ({@link repairAsSent}) — is parsed first, so a call it
1086
+ * validated validates with the same value. A repair below a transform is then
1087
+ * parsed written in place (#599, {@link repairRepresentations}), and the
1088
+ * substitution last — with the original repairs no rendered issue reached,
1089
+ * then, when that fails, without them; arguments equal to the original
1090
+ * repair's are not parsed again, and a substitution every transform refused,
1091
+ * which leaves the arguments as sent, is not parsed at all. Only the first
1092
+ * substitution's parse can be the rejection, since only its repairs sit where
1093
+ * the issues name values.
1094
+ *
1095
+ * A call that still fails is rejected from the attempt's arguments with only
1096
+ * the repairs that held applied (#706) — those {@link heldRepairs} finds no
1097
+ * re-parse issue at or below. Every repair held: the re-parse is the
1098
+ * rejection. None did: the first parse is, exactly as under `coerce: false`.
1099
+ * Some did: one more parse, of the arguments with those written. It only
1100
+ * reports, never admits: when it validates — possible only where the schema
1101
+ * judges one value by another, as a union or a cross-field refinement does —
1102
+ * the first parse is the rejection, so which calls validate never rests on
1103
+ * it. Short of that fallback, no reported issue names a value the caller sent
1104
+ * and the schema accepted once repaired, and a value whose repair the schema
1105
+ * refused is reported as sent — but a value that held only written in place,
1106
+ * below a transform that reorders or rewrites it, is judged here by the
1107
+ * substitution, whose issues name positions in the transform's output, and is
1108
+ * reported as sent too. A re-parse the author's schema throws on discards the
1109
+ * repairs it carried, like a failed one.
575
1110
  */
576
- function parseAttempt(def, args, options) {
577
- const parsed = def.input.safeParse(args);
578
- if (parsed.success)
579
- return { success: true, data: parsed.data, coerced: [] };
580
- if (options?.coerce !== false) {
581
- const repair = repairRepresentations(args, parsed.error.issues);
582
- if (repair.args !== args) {
583
- const retried = def.input.safeParse(repair.args);
584
- if (retried.success)
585
- return { success: true, data: retried.data, coerced: repair.kinds };
1111
+ function repairAttempt(def, args, error, options, transforms) {
1112
+ const asSent = { success: false, args, error };
1113
+ if (options?.coerce === false)
1114
+ return asSent;
1115
+ const original = repairAsSent(args, error.issues);
1116
+ const originalParse = original && reparse(def, original.args);
1117
+ if (original && originalParse?.success) {
1118
+ return { success: true, data: originalParse.data, coerced: original.kinds };
1119
+ }
1120
+ // A repair that writes the same arguments as the original one fails the same way.
1121
+ const parseRepaired = (repaired) => original && sameValue(repaired, original.args) ? originalParse : reparse(def, repaired);
1122
+ const repair = repairRepresentations(args, renderedIssues(error.issues), def.input, transforms, original?.repairs);
1123
+ if (repair.inPlace) {
1124
+ const placed = parseRepaired(repair.inPlace.args);
1125
+ if (placed?.success)
1126
+ return { success: true, data: placed.data, coerced: repair.inPlace.kinds };
1127
+ }
1128
+ // No repair, or each one below a transform that refused it: the first parse read these arguments.
1129
+ if (repair.repairs.length === 0 || sameValue(repair.args, args))
1130
+ return asSent;
1131
+ const retried = parseRepaired(repair.args);
1132
+ if (retried?.success)
1133
+ return { success: true, data: retried.data, coerced: repair.kinds };
1134
+ if (repair.unaided) {
1135
+ const unaided = parseRepaired(repair.unaided.args);
1136
+ if (unaided?.success) {
1137
+ return { success: true, data: unaided.data, coerced: repair.unaided.kinds };
586
1138
  }
587
1139
  }
588
- return { success: false, error: parsed.error };
1140
+ if (!retried)
1141
+ return asSent;
1142
+ const held = heldRepairs(repair.repairs, retried.error.issues);
1143
+ if (held.length === repair.repairs.length) {
1144
+ return { success: false, args: repair.args, error: retried.error };
1145
+ }
1146
+ if (held.length === 0)
1147
+ return asSent;
1148
+ const reported = applyRepairs(args, held);
1149
+ const reparsed = reparse(def, reported);
1150
+ return !reparsed || reparsed.success
1151
+ ? asSent
1152
+ : { success: false, args: reported, error: reparsed.error };
1153
+ }
1154
+ /**
1155
+ * Parses repaired arguments, or `undefined` when the author's schema throws on
1156
+ * them: a check written for the values a valid call carries can throw on a
1157
+ * repaired one, such as an optional field's `undefined` once its `null` is
1158
+ * deleted.
1159
+ */
1160
+ function reparse(def, args) {
1161
+ try {
1162
+ return def.input.safeParse(args);
1163
+ }
1164
+ catch {
1165
+ return;
1166
+ }
589
1167
  }
590
1168
  /** Emits the winning attempt's telemetry and hands its arguments to the handler. */
591
1169
  function accept(def, attempt, parsed, context) {
@@ -945,61 +1523,24 @@ function failureSeverity(entry, failure, reason) {
945
1523
  return 'notice';
946
1524
  return typeof reason === 'string' && FRAMEWORK_REFUSAL_REASONS.has(reason) ? 'notice' : undefined;
947
1525
  }
948
- /** The most entries an array keeps in an argument rejection's log record (#631). */
949
- const LOGGED_ARRAY_ENTRIES = 10;
950
- /** Whether {@link boundedForLog} cuts `value` itself. */
951
- function cutForLog(value) {
952
- return typeof value === 'string'
953
- ? value.length > OBSERVABILITY_MAX_STRING_LENGTH
954
- : Array.isArray(value) && value.length > LOGGED_ARRAY_ENTRIES;
955
- }
956
- /**
957
- * `value` bounded for a log record: a string cut to its first
958
- * {@link OBSERVABILITY_MAX_STRING_LENGTH} characters, an array to its first
959
- * {@link LOGGED_ARRAY_ENTRIES} entries, and every nested value the same. An
960
- * object records each cut beside the field it cut — `<key>Length` for a string,
961
- * `<key>Count` for an array, and `<key>Lengths`, the uncut length of every
962
- * entry kept, when one of the array's own entries was cut — and gains nothing
963
- * where nothing was.
964
- */
965
- function boundedForLog(value) {
966
- if (typeof value === 'string')
967
- return capForObservability(value).value;
968
- if (Array.isArray(value))
969
- return value.slice(0, LOGGED_ARRAY_ENTRIES).map(boundedForLog);
970
- if (value === null || typeof value !== 'object')
971
- return value;
972
- const bounded = {};
973
- for (const [key, entry] of Object.entries(value)) {
974
- bounded[key] = boundedForLog(entry);
975
- if (typeof entry === 'string' && cutForLog(entry))
976
- bounded[`${key}Length`] = entry.length;
977
- if (!Array.isArray(entry))
978
- continue;
979
- if (cutForLog(entry))
980
- bounded[`${key}Count`] = entry.length;
981
- const kept = entry.slice(0, LOGGED_ARRAY_ENTRIES);
982
- if (kept.some(cutForLog)) {
983
- bounded[`${key}Lengths`] = kept.map((item) => typeof item === 'string' || Array.isArray(item) ? item.length : null);
984
- }
985
- }
986
- return bounded;
987
- }
988
1526
  /**
989
1527
  * The argument rejection its `Error in tool:<name>` record is written from
990
1528
  * (#631). The caller sets every length in it — a key's name, how many keys,
991
1529
  * how many issues — and the record is logged at `notice`, which the default
992
1530
  * level admits, so it is bounded like any other caller-supplied value: the
993
- * message and every string in `data` keep at most their first 1,024 characters and
994
- * every array its first 10 entries ({@link boundedForLog}), with
995
- * `originalMessageLength` beside a cut message. A rejection within the caps
996
- * logs the fields it always did. The `-32602` result is still built from the
997
- * rejection itself.
1531
+ * message and every string in `data` keep at most their first 1,024 characters,
1532
+ * and `data` is the {@link boundedProjection} the result's is, with
1533
+ * `originalMessageLength` beside a cut message. `data.issues` and `data.input`
1534
+ * arrive as projections already (#648) and pass through unchanged, the records
1535
+ * their cuts wrote included ({@link projections}); what the record cuts further
1536
+ * is the message and `recovery.hint`, which the `-32602` result carries as up
1537
+ * to 10 lines of up to 1,025 characters each. A rejection within the caps is
1538
+ * logged uncut.
998
1539
  */
999
1540
  function argumentRejectionForLog(rejection) {
1000
1541
  const { value: message, length } = capForObservability(rejection.message);
1001
1542
  return new McpError(rejection.code, message, {
1002
- ...boundedForLog(rejection.data),
1543
+ ...boundedProjection(rejection.data),
1003
1544
  ...(length !== undefined && { originalMessageLength: length }),
1004
1545
  });
1005
1546
  }