@cyanheads/mcp-ts-core 0.13.12 → 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 (119) hide show
  1. package/AGENTS.md +9 -8
  2. package/CLAUDE.md +9 -8
  3. package/README.md +1 -1
  4. package/changelog/0.13.x/0.13.13.md +93 -0
  5. package/changelog/0.13.x/0.13.14.md +68 -0
  6. package/dist/core/app.d.ts +4 -3
  7. package/dist/core/app.d.ts.map +1 -1
  8. package/dist/core/app.js.map +1 -1
  9. package/dist/core/context.d.ts +12 -0
  10. package/dist/core/context.d.ts.map +1 -1
  11. package/dist/core/context.js +59 -14
  12. package/dist/core/context.js.map +1 -1
  13. package/dist/core/worker.d.ts.map +1 -1
  14. package/dist/core/worker.js +21 -8
  15. package/dist/core/worker.js.map +1 -1
  16. package/dist/mcp-server/inputRequired.d.ts +18 -9
  17. package/dist/mcp-server/inputRequired.d.ts.map +1 -1
  18. package/dist/mcp-server/inputRequired.js +29 -15
  19. package/dist/mcp-server/inputRequired.js.map +1 -1
  20. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  21. package/dist/mcp-server/prompts/prompt-registration.js +22 -12
  22. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  23. package/dist/mcp-server/prompts/utils/promptDefinition.d.ts +4 -1
  24. package/dist/mcp-server/prompts/utils/promptDefinition.d.ts.map +1 -1
  25. package/dist/mcp-server/prompts/utils/promptDefinition.js.map +1 -1
  26. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  27. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +22 -9
  28. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  29. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +309 -26
  30. package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
  31. package/dist/mcp-server/tools/utils/inputPrevalidation.js +870 -106
  32. package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
  33. package/dist/mcp-server/tools/utils/schemaShape.d.ts +127 -5
  34. package/dist/mcp-server/tools/utils/schemaShape.d.ts.map +1 -1
  35. package/dist/mcp-server/tools/utils/schemaShape.js +432 -4
  36. package/dist/mcp-server/tools/utils/schemaShape.js.map +1 -1
  37. package/dist/mcp-server/tools/utils/toolDefinition.d.ts +7 -3
  38. package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
  39. package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
  40. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +53 -23
  41. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  42. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +811 -255
  43. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  44. package/dist/mcp-server/transports/auth/lib/authUtils.d.ts +3 -1
  45. package/dist/mcp-server/transports/auth/lib/authUtils.d.ts.map +1 -1
  46. package/dist/mcp-server/transports/auth/lib/authUtils.js +4 -2
  47. package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
  48. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
  49. package/dist/storage/providers/fileSystem/fileSystemProvider.js +48 -21
  50. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  51. package/dist/types-global/errors.d.ts.map +1 -1
  52. package/dist/types-global/errors.js +31 -16
  53. package/dist/types-global/errors.js.map +1 -1
  54. package/dist/utils/internal/error-handler/errorHandler.d.ts +29 -12
  55. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  56. package/dist/utils/internal/error-handler/errorHandler.js +198 -100
  57. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  58. package/dist/utils/internal/error-handler/helpers.d.ts +75 -4
  59. package/dist/utils/internal/error-handler/helpers.d.ts.map +1 -1
  60. package/dist/utils/internal/error-handler/helpers.js +232 -33
  61. package/dist/utils/internal/error-handler/helpers.js.map +1 -1
  62. package/dist/utils/internal/error-handler/types.d.ts +17 -7
  63. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  64. package/dist/utils/internal/logValue.d.ts +33 -0
  65. package/dist/utils/internal/logValue.d.ts.map +1 -0
  66. package/dist/utils/internal/logValue.js +539 -0
  67. package/dist/utils/internal/logValue.js.map +1 -0
  68. package/dist/utils/internal/logger.d.ts +38 -9
  69. package/dist/utils/internal/logger.d.ts.map +1 -1
  70. package/dist/utils/internal/logger.js +228 -118
  71. package/dist/utils/internal/logger.js.map +1 -1
  72. package/dist/utils/internal/performance.d.ts.map +1 -1
  73. package/dist/utils/internal/performance.js +9 -11
  74. package/dist/utils/internal/performance.js.map +1 -1
  75. package/dist/utils/internal/requestContext.d.ts +3 -3
  76. package/dist/utils/internal/requestContext.js +1 -1
  77. package/dist/utils/network/fetchWithTimeout.d.ts +18 -10
  78. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  79. package/dist/utils/network/fetchWithTimeout.js +48 -20
  80. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  81. package/dist/utils/network/httpError.d.ts +8 -6
  82. package/dist/utils/network/httpError.d.ts.map +1 -1
  83. package/dist/utils/network/httpError.js +23 -7
  84. package/dist/utils/network/httpError.js.map +1 -1
  85. package/dist/utils/network/retry.d.ts +7 -4
  86. package/dist/utils/network/retry.d.ts.map +1 -1
  87. package/dist/utils/network/retry.js +24 -10
  88. package/dist/utils/network/retry.js.map +1 -1
  89. package/dist/utils/security/sanitization.d.ts +24 -28
  90. package/dist/utils/security/sanitization.d.ts.map +1 -1
  91. package/dist/utils/security/sanitization.js +25 -84
  92. package/dist/utils/security/sanitization.js.map +1 -1
  93. package/dist/utils/security/sensitiveFields.d.ts +32 -4
  94. package/dist/utils/security/sensitiveFields.d.ts.map +1 -1
  95. package/dist/utils/security/sensitiveFields.js +85 -4
  96. package/dist/utils/security/sensitiveFields.js.map +1 -1
  97. package/dist/utils/telemetry/attributes.d.ts +3 -2
  98. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  99. package/dist/utils/telemetry/attributes.js +3 -2
  100. package/dist/utils/telemetry/attributes.js.map +1 -1
  101. package/dist/utils/telemetry/trace.d.ts +3 -1
  102. package/dist/utils/telemetry/trace.d.ts.map +1 -1
  103. package/dist/utils/telemetry/trace.js +6 -6
  104. package/dist/utils/telemetry/trace.js.map +1 -1
  105. package/framework-skills/add-tool/SKILL.md +15 -7
  106. package/framework-skills/api-config/SKILL.md +2 -1
  107. package/framework-skills/api-context/SKILL.md +3 -3
  108. package/framework-skills/api-errors/SKILL.md +23 -21
  109. package/framework-skills/api-linter/SKILL.md +11 -10
  110. package/framework-skills/api-telemetry/SKILL.md +9 -7
  111. package/framework-skills/api-testing/SKILL.md +2 -2
  112. package/framework-skills/api-utils/SKILL.md +6 -6
  113. package/framework-skills/api-utils/references/security.md +4 -2
  114. package/framework-skills/design-mcp-server/SKILL.md +3 -2
  115. package/framework-skills/field-test/SKILL.md +3 -2
  116. package/package.json +8 -7
  117. package/scripts/check-framework-antipatterns.ts +3 -2
  118. package/templates/.env.example +2 -0
  119. package/templates/tests/tools/echo.tool.test.ts +19 -1
