@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.
- package/AGENTS.md +4 -4
- package/CLAUDE.md +4 -4
- package/README.md +1 -1
- package/changelog/0.13.x/0.13.14.md +68 -0
- package/dist/core/app.d.ts +4 -3
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
- package/dist/mcp-server/prompts/prompt-registration.js +12 -5
- package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
- package/dist/mcp-server/prompts/utils/promptDefinition.d.ts +4 -1
- package/dist/mcp-server/prompts/utils/promptDefinition.d.ts.map +1 -1
- package/dist/mcp-server/prompts/utils/promptDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +309 -26
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.js +870 -106
- package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
- package/dist/mcp-server/tools/utils/schemaShape.d.ts +127 -5
- package/dist/mcp-server/tools/utils/schemaShape.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/schemaShape.js +432 -4
- package/dist/mcp-server/tools/utils/schemaShape.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts +7 -3
- package/dist/mcp-server/tools/utils/toolDefinition.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolDefinition.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +47 -23
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +777 -236
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +3 -2
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +3 -2
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/framework-skills/add-tool/SKILL.md +15 -7
- package/framework-skills/api-errors/SKILL.md +8 -8
- package/framework-skills/api-telemetry/SKILL.md +4 -4
- package/framework-skills/api-testing/SKILL.md +2 -2
- package/framework-skills/design-mcp-server/SKILL.md +3 -2
- package/framework-skills/field-test/SKILL.md +3 -2
- 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,
|
|
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
|
|
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
|
-
*
|
|
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
|
|
145
|
-
* missing-required hint,
|
|
146
|
-
* Zod's `invalid_value` issue names an expected
|
|
147
|
-
* omitted field and a wrong choice are otherwise
|
|
148
|
-
* it here keeps the caller's value in-process:
|
|
149
|
-
* the arriving *type* reach a rendered
|
|
150
|
-
* option, which would copy every rejected
|
|
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
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* is
|
|
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
|
|
157
|
-
|
|
158
|
-
return value === undefined ? ABSENT : value;
|
|
157
|
+
function argumentOf(rejected, path) {
|
|
158
|
+
return argumentAt(rejected.input, path, rejected.args, rejected.transforms);
|
|
159
159
|
}
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
/**
|
|
167
|
-
function
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
208
|
-
*
|
|
209
|
-
* branch's issues under the union's
|
|
210
|
-
*
|
|
211
|
-
* (`items.1.name: …`). Recursive, so a
|
|
212
|
-
* inside a list element, resolves the
|
|
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,
|
|
216
|
-
* the hint's restatements identical to the
|
|
217
|
-
*
|
|
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. `
|
|
601
|
+
* root. `absent` is the bit {@link renderIssueMessage} reads ({@link isAbsent}).
|
|
238
602
|
*/
|
|
239
|
-
function renderIssueLine({ issue, path },
|
|
240
|
-
const message = renderIssueMessage(issue,
|
|
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 `.
|
|
252
|
-
*
|
|
253
|
-
*
|
|
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
|
|
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 =
|
|
274
|
-
|
|
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. ${
|
|
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
|
-
* `
|
|
304
|
-
*
|
|
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
|
-
|
|
307
|
-
const detail =
|
|
308
|
-
.
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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,
|
|
437
|
-
if (
|
|
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
|
|
446
|
-
* the root schema (#445) — so the one failure a weaker
|
|
447
|
-
* carries the same next step every handler-thrown error
|
|
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
|
|
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
|
|
463
|
-
* the parse (#468) — `Validated query as targetQuery.` for a
|
|
464
|
-
* `Dropped undeclared key _max.` for an underscore-rule drop —
|
|
465
|
-
* name only the keys that were validated. Both are framework
|
|
466
|
-
* restatements, so a hint carrying one keeps its `Recovery:`
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
|
524
|
-
* integer sent for a string,
|
|
525
|
-
*
|
|
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
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
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 =
|
|
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
|
|
1058
|
+
let failed = { attempt: first, parsed };
|
|
554
1059
|
const retry = prevalidateAliasFirst(def, input, first, options.input);
|
|
555
1060
|
if (retry) {
|
|
556
|
-
const
|
|
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
|
-
|
|
1067
|
+
failed = { attempt: retry, parsed: retried };
|
|
560
1068
|
}
|
|
561
|
-
const { attempt, error } =
|
|
1069
|
+
const { attempt, parsed: { args, error }, } = failed;
|
|
562
1070
|
recordPrevalidation(def, attempt, options.context);
|
|
563
1071
|
const { report } = attempt;
|
|
564
|
-
|
|
565
|
-
|
|
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(
|
|
1077
|
+
...(report && { input: boundedProjection(report) }),
|
|
1078
|
+
recovery: { hint: buildArgumentRecoveryHint(lines, rejected, attempt) },
|
|
569
1079
|
});
|
|
570
1080
|
}
|
|
571
1081
|
/**
|
|
572
|
-
*
|
|
573
|
-
* re-parses, keeping the repair only if the schema then accepts it.
|
|
574
|
-
*
|
|
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
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
579
|
-
return
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
|
994
|
-
*
|
|
995
|
-
* `originalMessageLength` beside a cut message.
|
|
996
|
-
*
|
|
997
|
-
*
|
|
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
|
-
...
|
|
1543
|
+
...boundedProjection(rejection.data),
|
|
1003
1544
|
...(length !== undefined && { originalMessageLength: length }),
|
|
1004
1545
|
});
|
|
1005
1546
|
}
|