@bevel-software/platform-mcp-core 0.25.2 → 0.27.0

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 (56) hide show
  1. package/dist/call-guards.d.ts +47 -0
  2. package/dist/call-guards.d.ts.map +1 -0
  3. package/dist/call-guards.js +215 -0
  4. package/dist/call-guards.js.map +1 -0
  5. package/dist/dispatch.d.ts +5 -1
  6. package/dist/dispatch.d.ts.map +1 -1
  7. package/dist/dispatch.js +19 -2
  8. package/dist/dispatch.js.map +1 -1
  9. package/dist/get-has-no-body.d.ts +49 -0
  10. package/dist/get-has-no-body.d.ts.map +1 -0
  11. package/dist/get-has-no-body.js +104 -0
  12. package/dist/get-has-no-body.js.map +1 -0
  13. package/dist/index.d.ts +7 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +7 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/mcp-app.d.ts +118 -0
  18. package/dist/mcp-app.d.ts.map +1 -0
  19. package/dist/mcp-app.js +126 -0
  20. package/dist/mcp-app.js.map +1 -0
  21. package/dist/meta-tools.d.ts +1 -1
  22. package/dist/meta-tools.d.ts.map +1 -1
  23. package/dist/meta-tools.js +28 -6
  24. package/dist/meta-tools.js.map +1 -1
  25. package/dist/proxied-tool.d.ts +33 -3
  26. package/dist/proxied-tool.d.ts.map +1 -1
  27. package/dist/proxied-tool.js +234 -21
  28. package/dist/proxied-tool.js.map +1 -1
  29. package/dist/results.d.ts +20 -1
  30. package/dist/results.d.ts.map +1 -1
  31. package/dist/results.js +117 -3
  32. package/dist/results.js.map +1 -1
  33. package/dist/schema-validity.d.ts +50 -0
  34. package/dist/schema-validity.d.ts.map +1 -0
  35. package/dist/schema-validity.js +254 -0
  36. package/dist/schema-validity.js.map +1 -0
  37. package/dist/tool-interface.d.ts +137 -0
  38. package/dist/tool-interface.d.ts.map +1 -0
  39. package/dist/tool-interface.js +640 -0
  40. package/dist/tool-interface.js.map +1 -0
  41. package/dist/utcp-namespace.d.ts +14 -0
  42. package/dist/utcp-namespace.d.ts.map +1 -1
  43. package/dist/utcp-namespace.js +7 -2
  44. package/dist/utcp-namespace.js.map +1 -1
  45. package/package.json +2 -1
  46. package/src/call-guards.ts +237 -0
  47. package/src/dispatch.ts +18 -1
  48. package/src/get-has-no-body.ts +110 -0
  49. package/src/index.ts +45 -0
  50. package/src/mcp-app.ts +194 -0
  51. package/src/meta-tools.ts +31 -6
  52. package/src/proxied-tool.ts +237 -19
  53. package/src/results.ts +129 -3
  54. package/src/schema-validity.ts +264 -0
  55. package/src/tool-interface.ts +673 -0
  56. package/src/utcp-namespace.ts +7 -2