@@ -3,23 +3,24 @@
3
3
  * Constructs Context, checks inline auth, measures execution, formats response.
4
4
  * @module src/mcp-server/tools/utils/toolHandlerFactory
5
5
  */
6
- import { ZodError, z } from 'zod';
6
+ import { z } from 'zod';
7
7
  import { config } from '../../../config/index.js';
8
8
  import { readContentStore, readEnrichmentStore, resolveDeclaredFailure } from '../../../core/context.js';
9
9
  import { buildHandlerContext, handlerParentContext, resolveHandlerRequest, } from '../../handlerContext.js';
10
- import { CLIENT_CAPABILITY_MISSING_REASON, isInputRequiredSignal, sealThrown, } from '../../inputRequired.js';
10
+ import { CLIENT_CAPABILITY_MISSING_REASON, isInputRequiredSignal, sealSignal, } from '../../inputRequired.js';
11
11
  import { parseOutputContract } from '../../outputContract.js';
12
12
  import { isScopeRefusal, withRequiredScopes } from '../../transports/auth/lib/authUtils.js';
13
13
  import { internalError, JsonRpcErrorCode, McpError, } from '../../../types-global/errors.js';
14
14
  import { resolvePartialResultKeys } from '../../../utils/formatting/partialResult.js';
15
15
  import { asRequestCancelled, ErrorHandler } from '../../../utils/internal/error-handler/errorHandler.js';
16
+ import { isInstance, readErrorData, readWireErrorData, } from '../../../utils/internal/error-handler/helpers.js';
16
17
  import { capForObservability, OBSERVABILITY_MAX_STRING_LENGTH, } from '../../../utils/internal/observabilityCap.js';
17
18
  import { measureToolExecution, recordToolRejection } from '../../../utils/internal/performance.js';
18
19
  import { requestContextService, withExtra, } from '../../../utils/internal/requestContext.js';
19
20
  import { sanitization } from '../../../utils/security/sanitization.js';
20
21
  import { ATTR_MCP_TOOL_ENRICHED } from '../../../utils/telemetry/attributes.js';
21
- import { countCoerced, prevalidateAliasFirst, prevalidateToolArguments, recordPrevalidation, repairRepresentations, } from './inputPrevalidation.js';
22
- 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';
23
24
  // ---------------------------------------------------------------------------
24
25
  // Default formatter
25
26
  // ---------------------------------------------------------------------------
@@ -73,7 +74,7 @@ function extractRecoveryHint(data) {
73
74
  * `data` outside a request, as `runToolContract` builds it — leaving the text
74
75
  * as it was. The numeric `code` and `data.issues` stay JSON-only on purpose:
75
76
  * the code is the one envelope field a model cannot act on, and the message
76
- * already renders each issue as a sentence.
77
+ * already renders the issues as sentences.
77
78
  */
78
79
  function renderBranchableTerms(data) {
79
80
  const terms = [];
@@ -135,42 +136,92 @@ export function buildToolErrorResult(code, message, data) {
135
136
  }
136
137
  /** The framework-owned `data.reason` on every argument rejection (#445). */
137
138
  const INVALID_ARGUMENTS_REASON = 'invalid_arguments';
138
- /** What {@link readArgumentAt} returns when the raw arguments carry no value there. */
139
- const ABSENT = Symbol('absent');
140
139
  /**
141
- * 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.
142
142
  *
143
- * The one resolver behind #378's missing-vs-wrong rendering and #445's
144
- * missing-required hint, which ask the same question of the same arguments.
145
- * Zod's `invalid_value` issue names an expected set and nothing else, so an
146
- * omitted field and a wrong choice are otherwise indistinguishable. Resolving
147
- * it here keeps the caller's value in-process: only the absent/present bit and
148
- * the arriving *type* reach a rendered sentence, unlike Zod's `reportInput`
149
- * 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`.
150
151
  *
151
- * A key present with an explicit `null` is present — the caller supplied a
152
- * value, it was the wrong one. A key present with `undefined` is absent, which
153
- * 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.
154
156
  */
155
- function readArgumentAt(args, path) {
156
- const value = path.reduce(stepInto, args);
157
- return value === undefined ? ABSENT : value;
157
+ function argumentOf(rejected, path) {
158
+ return argumentAt(rejected.input, path, rejected.args, rejected.transforms);
158
159
  }
159
- /** The caller's value one step down, or `undefined` when nothing owns one there. */
160
- function stepInto(value, step) {
161
- return value !== null && typeof value === 'object' && Object.hasOwn(value, step)
162
- ? value[step]
163
- : 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() === '');
164
176
  }
165
- /** The accepted-value half of an `invalid_value` sentence, from the issue's own values. */
166
- function expectedValuesText(values) {
177
+ /** `"a"`, or `one of "a"|"b"` — the values an `invalid_value` sentence names. */
178
+ function acceptedValuesText(values) {
167
179
  const rendered = values.map((value) => JSON.stringify(value)).join('|');
168
- return values.length === 1 ? `Expected ${rendered}` : `Expected one of ${rendered}`;
180
+ return values.length === 1 ? rendered : `one of ${rendered}`;
169
181
  }
170
182
  /** The branch's only issue, when it has exactly one. */
171
183
  function onlyIssue(branch) {
172
184
  return branch.length === 1 ? branch[0] : undefined;
173
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
+ }
174
225
  /** Whether any of a branch's issues names a path below the branch's root. */
175
226
  function failsBelowRoot(branch) {
176
227
  return branch.some((issue) => issue.path.length > 0);
@@ -183,18 +234,28 @@ function failsBelowRoot(branch) {
183
234
  * `invalid_value`. That shape is the `z.literal('')` blank-field sentinel of
184
235
  * the form-client convention — never the branch that says what would have
185
236
  * been accepted. A one-entry `z.enum([...])`, which Zod reports identically,
186
- * 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.
187
245
  * - **#492** — once some branch fails below its root, drop every branch whose
188
246
  * only issue is a root `invalid_type`. In a one-or-many field
189
247
  * (`z.union([z.array(Item), Item])`) that branch merely says the value is
190
248
  * the other shape; the branch that failed inside the value is the one that
191
249
  * says what to change. When every branch fails at its root, none is dropped.
192
250
  */
193
- function selectUnionBranches(branches) {
194
- const selected = branches.filter((branch) => {
195
- const only = onlyIssue(branch);
196
- return !(only?.code === 'invalid_value' && only.values.length === 1);
197
- });
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
+ }
198
259
  if (!selected.some(failsBelowRoot))
199
260
  return selected;
200
261
  return selected.filter((branch) => {
@@ -203,42 +264,355 @@ function selectUnionBranches(branches) {
203
264
  });
204
265
  }
205
266
  /**
206
- * The issues a rejection renders, in order: Zod's list, except that a union
207
- * left with one selected branch that fails below its root is replaced by that
208
- * branch's issues under the union's path (#492) — so a one-or-many field
209
- * reports a list element's field error exactly as a list-only field does
210
- * (`items.1.name: …`). Recursive, so a one-or-many union nested in another, or
211
- * 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.
212
274
  *
213
275
  * The message ({@link formatInputValidationMessage}) and the hint
214
- * ({@link buildArgumentRecoveryHint}) both render from this list, which keeps
215
- * the hint's restatements identical to the message's lines. `data.issues` is
216
- * 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.
217
291
  */
218
- function renderedIssues(issues, prefix = []) {
292
+ function renderedIssues(issues, prefix = [], leftOut) {
219
293
  return issues.flatMap((issue) => {
220
294
  const path = [...prefix, ...issue.path];
221
295
  if (issue.code === 'invalid_union') {
222
- const [branch, ...others] = selectUnionBranches(issue.errors);
296
+ const [branch, ...others] = selectUnionBranches(issue.errors, leftOut && ((at) => leftOut([...path, ...at])));
223
297
  if (branch && others.length === 0 && failsBelowRoot(branch)) {
224
- return renderedIssues(branch, path);
298
+ return withRivals(renderedIssues(branch, path, leftOut), issue.errors, path);
225
299
  }
226
300
  }
227
301
  return [{ issue, path }];
228
302
  });
229
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
+ }
230
354
  /** `items.1.name` — the dotted form a path takes in the message and the hint. */
231
355
  function dottedPath(path) {
232
356
  return path.map(String).join('.');
233
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
+ }
234
599
  /**
235
600
  * One line of the rendered detail: `path: message`, or the bare message at the
236
- * root. `args` decides the absent/present bit {@link renderIssueMessage} reads.
601
+ * root. `absent` is the bit {@link renderIssueMessage} reads ({@link isAbsent}).
237
602
  */
238
- function renderIssueLine({ issue, path }, args) {
239
- const message = renderIssueMessage(issue, readArgumentAt(args, path) === ABSENT);
603
+ function renderIssueLine({ issue, path }, absent) {
604
+ const message = renderIssueMessage(issue, absent);
240
605
  return path.length > 0 ? `${dottedPath(path)}: ${message}` : message;
241
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
+ };
242
616
  /**
243
617
  * The readable half of one issue's rendered line.
244
618
  *
@@ -247,9 +621,14 @@ function renderIssueLine({ issue, path }, args) {
247
621
  * - **#417** — Zod reports a union whose every branch aborted as one
248
622
  * `invalid_union` issue whose own message is the placeholder `Invalid input`;
249
623
  * what would have been accepted lives on the nested branch issues. Render the
250
- * selected branches instead, joined by ` or `. (When exactly one branch
251
- * matched the base type and failed only a check, Zod returns that branch's
252
- * 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.)
253
632
  * - **#447** — an object branch's issues carry their own branch-relative path,
254
633
  * so {@link renderBranchIssue} prefixes each one with it and no alternative
255
634
  * goes unnamed. Issues *within* a branch join on `; ` rather than the `, `
@@ -263,20 +642,69 @@ function renderIssueLine({ issue, path }, args) {
263
642
  *
264
643
  * On a required union field both compose: the branch is selected first, then
265
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.
266
653
  */
267
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) {
268
669
  if (issue.code === 'invalid_union') {
269
- 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;
270
675
  if (branches.length === 0)
271
676
  return issue.message;
272
- const rendered = branches.map((branch) => branch.map((branchIssue) => renderBranchIssue(branchIssue, absent)).join('; '));
273
- 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 ');
274
689
  }
275
690
  if (absent && issue.code === 'invalid_value') {
276
- return `Missing required field. ${expectedValuesText(issue.values)}`;
691
+ return `Missing required field. Expected ${acceptedValuesText(issue.values)}`;
277
692
  }
278
693
  return issue.message;
279
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
+ }
280
708
  /**
281
709
  * One issue of a union branch, prefixed with the branch-relative path it names.
282
710
  *
@@ -299,14 +727,24 @@ function renderBranchIssue(issue, absent) {
299
727
  * rejection (see `deferInputValidation`) purely so it can also carry
300
728
  * `structuredContent.error`.
301
729
  *
302
- * `args` are the caller's raw arguments, read only through
303
- * {@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.
304
740
  */
305
- export function formatInputValidationMessage(toolName, error, args) {
306
- const detail = renderedIssues(error.issues)
307
- .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)))))
308
745
  .join(', ');
309
- 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}`}`;
310
748
  }