@@ -0,0 +1,673 @@
1
+ import { utcpNameToTsInterfaceName } from './code-mode-names.js';
2
+
3
+ /**
4
+ * A tool's INTERFACE, in the two forms an agent needs it: the one-line call
5
+ * example that opens every description, and the argument list a refusal shows
6
+ * when a call did not match.
7
+ *
8
+ * Both are derived from the tool's own input schema and nothing else. A
9
+ * hand-written example drifts from the schema the moment either changes, and a
10
+ * connected server's tools would have none at all — so there is one generator
11
+ * here, used by the platform's own tools, by a deployment's, and by every
12
+ * connected server's alike.
13
+ *
14
+ * Pure: no IO, no clock, no client. The surfaces that list tools call
15
+ * {@link withCallExample}; the argument check beside it (`call-guards.ts`)
16
+ * calls {@link argumentsDoNotMatchMessage}.
17
+ */
18
+
19
+ /** What every tool description starts with, on a line of its own. */
20
+ export const CALL_LINE_PREFIX = 'Call: ';
21
+
22
+ /** The `kind` an arguments-do-not-match refusal carries, for a client that branches on it. */
23
+ export const ARGUMENTS_DO_NOT_MATCH_KIND = 'arguments-do-not-match';
24
+
25
+ /**
26
+ * The sentence that leads the mismatches when every argument was wrapped in a
27
+ * `body` the tool does not have — the single most common wrong call, because
28
+ * the platform's own tools DO take their arguments that way and a connector
29
+ * tool's arguments are flat.
30
+ */
31
+ export const BODY_AT_TOP_LEVEL_LINE = 'This tool takes its arguments at the top level, not under "body".';
32
+
33
+ /**
34
+ * The mirror of {@link BODY_AT_TOP_LEVEL_LINE}: the same mistake the other way
35
+ * round, by an agent that learned the flat shape and used it on a tool whose
36
+ * arguments ride a `body` envelope. The platform's route-hosted tools take that
37
+ * envelope, and the arguments of a flat call reach their route as query
38
+ * parameters instead of a body — which is how the route tells this apart from a
39
+ * call that simply left everything out, and can say which it was.
40
+ */
41
+ export const ARGS_UNDER_BODY_LINE = 'This tool takes its arguments under "body", not at the top level.';
42
+
43
+ /**
44
+ * How deep the interface and the check go: the top level and one level below
45
+ * it. That is exactly far enough for the `{ body: { ... } }` envelope every
46
+ * platform tool wears, and keeps a refusal readable for a connected server's
47
+ * deeply nested schema instead of printing its whole tree.
48
+ */
49
+ const MAX_DEPTH = 2;
50
+
51
+ /**
52
+ * How many placeholders an array example holds at most. `minItems` comes from
53
+ * a schema someone else wrote; one declaring a million would otherwise have
54
+ * the listing allocate a million placeholders for one line of description.
55
+ * An example cut short is still a call that shows the shape, which is its job.
56
+ */
57
+ const EXAMPLE_ITEMS_MAX = 8;
58
+
59
+ /** An argument description is cut to this, so one verbose argument can't crowd out the rest. */
60
+ const DESCRIPTION_MAX = 160;
61
+
62
+ type Dict = Record<string, unknown>;
63
+
64
+ function isDict(value: unknown): value is Dict {
65
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ /** The JSON-Schema keywords this module cannot reason about; their presence switches checking off. */
69
+ const UNSUPPORTED_KEYWORDS = ['anyOf', 'oneOf', 'allOf', 'not', '$ref', 'if', 'then', 'else'] as const;
70
+
71
+ function unsupportedKeyword(schema: Dict): string | undefined {
72
+ return UNSUPPORTED_KEYWORDS.find((k) => schema[k] !== undefined);
73
+ }
74
+
75
+ /** The declared types of a (sub)schema as a list, or undefined when it declares none we know. */
76
+ function declaredTypes(schema: Dict): string[] | undefined {
77
+ const raw = schema.type;
78
+ const list = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw.filter((t) => typeof t === 'string') : [];
79
+ const known = (list as string[]).filter((t) =>
80
+ ['string', 'number', 'integer', 'boolean', 'array', 'object', 'null'].includes(t),
81
+ );
82
+ return known.length > 0 ? known : undefined;
83
+ }
84
+
85
+ /** The placeholder value a type takes in the call example. */
86
+ function placeholder(schema: Dict, depth: number): unknown {
87
+ // A value the schema fixes is shown as that value: `"..."` would be a call
88
+ // the check refuses.
89
+ if (schema.const !== undefined) return schema.const;
90
+ // The first member the rest of the schema admits, so a sibling constraint
91
+ // (`minLength`, a range) cannot make the example one the check refuses.
92
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
93
+ return schema.enum.find((option) => valueMismatch('', schema, option) === null) ?? schema.enum[0];
94
+ }
95
+ const type = declaredTypes(schema)?.[0];
96
+ if (type === 'number' || type === 'integer') return numberPlaceholder(schema, type === 'integer');
97
+ if (type === 'boolean') return true;
98
+ if (type === 'array') {
99
+ // An array that must not be empty is shown with one element, so the
100
+ // example satisfies its own schema (`tools_info` takes `tool_names`
101
+ // with at least one name). Otherwise empty, the shortest array that works.
102
+ const minItems = typeof schema.minItems === 'number' ? schema.minItems : 0;
103
+ if (minItems < 1) return [];
104
+ const items = isDict(schema.items) ? schema.items : {};
105
+ return Array.from({ length: Math.min(minItems, EXAMPLE_ITEMS_MAX) }, () => placeholder(items, depth));
106
+ }
107
+ if (type === 'object') {
108
+ // An object whose own required arguments are known is shown with them, so
109
+ // the `{ body: { branch, path } }` envelope is spelled out rather than
110
+ // handed over as an empty `{}` the agent has to guess the inside of. The
111
+ // check stops at the same depth (see `compileCheck`), so an example cut
112
+ // off here is still one the check accepts.
113
+ return depth < MAX_DEPTH ? exampleArguments(schema, depth + 1) : {};
114
+ }
115
+ // A value that must be null is shown as `null`: `"..."` would be a call the
116
+ // check refuses.
117
+ if (type === 'null') return null;
118
+ // A string long enough for its `minLength`: `"..."` fails a schema that
119
+ // wants more. (A `pattern` is not synthesised — a placeholder cannot be
120
+ // made to match an arbitrary expression — so the example stays `"..."`.)
121
+ if (type === 'string') return stringPlaceholder(schema);
122
+ // No declared type is shown as a string placeholder: the overwhelming
123
+ // majority of such arguments are strings, and a `"..."` reads as "put a
124
+ // value here" in a way `null` does not.
125
+ return '...';
126
+ }
127
+
128
+ /** `"..."`, padded with dots to the schema's `minLength` (capped: a bound is a bound, not a size). */
129
+ function stringPlaceholder(schema: Dict): string {
130
+ const minLength = typeof schema.minLength === 'number' && Number.isFinite(schema.minLength) ? schema.minLength : 0;
131
+ return '.'.repeat(Math.min(64, Math.max(3, Math.ceil(minLength))));
132
+ }
133
+
134
+ /**
135
+ * A number the schema's range admits: `0`, unless a bound rules it out —
136
+ * then the nearest value at the lower bound, the upper bound, or between the
137
+ * two, whichever satisfies EVERY bound (`exclusiveMinimum: 1, maximum: 1.5`
138
+ * gives `1.5` and `exclusiveMaximum: 1.5` in its place
139
+ * gives `1.25`, not a `2` the check would refuse). A range nothing satisfies
140
+ * keeps `0`: the schema is at fault, and no example can fix it.
141
+ */
142
+ function numberPlaceholder(schema: Dict, integer: boolean): number {
143
+ const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
144
+ const minimum = num(schema.minimum);
145
+ const maximum = num(schema.maximum);
146
+ const exclusiveMinimum = num(schema.exclusiveMinimum);
147
+ const exclusiveMaximum = num(schema.exclusiveMaximum);
148
+ const lows = [minimum, exclusiveMinimum].filter((v): v is number => v !== undefined);
149
+ const highs = [maximum, exclusiveMaximum].filter((v): v is number => v !== undefined);
150
+ const low = lows.length > 0 ? Math.max(...lows) : undefined;
151
+ const high = highs.length > 0 ? Math.min(...highs) : undefined;
152
+ const candidates = [
153
+ 0,
154
+ minimum,
155
+ exclusiveMinimum === undefined ? undefined : exclusiveMinimum + 1,
156
+ maximum,
157
+ exclusiveMaximum === undefined ? undefined : exclusiveMaximum - 1,
158
+ low !== undefined && high !== undefined ? (low + high) / 2 : undefined,
159
+ ].flatMap((c) => (c === undefined ? [] : integer ? [Math.ceil(c), Math.floor(c)] : [c]));
160
+ return candidates.find((c) => numericBoundBroken(schema, c) === null) ?? 0;
161
+ }
162
+
163
+ /**
164
+ * The arguments the call example passes: one placeholder per REQUIRED
165
+ * argument, in the order the schema requires them, and nothing else.
166
+ *
167
+ * This is the example as a VALUE, which is what makes the example testable —
168
+ * every tool the platform declares is checked with it against its own schema,
169
+ * so an example an agent copies is one the check accepts.
170
+ */
171
+ export function exampleArguments(inputs: unknown, depth = 1): Dict {
172
+ const schema = isDict(inputs) ? inputs : {};
173
+ const properties = isDict(schema.properties) ? schema.properties : {};
174
+ const required = Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === 'string') : [];
175
+ const args: Dict = {};
176
+ for (const name of required as string[]) {
177
+ const prop = properties[name];
178
+ // An own property by definition: a bare `args.__proto__ = …` would set
179
+ // the prototype and lose the argument.
180
+ Object.defineProperty(args, name, {
181
+ value: placeholder(isDict(prop) ? prop : {}, depth),
182
+ enumerable: true,
183
+ writable: true,
184
+ configurable: true,
185
+ });
186
+ }
187
+ return args;
188
+ }
189
+
190
+ /**
191
+ * A key as JavaScript source: bare when it is an identifier, quoted (and
192
+ * escaped) when it is not, e.g. `"odd-key"`. `__proto__` is computed
193
+ * (`["__proto__"]`): bare or quoted in an object literal it sets the prototype.
194
+ */
195
+ function renderKey(key: string): string {
196
+ if (key === '__proto__') return '["__proto__"]';
197
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
198
+ }
199
+
200
+ /**
201
+ * The placeholder tree as source an agent can paste: object keys bare, strings
202
+ * double-quoted, one space inside the braces. Every value here was produced by
203
+ * {@link placeholder}; strings and keys that are not identifiers are quoted as
204
+ * JSON, so a value taken from an `enum` is escaped like any other.
205
+ */
206
+ function render(value: unknown): string {
207
+ if (typeof value === 'string') return JSON.stringify(value);
208
+ if (Array.isArray(value)) return value.length === 0 ? '[]' : `[${value.map(render).join(', ')}]`;
209
+ if (isDict(value)) {
210
+ const entries = Object.entries(value).map(([k, v]) => `${renderKey(k)}: ${render(v)}`);
211
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`;
212
+ }
213
+ return String(value);
214
+ }
215
+
216
+ /**
217
+ * The call example for one tool: the namespace this connection exposes, the
218
+ * tool's name, and its required arguments with a placeholder each, in the shape
219
+ * the tool really takes.
220
+ *
221
+ * `utcpName` is the tool's registered UTCP name (`<manual>.<tool>`); the
222
+ * namespace and name come from the same mapping the chain runtime uses, so the
223
+ * example is literally callable inside `call_tool_chain`. Optional arguments
224
+ * are left out — the example is the shortest call that can work, not a catalog.
225
+ */
226
+ export function callExample(utcpName: string, inputs: unknown): string {
227
+ return `${utcpNameToTsInterfaceName(utcpName)}(${render(exampleArguments(inputs))})`;
228
+ }
229
+
230
+ /** The `Call:` line as it appears at the top of a description. */
231
+ export function callLine(utcpName: string, inputs: unknown): string {
232
+ return `${CALL_LINE_PREFIX}${callExample(utcpName, inputs)}`;
233
+ }
234
+
235
+ /**
236
+ * A description with its call example ahead of it. Idempotent: a description
237
+ * that already opens with a `Call:` line keeps the one it has, so a surface
238
+ * that lists the same tool through two layers cannot stack two examples.
239
+ */
240
+ export function withCallExample(description: string | undefined, utcpName: string, inputs: unknown): string {
241
+ const body = description ?? '';
242
+ if (body.startsWith(CALL_LINE_PREFIX)) return body;
243
+ return body === '' ? callLine(utcpName, inputs) : `${callLine(utcpName, inputs)}\n\n${body}`;
244
+ }
245
+
246
+ /**
247
+ * Split a description into its `Call:` line and the rest, so a caller that
248
+ * prepends text of its own (the knowledge-base tools' purpose prefix) can keep
249
+ * the example first — the line only does its job if it is the first thing read.
250
+ */
251
+ export function splitCallLine(description: string): { call: string | null; rest: string } {
252
+ if (!description.startsWith(CALL_LINE_PREFIX)) return { call: null, rest: description };
253
+ const end = description.indexOf('\n');
254
+ if (end < 0) return { call: description, rest: '' };
255
+ return { call: description.slice(0, end), rest: description.slice(end + 1).replace(/^\n+/, '') };
256
+ }
257
+
258
+ function shortDescription(schema: Dict): string {
259
+ const raw = typeof schema.description === 'string' ? schema.description.replace(/\s+/g, ' ').trim() : '';
260
+ if (raw.length <= DESCRIPTION_MAX) return raw;
261
+ return `${raw.slice(0, DESCRIPTION_MAX - 1)}…`;
262
+ }
263
+
264
+ function typeLabel(schema: Dict): string {
265
+ return declaredTypes(schema)?.join(' or ') ?? 'any';
266
+ }
267
+
268
+ /**
269
+ * The tool's interface as lines: every argument with its type, whether it is
270
+ * required, and its description — the top level, then one level below it,
271
+ * indented. This is what an agent needs in order to correct its call, and it
272
+ * is the schema's own content, never prose about it.
273
+ */
274
+ export function describeInterface(inputs: unknown, depth = 1, indent = ''): string[] {
275
+ const schema = isDict(inputs) ? inputs : {};
276
+ const properties = isDict(schema.properties) ? schema.properties : {};
277
+ const required = new Set(
278
+ (Array.isArray(schema.required) ? schema.required : []).filter((r): r is string => typeof r === 'string'),
279
+ );
280
+ const lines: string[] = [];
281
+ for (const [name, raw] of Object.entries(properties)) {
282
+ const prop = isDict(raw) ? raw : {};
283
+ const description = shortDescription(prop);
284
+ lines.push(
285
+ `${indent}${name} (${typeLabel(prop)}, ${required.has(name) ? 'required' : 'optional'})` +
286
+ (description === '' ? '' : ` — ${description}`),
287
+ );
288
+ if (depth < MAX_DEPTH && declaredTypes(prop)?.includes('object') && isDict(prop.properties)) {
289
+ lines.push(...describeInterface(prop, depth + 1, `${indent} `));
290
+ }
291
+ }
292
+ return lines;
293
+ }
294
+
295
+ /**
296
+ * The whole refusal: one sentence that the arguments do not match, the
297
+ * mismatches one per line, the interface, and the call example last — the order
298
+ * an agent reads it in, ending with the line it can copy.
299
+ */
300
+ export function argumentsDoNotMatchMessage(
301
+ toolName: string,
302
+ utcpName: string,
303
+ inputs: unknown,
304
+ mismatches: string[],
305
+ options: {
306
+ /**
307
+ * The schema the CALL EXAMPLE is generated from, when that is not the
308
+ * schema the arguments were checked against. A route-hosted tool is checked
309
+ * against its FLAT arguments — the ones its handler receives, and so the
310
+ * ones the mismatch lines and the interface name — while the example still
311
+ * has to show the `{ body: { … } }` envelope an agent actually types.
312
+ * Default: one schema for both.
313
+ */
314
+ exampleInputs?: unknown;
315
+ } = {},
316
+ ): string {
317
+ const interfaceLines = describeInterface(inputs);
318
+ return [
319
+ `The arguments do not match the "${toolName}" tool.`,
320
+ ...mismatches,
321
+ `Interface of "${toolName}":`,
322
+ ...(interfaceLines.length > 0 ? interfaceLines : ['(this tool takes no arguments)']),
323
+ callLine(utcpName, options.exampleInputs ?? inputs),
324
+ ].join('\n');
325
+ }
326
+
327
+ /**
328
+ * A compiled check for one input schema: either the rules to check a call
329
+ * against, or the reason this schema cannot be used for checking.
330
+ *
331
+ * Compiled once per distinct schema and kept (see {@link checkFor}), so the
332
+ * check costs a walk of the arguments and nothing else per call.
333
+ */
334
+ export type CompiledCheck =
335
+ | { checkable: false; reason: string }
336
+ | { checkable: true; check: (args: Dict) => string[] };
337
+
338
+ /** A value's JSON type, as the schema's vocabulary names it. */
339
+ function jsonTypeOf(value: unknown): string {
340
+ if (value === null) return 'null';
341
+ if (Array.isArray(value)) return 'array';
342
+ if (typeof value === 'number') return Number.isInteger(value) ? 'integer' : 'number';
343
+ if (typeof value === 'object') return 'object';
344
+ return typeof value;
345
+ }
346
+
347
+ function typeMatches(types: string[], value: unknown): boolean {
348
+ const actual = jsonTypeOf(value);
349
+ // An integer satisfies `number`, and any number satisfies `integer` only
350
+ // when it has no fractional part — which `jsonTypeOf` has already decided.
351
+ return types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
352
+ }
353
+
354
+ /**
355
+ * Compile one input schema into a check, or decide it cannot be checked.
356
+ *
357
+ * Deliberately conservative: anything this module cannot reason about with
358
+ * certainty — a combinator, a `$ref`, a schema that is not an object — switches
359
+ * the check OFF rather than guessing. A checker that refuses valid calls would
360
+ * take tools away from every agent at once, so every rule here is one a call
361
+ * cannot satisfy by any reading of the schema.
362
+ */
363
+ export function compileCheck(inputs: unknown, depth = 1): CompiledCheck {
364
+ if (!isDict(inputs)) return { checkable: false, reason: 'the input schema is not an object' };
365
+ const unsupported = unsupportedKeyword(inputs);
366
+ if (unsupported) return { checkable: false, reason: `the input schema uses "${unsupported}"` };
367
+ // A boolean subschema (`false`: nothing is valid; `true`: anything is) is a
368
+ // rule this module does not apply — read as `{}` it would pass what the
369
+ // schema forbids — so it switches the check off like a combinator does.
370
+ if (hasBooleanSubschema(inputs)) return { checkable: false, reason: 'the input schema uses a boolean subschema' };
371
+ const types = declaredTypes(inputs);
372
+ if (types && !types.includes('object')) {
373
+ return { checkable: false, reason: `the input schema declares type "${types.join(' or ')}", not an object` };
374
+ }
375
+ if (inputs.properties !== undefined && !isDict(inputs.properties)) {
376
+ return { checkable: false, reason: 'the input schema\'s "properties" is not an object' };
377
+ }
378
+ const properties = isDict(inputs.properties) ? inputs.properties : {};
379
+ const required = (Array.isArray(inputs.required) ? inputs.required : []).filter(
380
+ (r): r is string => typeof r === 'string',
381
+ );
382
+ const closed = inputs.additionalProperties === false;
383
+ const hasBodyProperty = Object.prototype.hasOwnProperty.call(properties, 'body');
384
+
385
+ // Sub-checks for the one level below the top: compiled here, with the rest,
386
+ // so a call pays nothing for them. No deeper than the call example goes
387
+ // (`MAX_DEPTH`): an example cut off at `{}` must be a call the check accepts.
388
+ const nested = new Map<string, CompiledCheck>();
389
+ if (depth < MAX_DEPTH) {
390
+ for (const [name, raw] of Object.entries(properties)) {
391
+ if (!isDict(raw) || !declaredTypes(raw)?.includes('object') || !isDict(raw.properties)) continue;
392
+ const inner = compileCheck(raw, depth + 1);
393
+ if (inner.checkable) nested.set(name, inner);
394
+ }
395
+ }
396
+
397
+ const check = (args: Dict): string[] => {
398
+ const mismatches: string[] = [];
399
+ // The `body` wrapper first: when every argument sits under a `body` key the
400
+ // tool does not have, THAT is what went wrong, and the lines below (a
401
+ // missing required argument, an argument the tool lacks) are its symptoms.
402
+ // Only when the wrapper IS wrong: an open schema with nothing required
403
+ // accepts `{ body: {} }` as it accepts any extra key.
404
+ const keys = Object.keys(args);
405
+ if (
406
+ !hasBodyProperty &&
407
+ (closed || required.length > 0) &&
408
+ keys.length === 1 &&
409
+ keys[0] === 'body' &&
410
+ isDict(args.body)
411
+ ) {
412
+ mismatches.push(BODY_AT_TOP_LEVEL_LINE);
413
+ }
414
+ // Own properties only, here and below: `constructor` or `toString` read
415
+ // through the prototype would otherwise count as given — or be checked
416
+ // as a value the caller never sent.
417
+ const given = (name: string): boolean => Object.prototype.hasOwnProperty.call(args, name);
418
+ for (const name of required) {
419
+ if (!given(name)) mismatches.push(`"${name}" is required, and was not given.`);
420
+ }
421
+ if (closed) {
422
+ for (const key of keys) {
423
+ if (!Object.prototype.hasOwnProperty.call(properties, key)) {
424
+ mismatches.push(`"${key}" is not an argument of this tool.`);
425
+ }
426
+ }
427
+ }
428
+ for (const [name, raw] of Object.entries(properties)) {
429
+ if (!given(name)) continue;
430
+ const value = args[name];
431
+ if (value === undefined) continue;
432
+ const prop = isDict(raw) ? raw : {};
433
+ const wrong = valueMismatch(name, prop, value);
434
+ if (wrong) {
435
+ mismatches.push(wrong);
436
+ continue; // a wrong value cannot also be walked for its own arguments
437
+ }
438
+ const inner = nested.get(name);
439
+ if (inner?.checkable && isDict(value)) {
440
+ mismatches.push(...inner.check(value).map((m) => qualify(name, m)));
441
+ }
442
+ }
443
+ return mismatches;
444
+ };
445
+ return { checkable: true, check };
446
+ }
447
+
448
+ /**
449
+ * What is wrong with one argument's VALUE, or `null`: its type first, then
450
+ * the constraints the schema puts on a value of that type — `enum`/`const`,
451
+ * a string's length and `pattern`, a number's range, an array's length and
452
+ * each of its `items`. A call that breaks a declared constraint does not match
453
+ * the schema any more than one of the wrong type does, and is refused the same
454
+ * way rather than reaching the tool.
455
+ *
456
+ * `format` is an annotation (JSON Schema does not require it to be asserted)
457
+ * and is not checked; neither is a keyword this module does not know. A
458
+ * subschema using a combinator is not ours to judge and passes as it is.
459
+ */
460
+ function valueMismatch(name: string, schema: Dict, value: unknown): string | null {
461
+ if (unsupportedKeyword(schema)) return null;
462
+ const expected = declaredTypes(schema);
463
+ if (expected && !typeMatches(expected, value)) {
464
+ return `"${name}" must be ${expected.join(' or ')}, but ${jsonTypeOf(value)} was given.`;
465
+ }
466
+ if (Array.isArray(schema.enum) && !schema.enum.some((option) => sameJson(option, value))) {
467
+ return `"${name}" must be one of ${schema.enum.map((o) => JSON.stringify(o)).join(', ')}, but ${shortJson(value)} was given.`;
468
+ }
469
+ if (schema.const !== undefined && !sameJson(schema.const, value)) {
470
+ return `"${name}" must be ${JSON.stringify(schema.const)}, but ${shortJson(value)} was given.`;
471
+ }
472
+ if (typeof value === 'string') {
473
+ // Length in code points, as JSON Schema counts it, not UTF-16 units.
474
+ const length = [...value].length;
475
+ if (typeof schema.minLength === 'number' && length < schema.minLength) {
476
+ return `"${name}" must be at least ${schema.minLength} character(s) long, but ${length} was given.`;
477
+ }
478
+ if (typeof schema.maxLength === 'number' && length > schema.maxLength) {
479
+ return `"${name}" must be at most ${schema.maxLength} character(s) long, but ${length} was given.`;
480
+ }
481
+ const pattern = typeof schema.pattern === 'string' ? compiledPattern(schema.pattern) : null;
482
+ if (pattern && !pattern.test(value)) {
483
+ return `"${name}" must match the pattern ${schema.pattern as string}, but ${shortJson(value)} was given.`;
484
+ }
485
+ }
486
+ if (typeof value === 'number') {
487
+ const bound = numericBoundBroken(schema, value);
488
+ if (bound) return `"${name}" must be ${bound}, but ${value} was given.`;
489
+ }
490
+ if (Array.isArray(value)) {
491
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
492
+ return `"${name}" must hold at least ${schema.minItems} item(s), but ${value.length} was given.`;
493
+ }
494
+ if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
495
+ return `"${name}" must hold at most ${schema.maxItems} item(s), but ${value.length} was given.`;
496
+ }
497
+ if (isDict(schema.items)) {
498
+ for (let i = 0; i < value.length; i++) {
499
+ const wrong = valueMismatch(`${name}[${i}]`, schema.items, value[i]);
500
+ if (wrong) return wrong; // the first bad item is enough to correct the call
501
+ }
502
+ }
503
+ }
504
+ return null;
505
+ }
506
+
507
+ /**
508
+ * Whether a property or item schema anywhere the check would look (`MAX_DEPTH`
509
+ * levels, items included) is a boolean rather than an object.
510
+ */
511
+ function hasBooleanSubschema(schema: Dict, depth = 1): boolean {
512
+ // `additionalProperties: false` is the ordinary way to close an object and
513
+ // is applied as such; a boolean PROPERTY or ITEM schema is the case here.
514
+ const subschemas: unknown[] = isDict(schema.properties) ? Object.values(schema.properties) : [];
515
+ if (schema.items !== undefined) subschemas.push(schema.items);
516
+ for (const sub of subschemas) {
517
+ if (typeof sub === 'boolean') return true;
518
+ if (isDict(sub) && depth < MAX_DEPTH && hasBooleanSubschema(sub, depth + 1)) return true;
519
+ }
520
+ return false;
521
+ }
522
+
523
+ /** The numeric bound `value` breaks, phrased for a refusal, or `null`. */
524
+ function numericBoundBroken(schema: Dict, value: number): string | null {
525
+ const { minimum, maximum, exclusiveMinimum, exclusiveMaximum } = schema;
526
+ if (typeof minimum === 'number' && value < minimum) return `at least ${minimum}`;
527
+ if (typeof maximum === 'number' && value > maximum) return `at most ${maximum}`;
528
+ if (typeof exclusiveMinimum === 'number' && value <= exclusiveMinimum) return `greater than ${exclusiveMinimum}`;
529
+ if (typeof exclusiveMaximum === 'number' && value >= exclusiveMaximum) return `less than ${exclusiveMaximum}`;
530
+ return null;
531
+ }
532
+
533
+ /**
534
+ * Equality as JSON sees it, for `enum` and `const`: arrays in order, objects
535
+ * by their properties whatever order their keys were written in.
536
+ */
537
+ function sameJson(a: unknown, b: unknown): boolean {
538
+ if (a === b) return true;
539
+ if (Array.isArray(a) || Array.isArray(b)) {
540
+ return (
541
+ Array.isArray(a) &&
542
+ Array.isArray(b) &&
543
+ a.length === b.length &&
544
+ a.every((value, index) => sameJson(value, b[index]))
545
+ );
546
+ }
547
+ if (!isDict(a) || !isDict(b)) return false;
548
+ const keys = Object.keys(a);
549
+ return (
550
+ keys.length === Object.keys(b).length &&
551
+ keys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && sameJson(a[key], b[key]))
552
+ );
553
+ }
554
+
555
+ /** A value as it appears in a refusal: JSON, cut short so one long argument cannot fill the message. */
556
+ function shortJson(value: unknown): string {
557
+ const text = JSON.stringify(value) ?? String(value);
558
+ return text.length <= 60 ? text : `${text.slice(0, 59)}…`;
559
+ }
560
+
561
+ /**
562
+ * Could this pattern backtrack catastrophically? True for a group that repeats
563
+ * (`*`, `+`, `{…}`) and itself contains a quantifier or an
564
+ * alternation — `(a+)+`, `(a|a)*`, `(\w+\s?)*` — and for a backreference.
565
+ *
566
+ * JavaScript's matcher backtracks, so such a pattern, which comes from a
567
+ * schema someone else wrote, could hold the event loop for seconds on one
568
+ * caller's string. The test is a conservative over-approximation (it flags
569
+ * some patterns that would in fact be fast); a flagged pattern is simply not
570
+ * asserted, which is what this module does with any keyword it cannot judge
571
+ * safely.
572
+ */
573
+ export function patternMayBacktrack(source: string): boolean {
574
+ if (/\\[1-9]|\\k</.test(source)) return true;
575
+ // One frame per open group: did anything inside it repeat or branch?
576
+ const stack: boolean[] = [];
577
+ let risky = false; // what the group that just closed held
578
+ let previousClosedGroup = false;
579
+ for (let i = 0; i < source.length; i++) {
580
+ const ch = source[i];
581
+ const afterGroup = previousClosedGroup;
582
+ previousClosedGroup = false;
583
+ if (ch === '\\') {
584
+ i++;
585
+ continue;
586
+ }
587
+ if (ch === '[') {
588
+ // A character class is one atom: skip to its closing bracket.
589
+ for (i++; i < source.length && source[i] !== ']'; i++) if (source[i] === '\\') i++;
590
+ continue;
591
+ }
592
+ if (ch === '(') {
593
+ stack.push(false);
594
+ if (source[i + 1] === '?') i++; // `(?:`, `(?=`, `(?<name>`: the `?` is syntax, not a quantifier
595
+ continue;
596
+ }
597
+ if (ch === ')') {
598
+ risky = stack.pop() ?? false;
599
+ // What a group holds, the group around it holds too.
600
+ if (risky && stack.length > 0) stack[stack.length - 1] = true;
601
+ previousClosedGroup = true;
602
+ continue;
603
+ }
604
+ const quantifier = ch === '*' || ch === '+' || ch === '?' || ch === '{';
605
+ if (quantifier || ch === '|') {
606
+ if (stack.length > 0) stack[stack.length - 1] = true;
607
+ // `?` repeats nothing; any `{…}` count is treated as a repeat, a long
608
+ // fixed count compounding the backtracking just as `+` does.
609
+ const repeats = ch === '*' || ch === '+' || ch === '{';
610
+ if (afterGroup && repeats && risky) return true;
611
+ }
612
+ if (ch === '{') {
613
+ const close = source.indexOf('}', i);
614
+ if (close > i) i = close;
615
+ }
616
+ }
617
+ return false;
618
+ }
619
+
620
+ /**
621
+ * A `pattern` compiled once. One this runtime cannot compile, or one that may
622
+ * backtrack catastrophically (see {@link patternMayBacktrack}), is not checked
623
+ * rather than refused.
624
+ */
625
+ const patterns = new Map<string, RegExp | null>();
626
+ /** How many compiled patterns are kept: past this the cache starts over, so a process listing ever-new schemas does not grow without bound. */
627
+ const PATTERN_CACHE_MAX = 512;
628
+ function compiledPattern(source: string): RegExp | null {
629
+ if (!patterns.has(source)) {
630
+ if (patterns.size >= PATTERN_CACHE_MAX) patterns.clear();
631
+ let re: RegExp | null = null;
632
+ if (patternMayBacktrack(source)) {
633
+ patterns.set(source, null);
634
+ return null;
635
+ }
636
+ try {
637
+ re = new RegExp(source, 'u');
638
+ } catch {
639
+ try {
640
+ re = new RegExp(source);
641
+ } catch {
642
+ re = null;
643
+ }
644
+ }
645
+ patterns.set(source, re);
646
+ }
647
+ return patterns.get(source) ?? null;
648
+ }
649
+
650
+ /** A nested mismatch, named by its path (`"body.path" is required…`). */
651
+ function qualify(parent: string, mismatch: string): string {
652
+ return mismatch.replace(/^"([^"]+)"/, (_m, name: string) => `"${parent}.${name}"`);
653
+ }
654
+
655
+ /**
656
+ * One compiled check per distinct input schema, kept for as long as the schema
657
+ * object lives. Both surfaces that check a call hand in the SAME schema object
658
+ * every time — the tool repository hands out tools whose `inputs` is the stored
659
+ * object, and a route's schema is the one its `toolDef` was given — so the
660
+ * check is compiled once per tool rather than once per call, and a call pays
661
+ * for a walk of its arguments and nothing else.
662
+ */
663
+ const compiled = new WeakMap<object, CompiledCheck>();
664
+
665
+ /** The compiled check for one input schema, compiled at most once per schema. */
666
+ export function checkFor(inputs: unknown): CompiledCheck {
667
+ if (typeof inputs !== 'object' || inputs === null) return compileCheck(inputs);
668
+ const hit = compiled.get(inputs);
669
+ if (hit) return hit;
670
+ const built = compileCheck(inputs);
671
+ compiled.set(inputs, built);
672
+ return built;
673
+ }