311
749
  /** `a number`, `an array` — the indefinite article a type name reads with. */
312
750
  function withArticle(typeName) {
@@ -336,67 +774,6 @@ function joinNames(names) {
336
774
  function rootPropertyNames(input) {
337
775
  return isZodObjectSchema(input) ? Object.keys(input.shape) : [];
338
776
  }
339
- /**
340
- * The `z.object()` a nested unknown-key issue sits in, found by walking its
341
- * rendered path down from `schema` beside the caller's own `value` — or
342
- * `undefined` when the path does not land on exactly one object (#566).
343
- *
344
- * Wrappers (`optional`, `nullable`, `default`, …), `pipe`, and `z.lazy()` are
345
- * looked through. A numeric step enters an array element or tuple item; a
346
- * string step, a property or a record value. A discriminated union follows the
347
- * variant the argument's own discriminator selects, as Zod did. A plain union
348
- * follows the one option under which the rest of the path still lands on an
349
- * object — the branch {@link renderedIssues} lifted under #492. Anything else,
350
- * an intersection or a union two options satisfy, resolves to nothing.
351
- */
352
- function objectSchemaAt(schema, path, value) {
353
- const def = zodDef(schema);
354
- if (!def)
355
- return undefined;
356
- if (def.type === 'lazy' && def.getter)
357
- return objectSchemaAt(def.getter(), path, value);
358
- if (def.type === 'pipe')
359
- return objectSchemaAt(def.in, path, value);
360
- if (def.innerType !== undefined)
361
- return objectSchemaAt(def.innerType, path, value);
362
- if (def.type === 'union')
363
- return unionOptionAt(def, path, value);
364
- const [step, ...rest] = path;
365
- if (step === undefined)
366
- return isZodObjectSchema(schema) ? schema : undefined;
367
- const next = stepInto(value, step);
368
- switch (def.type) {
369
- case 'object':
370
- return typeof step === 'string' && def.shape && Object.hasOwn(def.shape, step)
371
- ? objectSchemaAt(def.shape[step], rest, next)
372
- : undefined;
373
- case 'record':
374
- return objectSchemaAt(def.valueType, rest, next);
375
- case 'array':
376
- return typeof step === 'number' ? objectSchemaAt(def.element, rest, next) : undefined;
377
- case 'tuple':
378
- return typeof step === 'number'
379
- ? objectSchemaAt(def.items?.[step] ?? def.rest, rest, next)
380
- : undefined;
381
- default:
382
- return undefined;
383
- }
384
- }
385
- /** {@link objectSchemaAt} at a union: the one option the path resolves through. */
386
- function unionOptionAt(def, path, value) {
387
- const options = def.options ?? [];
388
- const { discriminator } = def;
389
- if (typeof discriminator === 'string') {
390
- const tag = stepInto(value, discriminator);
391
- const selected = options.find((option) => {
392
- const field = isZodObjectSchema(option) ? option.shape[discriminator] : undefined;
393
- return field?.safeParse(tag).success === true;
394
- });
395
- return selected === undefined ? undefined : objectSchemaAt(selected, path, value);
396
- }
397
- const resolved = options.flatMap((option) => objectSchemaAt(option, path, value) ?? []);
398
- return resolved.length === 1 ? resolved[0] : undefined;
399
- }
400
777
  /**
401
778
  * The unknown-key sentence for one `unrecognized_keys` issue.
402
779
  *
@@ -405,25 +782,40 @@ function unionOptionAt(def, path, value) {
405
782
  * accepts (#566) — the root list there would send the caller to move the key to
406
783
  * the root or rename it after a root field. Neither list is given when there is
407
784
  * none to give: a discriminated-union root, a nested object declaring no keys,
408
- * 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.
409
788
  */
410
- function unknownKeySentence(input, keys, path, args) {
789
+ function unknownKeySentence(rejected, keys, path) {
411
790
  const label = keys.length === 1 ? 'Unknown key' : 'Unknown keys';
412
791
  if (path.length === 0) {
413
- const accepted = rootPropertyNames(input);
792
+ const accepted = rootPropertyNames(rejected.input);
414
793
  return accepted.length > 0
415
794
  ? `${label} ${keys.join(', ')}. This tool accepts: ${accepted.join(', ')}.`
416
795
  : `${label} ${keys.join(', ')}.`;
417
796
  }
418
797
  const where = dottedPath(path);
419
798
  const named = keys.map((key) => `${where}.${key}`).join(', ');
420
- 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 ?? {});
421
801
  return accepted.length > 0
422
802
  ? `${label} ${named}. ${where} accepts: ${accepted.join(', ')}.`
423
803
  : `${label} ${named}.`;
424
804
  }
425
805
  /**
426
- * 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.
427
819
  *
428
820
  * `int` is the one expectation a JSON number fails by type — `.int()`,
429
821
  * `z.int()`, `z.int32()`, and `z.uint32()` all report it, while range and
@@ -432,52 +824,102 @@ function unknownKeySentence(input, keys, path, args) {
432
824
  * rather than the type names `int` and `number`, which the value already
433
825
  * satisfies (#499).
434
826
  */
435
- function wrongTypeSentence(subject, expected, arrived) {
436
- if (arrived === ABSENT)
827
+ function wrongTypeSentence(subject, expected, at) {
828
+ if (isAbsent(at))
437
829
  return `Send ${subject} as ${withArticle(expected)}.`;
830
+ const arrived = at.received;
438
831
  if (expected === 'int' && typeof arrived === 'number') {
439
832
  return `Send ${subject} as an integer, not a fractional number.`;
440
833
  }
441
834
  return `Send ${subject} as ${withArticle(expected)}, not ${arrivedTypeText(arrived)}.`;
442
835
  }
443
836
  /**
444
- * Synthesizes `data.recovery.hint` from the Zod issues, the raw arguments, and
445
- * the root schema (#445) — so the one failure a weaker model hits most often
446
- * carries the same next step every handler-thrown error does, instead of
447
- * 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.
448
841
  *
449
- * One sentence per {@link renderedIssues} entry, joined into a single hint,
842
+ * One sentence per {@link issueLines} entry, joined into a single hint,
450
843
  * except that every missing required field collapses into one `Provide …`
451
844
  * sentence at the first of their positions. An issue no bucket claims is
452
845
  * restated as its message line — `start: Must be …`, path included, so
453
846
  * identical constraints on different fields stay distinguishable (#493).
454
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
+ *
455
857
  * When every sentence is a restatement, the hint is the message's issue text
456
858
  * verbatim, which is what lets {@link buildToolErrorResult} drop the
457
859
  * `Recovery:` line (#459). When restatements share the hint with the
458
860
  * framework's own sentences, each is terminated so it cannot run into the next
459
861
  * one, and a sentence already stated is not repeated.
460
862
  *
461
- * `report` closes the hint with what the pre-validation step changed before
462
- * the parse (#468) — `Validated query as targetQuery.` for a rewritten key,
463
- * `Dropped undeclared key _max.` for an underscore-rule drop — since the issues
464
- * name only the keys that were validated. Both are framework sentences, never
465
- * 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.
466
884
  */
467
- function buildArgumentRecoveryHint(def, error, args, report) {
885
+ function buildArgumentRecoveryHint(lines, rejected, { collisions, report }) {
468
886
  const sentences = [];
469
887
  const restatements = [];
470
888
  const missing = [];
471
889
  let missingSlot = -1;
472
- 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)) {
473
907
  const { issue } = entry;
474
908
  const path = dottedPath(entry.path);
475
- const arrived = readArgumentAt(args, entry.path);
476
909
  if (issue.code === 'unrecognized_keys') {
477
- 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
+ }
478
918
  continue;
479
919
  }
480
- if (path.length > 0 && arrived === ABSENT) {
920
+ const at = argumentOf(rejected, entry.path);
921
+ const absent = isAbsent(at);
922
+ if (path.length > 0 && absent) {
481
923
  if (missingSlot < 0) {
482
924
  missingSlot = sentences.length;
483
925
  sentences.push('');
@@ -485,27 +927,39 @@ function buildArgumentRecoveryHint(def, error, args, report) {
485
927
  missing.push(path);
486
928
  continue;
487
929
  }
488
- if (issue.code === 'invalid_type') {
489
- 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)));
490
934
  continue;
491
935
  }
492
- const line = renderIssueLine(entry, args);
936
+ const rendered = renderIssueLine(entry, absent);
937
+ const line = cutLine(rendered);
493
938
  restatements.push(line);
494
- sentences.push(terminateSentence(line));
939
+ sentences.push(line === rendered ? terminateSentence(rendered) : line);
495
940
  }
941
+ const changed = [];
942
+ if (report)
943
+ nameCollisions(report.ignored, changed);
496
944
  if (report && report.aliased.length > 0) {
497
945
  const rewrites = report.aliased.map(({ alias, target }) => `${alias} as ${target}`);
498
- 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)}.`));
499
952
  }
500
- if (report && report.ignored.length > 0) {
501
- const label = report.ignored.length === 1 ? 'key' : 'keys';
502
- 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}`;
503
957
  }
504
- if (restatements.length === sentences.length)
505
- return restatements.join(', ');
506
958
  if (missingSlot >= 0)
507
- sentences[missingSlot] = `Provide ${joinNames(missing)}.`;
508
- 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(' ');
509
963
  }
510
964
  /**
511
965
  * Validates raw tool arguments against the definition's `input` schema, or
@@ -516,25 +970,60 @@ function buildArgumentRecoveryHint(def, error, args, report) {
516
970
  * synthesizes (#445). {@link buildToolErrorResult} mirrors that hint into
517
971
  * `content[]`, so it reaches format()-only clients with no extra work.
518
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
+ *
519
989
  * An ordered pre-validation step wraps the parse. Before it,
520
990
  * {@link prevalidateToolArguments} drops client-added keys (#453) and rewrites
521
991
  * key aliases (#452); after a failure — and only then —
522
- * {@link repairRepresentations} undoes a stringified array or object or an
523
- * integer sent for a string, and the arguments are parsed once more (#234,
524
- * #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.
525
1005
  * When that attempt still fails and its drop discarded a key,
526
1006
  * {@link prevalidateAliasFirst} reruns the stages alias-first and the same
527
1007
  * parse-then-repair runs on the result, kept only if it validates (#563).
528
1008
  * {@link recordPrevalidation} then counts and logs the attempt the handler
529
1009
  * receives, so a call the first attempt validates is untouched by the retry.
530
- * When nothing validates, the *original* rejection of the last attempt is
531
- * thrown — the retry's when it ran, since there every key the drop discarded
532
- * reached its target and the issues name what is wrong with the value it
533
- * carried — built from the arguments that produced it, identical to the one
534
- * the same call gets under `input: { coerce: false }`. It carries the rewrites
535
- * and underscore-rule drops that attempt made as `data.input`, and as
536
- * sentences closing the hint (#468); a call with neither gains no
537
- * `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.
538
1027
  *
539
1028
  * The single argument-rejection path. {@link createToolHandler} and the
540
1029
  * `runToolContract` test helper both route through it, so a test written to
@@ -546,45 +1035,135 @@ function buildArgumentRecoveryHint(def, error, args, report) {
546
1035
  */
547
1036
  export function parseToolArguments(def, input, options = {}) {
548
1037
  const first = prevalidateToolArguments(def, input, options.input);
549
- 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);
550
1056
  if (parsed.success)
551
1057
  return accept(def, first, parsed, options.context);
552
- let rejected = { attempt: first, error: parsed.error };
1058
+ let failed = { attempt: first, parsed };
553
1059
  const retry = prevalidateAliasFirst(def, input, first, options.input);
554
1060
  if (retry) {
555
- 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);
556
1065
  if (retried.success)
557
1066
  return accept(def, retry, retried, options.context);
558
- rejected = { attempt: retry, error: retried.error };
1067
+ failed = { attempt: retry, parsed: retried };
559
1068
  }
560
- const { attempt, error } = rejected;
1069
+ const { attempt, parsed: { args, error }, } = failed;
561
1070
  recordPrevalidation(def, attempt, options.context);
562
1071
  const { report } = attempt;
563
- throw new McpError(JsonRpcErrorCode.InvalidParams, formatInputValidationMessage(def.name, error, attempt.args), {
564
- 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),
565
1076
  reason: INVALID_ARGUMENTS_REASON,
566
- ...(report && { input: report }),
567
- recovery: { hint: buildArgumentRecoveryHint(def, error, attempt.args, report) },
1077
+ ...(report && { input: boundedProjection(report) }),
1078
+ recovery: { hint: buildArgumentRecoveryHint(lines, rejected, attempt) },
568
1079
  });
569
1080
  }
570
1081
  /**
571
- * Parses one attempt's arguments, and on failure repairs them once and
572
- * re-parses, keeping the repair only if the schema then accepts it. A failure
573
- * 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.
574
1110
  */
575
- function parseAttempt(def, args, options) {
576
- const parsed = def.input.safeParse(args);
577
- if (parsed.success)
578
- return { success: true, data: parsed.data, coerced: [] };
579
- if (options?.coerce !== false) {
580
- const repair = repairRepresentations(args, parsed.error.issues);
581
- if (repair.args !== args) {
582
- const retried = def.input.safeParse(repair.args);
583
- if (retried.success)
584
- 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 };
585
1138
  }
586
1139
  }
587
- 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
+ }
588
1167
  }
589
1168
  /** Emits the winning attempt's telemetry and hands its arguments to the handler. */
590
1169
  function accept(def, attempt, parsed, context) {
@@ -609,6 +1188,12 @@ function accept(def, attempt, parsed, context) {
609
1188
  * `runToolContract` passes none: it has no real request, so a thrown
610
1189
  * `data.requestId` is dropped rather than rendered as one.
611
1190
  *
1191
+ * Never throws on the thrown value (#697): its `code`, `message`, and `data`
1192
+ * are read as {@link ErrorHandler.classifyOnly} reads them, so one that cannot
1193
+ * be read is `InternalError`, `'[Unreadable]'`, or left out, and `data` is a
1194
+ * copy of the thrown one in which every field is one the wire carries
1195
+ * ({@link readWireErrorData}).
1196
+ *
612
1197
  * Use after invoking {@link ErrorHandler.handleError} for OTel/logging side
613
1198
  * effects — this helper does not log.
614
1199
  */
@@ -621,12 +1206,9 @@ export function classifyAndBuildToolErrorResult(error, requestId) {
621
1206
  const { requestId: _thrown, ...rest } = data;
622
1207
  return Object.keys(rest).length > 0 ? rest : undefined;
623
1208
  };
624
- if (error instanceof McpError) {
625
- return buildToolErrorResult(error.code, error.message, withRequestId(error.data));
626
- }
627
- const { code, message } = ErrorHandler.classifyOnly(error);
628
- const data = error instanceof ZodError ? { issues: error.issues } : undefined;
629
- return buildToolErrorResult(code, message, withRequestId(data));
1209
+ // `classifyOnly` returns only a `ZodError`'s issues as `data`; a thrown `McpError`'s is copied here.
1210
+ const { code, message, data } = ErrorHandler.classifyOnly(error);
1211
+ return buildToolErrorResult(code, message, withRequestId(readWireErrorData(error) ?? data));
630
1212
  }
631
1213
  // ---------------------------------------------------------------------------
632
1214
  // Success response shaping — enrichment merge + trailer
@@ -927,73 +1509,38 @@ const FRAMEWORK_REFUSAL_REASONS = new Set([
927
1509
  * reason, an entry with no severity, a missing auth context, a handler's own
928
1510
  * `forbidden()`, an upstream 403, an output-contract failure — keeps `error`.
929
1511
  * A cancellation takes `handleError`'s own `info` path whatever this returns.
1512
+ * The three framework refusals also log without a stack, whatever level this
1513
+ * resolves for them (#651).
930
1514
  *
931
1515
  * Resolved here rather than in `ErrorHandler`, which also serves services,
932
1516
  * prompts, and transports and knows neither the definition nor the schema gate.
1517
+ * `reason` is the failure's `data.reason`, as {@link readErrorData} reads it.
933
1518
  */
934
- function failureSeverity(entry, failure) {
1519
+ function failureSeverity(entry, failure, reason) {
935
1520
  if (entry?.severity !== undefined)
936
1521
  return entry.severity;
937
1522
  if (isScopeRefusal(failure))
938
1523
  return 'notice';
939
- const reason = failure instanceof McpError ? failure.data?.reason : undefined;
940
1524
  return typeof reason === 'string' && FRAMEWORK_REFUSAL_REASONS.has(reason) ? 'notice' : undefined;
941
1525
  }
942
- /** The most entries an array keeps in an argument rejection's log record (#631). */
943
- const LOGGED_ARRAY_ENTRIES = 10;
944
- /** Whether {@link boundedForLog} cuts `value` itself. */
945
- function cutForLog(value) {
946
- return typeof value === 'string'
947
- ? value.length > OBSERVABILITY_MAX_STRING_LENGTH
948
- : Array.isArray(value) && value.length > LOGGED_ARRAY_ENTRIES;
949
- }
950
- /**
951
- * `value` bounded for a log record: a string cut to its first
952
- * {@link OBSERVABILITY_MAX_STRING_LENGTH} characters, an array to its first
953
- * {@link LOGGED_ARRAY_ENTRIES} entries, and every nested value the same. An
954
- * object records each cut beside the field it cut — `<key>Length` for a string,
955
- * `<key>Count` for an array, and `<key>Lengths`, the uncut length of every
956
- * entry kept, when one of the array's own entries was cut — and gains nothing
957
- * where nothing was.
958
- */
959
- function boundedForLog(value) {
960
- if (typeof value === 'string')
961
- return capForObservability(value).value;
962
- if (Array.isArray(value))
963
- return value.slice(0, LOGGED_ARRAY_ENTRIES).map(boundedForLog);
964
- if (value === null || typeof value !== 'object')
965
- return value;
966
- const bounded = {};
967
- for (const [key, entry] of Object.entries(value)) {
968
- bounded[key] = boundedForLog(entry);
969
- if (typeof entry === 'string' && cutForLog(entry))
970
- bounded[`${key}Length`] = entry.length;
971
- if (!Array.isArray(entry))
972
- continue;
973
- if (cutForLog(entry))
974
- bounded[`${key}Count`] = entry.length;
975
- const kept = entry.slice(0, LOGGED_ARRAY_ENTRIES);
976
- if (kept.some(cutForLog)) {
977
- bounded[`${key}Lengths`] = kept.map((item) => typeof item === 'string' || Array.isArray(item) ? item.length : null);
978
- }
979
- }
980
- return bounded;
981
- }
982
1526
  /**
983
1527
  * The argument rejection its `Error in tool:<name>` record is written from
984
1528
  * (#631). The caller sets every length in it — a key's name, how many keys,
985
1529
  * how many issues — and the record is logged at `notice`, which the default
986
1530
  * level admits, so it is bounded like any other caller-supplied value: the
987
- * message and every string in `data` keep at most their first 1,024 characters and
988
- * every array its first 10 entries ({@link boundedForLog}), with
989
- * `originalMessageLength` beside a cut message. A rejection within the caps
990
- * logs the fields it always did. The `-32602` result is still built from the
991
- * 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.
992
1539
  */
993
1540
  function argumentRejectionForLog(rejection) {
994
1541
  const { value: message, length } = capForObservability(rejection.message);
995
1542
  return new McpError(rejection.code, message, {
996
- ...boundedForLog(rejection.data),
1543
+ ...boundedProjection(rejection.data),
997
1544
  ...(length !== undefined && { originalMessageLength: length }),
998
1545
  });
999
1546
  }
@@ -1067,8 +1614,11 @@ export function createToolHandler(def, services, notifiers, capabilities) {
1067
1614
  // `metrics.errorCode` and the span's error-code attribute are
1068
1615
  // derived from what leaves this callback (#421). An input-required
1069
1616
  // signal leaves with its `requestState` sealed when a key is
1070
- // configured, so a sealing failure is a failed call like any other.
1071
- throw asRequestCancelled(await sealThrown(error, services.requestState, serverContext), request.signal);
1617
+ // configured, so a sealing failure is a failed call like any other;
1618
+ // anything else leaves as itself, never awaited (#697).
1619
+ throw asRequestCancelled(isInputRequiredSignal(error)
1620
+ ? await sealSignal(error, services.requestState, serverContext)
1621
+ : error, request.signal);
1072
1622
  }
1073
1623
  }, { ...appContext, toolName: def.name }, validatedInput, () => {
1074
1624
  const store = ctx ? readEnrichmentStore(ctx) : undefined;
@@ -1092,18 +1642,24 @@ export function createToolHandler(def, services, notifiers, capabilities) {
1092
1642
  // #567). Filled before `handleError`, so the error record, the envelope,
1093
1643
  // and the failure-payload record carry the same hint.
1094
1644
  const { entry, failure } = resolveDeclaredFailure(def.errors, error);
1095
- const severity = failureSeverity(entry, failure);
1645
+ // Read once, through a copy: the failure may be a thrown value whose
1646
+ // `data` cannot be read (#697).
1647
+ const reason = readErrorData(failure)?.reason;
1648
+ const severity = failureSeverity(entry, failure, reason);
1096
1649
  // The schema gate's rejection, raised before the handler ran (#631).
1097
- const argumentRejection = !measured &&
1098
- failure instanceof McpError &&
1099
- failure.data?.reason === INVALID_ARGUMENTS_REASON;
1100
- // Neither refusal is a fault in this server, so a stack would name only
1101
- // the gate that refused it (#585, #631).
1650
+ const argumentRejection = !measured && isInstance(failure, McpError) && reason === INVALID_ARGUMENTS_REASON;
1651
+ // The capability gate's refusal, recognized by its reserved reason.
1652
+ const capabilityRefusal = reason === CLIENT_CAPABILITY_MISSING_REASON;
1653
+ // No framework refusal is a fault in this server, so a stack would name
1654
+ // only the gate that refused it and the line that called it — at
1655
+ // whatever level an `errors[]` entry declares (#585, #631, #651).
1102
1656
  ErrorHandler.handleError(argumentRejection ? argumentRejectionForLog(failure) : failure, {
1103
1657
  operation: `tool:${def.name}`,
1104
1658
  context: appContext,
1105
1659
  ...(severity !== undefined && { severity }),
1106
- ...((argumentRejection || isScopeRefusal(failure)) && { includeStack: false }),
1660
+ ...((argumentRejection || capabilityRefusal || isScopeRefusal(failure)) && {
1661
+ includeStack: false,
1662
+ }),
1107
1663
  });
1108
1664
  if (!measured)
1109
1665
  recordToolRejection(def.name, failure);