@bevel-software/platform-mcp-core 0.25.0 → 0.26.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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Is a connected tool's input schema valid JSON Schema?
3
+ *
4
+ * AI clients ask this question themselves, and when the answer is no they drop
5
+ * the tool WITHOUT SAYING SO: the tool is simply missing for the agent, with
6
+ * nothing in Hexis to look at. So Hexis asks it first, when a server's tools
7
+ * are loaded, and keeps such a tool off every agent surface with a marker the
8
+ * server's owner can read.
9
+ *
10
+ * The check is the JSON Schema META-SCHEMA (2020-12, the draft the MCP
11
+ * specification names), run by `ajv` — the same validator the MCP SDK and the
12
+ * clients use, so a tool is hidden on exactly the violations a client would
13
+ * also refuse, and the reason is quoted in the words the client reports. The
14
+ * three refusals this started from reproduce to the character:
15
+ *
16
+ * /properties/…/socialLinks/items/anyOf must be an array
17
+ * /properties/…/value/anyOf/0/required/0 must be a string
18
+ * /properties/…/value/properties/table/type must be equal to one of the allowed values
19
+ *
20
+ * One thing the meta-schema does not cover is checked beside it: a `pattern`
21
+ * (or a `patternProperties` key) that is not a compilable regular expression.
22
+ * The meta-schema says only that it is a string, while a client COMPILES it and
23
+ * drops the tool when that throws — see {@link regexDefect}.
24
+ *
25
+ * NOT a repair. Nothing here rewrites a schema — the proxy passes a connected
26
+ * server's schema through as sent, and an invalid one is reported, never fixed.
27
+ */
28
+ import Ajv2020 from 'ajv/dist/2020.js';
29
+ /**
30
+ * One validator for the process. `strict: false` keeps this to the
31
+ * meta-schema: ajv's strict mode objects to things no client refuses (an
32
+ * unknown keyword, a `required` naming an undeclared property), and hiding a
33
+ * tool that works is worse than listing one whose schema is merely unusual.
34
+ *
35
+ * `validateFormats: false` is the same rule applied to the META-schema's own
36
+ * `format` annotations (`$id` and `$ref` as `uri-reference`, `pattern` as
37
+ * `regex`). Switching them on is INERT, which is worth knowing before anyone
38
+ * reaches for it: `validateSchema` does not assert the meta-schema's formats
39
+ * at all, so every schema gets the same verdict with `validateFormats: true`
40
+ * as with it off — including `pattern: "["`, which ajv-formats' own `regex`
41
+ * function rejects when called directly. The switch changes configuration,
42
+ * not behaviour; `__tests__/schema-validity.test.ts` pins that.
43
+ *
44
+ * It would be the wrong thing to want in any case. An AI client never
45
+ * meta-validates the schema document: the MCP SDK's validator runs
46
+ * `{ strict: false, validateFormats: true, validateSchema: false }`
47
+ * (`validation/ajv-provider.js`) — formats on the INSTANCE, the schema
48
+ * document itself never checked against the meta-schema — so a malformed
49
+ * `$id` or `$ref` compiles there without complaint, and flagging one here
50
+ * would hide a tool every client accepts. The one URI-valued construct a
51
+ * client really refuses is a `$ref` it cannot RESOLVE (`can't resolve
52
+ * reference …` out of `compile`), and no format assertion catches that one
53
+ * either; none is ever offered, because `sanitizeInputSchema` replaces an
54
+ * unresolvable or non-local `$ref` with `{}` before the listing goes out.
55
+ * Each half is pinned where it belongs: what this check does NOT flag in
56
+ * `__tests__/schema-validity.test.ts`, and what the proxy offers in place of
57
+ * such a `$ref` in `__tests__/schema-pass-through.test.ts`.
58
+ *
59
+ * So the one format that does decide a client's verdict is `regex` — a client
60
+ * COMPILES a `pattern` — and because the meta-check misses it under either
61
+ * setting, it is checked directly, below.
62
+ */
63
+ const ajv = new Ajv2020({ strict: false, allErrors: false, validateFormats: false });
64
+ /**
65
+ * The first place `schema` is not valid JSON Schema, or `null` when it is.
66
+ *
67
+ * FAIL-OPEN: a schema the validator itself cannot process (an unknown
68
+ * `$schema` dialect, say) is reported as having no defect. A validator fault
69
+ * is not evidence against the tool, and the cost of being wrong here is one
70
+ * tool hidden from every agent.
71
+ */
72
+ export function inputSchemaDefect(schema) {
73
+ // MCP requires an OBJECT input schema. `true`/`false` are legal JSON Schema
74
+ // but not legal here, and an array or a string is neither. Only `undefined`
75
+ // is ABSENT: a server that advertises `"inputSchema": null` has declared one
76
+ // and declared it wrong, which is a thing its owner wants to hear about.
77
+ if (schema === undefined)
78
+ return null; // absent: `toListedTool` supplies `{}`
79
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
80
+ return { path: '/', reason: 'must be an object' };
81
+ }
82
+ let valid;
83
+ try {
84
+ valid = ajv.validateSchema(schema);
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ if (!valid) {
90
+ const first = ajv.errors?.[0];
91
+ if (!first)
92
+ return null;
93
+ return {
94
+ path: first.instancePath || '/',
95
+ reason: withArticle(first.message ?? 'is not valid JSON Schema'),
96
+ };
97
+ }
98
+ return regexDefect(schema);
99
+ }
100
+ /** JSON Schema keywords whose value is a schema, or a list of schemas. */
101
+ const SCHEMA_VALUED_KEYWORDS = [
102
+ 'additionalItems',
103
+ 'additionalProperties',
104
+ 'allOf',
105
+ 'anyOf',
106
+ 'contains',
107
+ 'contentSchema',
108
+ 'else',
109
+ 'if',
110
+ 'items',
111
+ 'not',
112
+ 'oneOf',
113
+ 'prefixItems',
114
+ 'propertyNames',
115
+ 'then',
116
+ 'unevaluatedItems',
117
+ 'unevaluatedProperties',
118
+ ];
119
+ /**
120
+ * Keywords whose value is a MAP of schemas: the keys are names, the values
121
+ * schemas. `$defs`/`definitions` are NOT walked as such: an entry there is a
122
+ * schema only once a reference reaches it (ajv compiles nothing else, and the
123
+ * listing the proxy serves drops the block and inlines only what a `$ref`
124
+ * reaches), so the walk follows the references instead — see
125
+ * {@link regexDefect}.
126
+ */
127
+ const SCHEMA_MAP_KEYWORDS = ['dependentSchemas', 'patternProperties', 'properties'];
128
+ /**
129
+ * A bound on the walk below, for a schema whose shape is the sender's choice.
130
+ * It caps the QUEUE, so neither depth nor breadth can make the check cost more
131
+ * than this many entries.
132
+ */
133
+ const MAX_REGEX_CHECK_NODES = 50_000;
134
+ /**
135
+ * The first regex-bearing keyword in `schema` that does not compile, or `null`.
136
+ *
137
+ * The meta-schema does NOT catch these: `{ "type": "string", "pattern": "[" }`
138
+ * is a perfectly well-formed schema document. But a client does not merely read
139
+ * a `pattern` — it COMPILES it (the MCP SDK's own validator is `ajv.compile`),
140
+ * so that schema throws there and the tool is dropped just as silently as one
141
+ * the meta-schema rejects. Hexis has to find it for the same reason it finds
142
+ * the others.
143
+ *
144
+ * Tested with the `u` flag, which is how ajv compiles a `pattern`
145
+ * (`unicodeRegExp`, on by default) — so the verdict matches the validator the
146
+ * clients actually run rather than a looser reading. The walk is iterative and
147
+ * only ever descends through keywords whose value IS a schema, so a tool's own
148
+ * field named `pattern` (or a `pattern` string inside a `const` value) is data
149
+ * and is left alone.
150
+ *
151
+ * A `$defs`/`definitions` entry is reached only through a local `$ref` (or
152
+ * `$dynamicRef`) that names it, and reported at ITS OWN path — the place in
153
+ * the schema the server sent, which is what its owner edits. An entry nothing
154
+ * references is never compiled by a client and never listed by the proxy, so
155
+ * a bad regex in it hides no tool anywhere and is not a defect here: this
156
+ * module's contract is "hidden on exactly what a client would refuse".
157
+ */
158
+ function regexDefect(schema) {
159
+ const queue = [{ node: schema, path: '' }];
160
+ // Each node once, by path: two references to one entry are one schema.
161
+ const seen = new Set(['']);
162
+ // The cap bounds what is ENQUEUED, not only what is dequeued: a wide schema
163
+ // reaches its limit by breadth rather than depth, and a queue entry costs a
164
+ // path string that the node it describes does not. Past the cap the check
165
+ // simply stops looking, which is the fail-open rule this whole file follows.
166
+ const enqueue = (node, path) => {
167
+ if (seen.has(path) || queue.length >= MAX_REGEX_CHECK_NODES)
168
+ return;
169
+ seen.add(path);
170
+ queue.push({ node, path });
171
+ };
172
+ for (let i = 0; i < queue.length; i += 1) {
173
+ const { node, path } = queue[i];
174
+ if (typeof node.pattern === 'string' && !compiles(node.pattern)) {
175
+ return { path: `${path}/pattern`, reason: 'must be a valid regular expression' };
176
+ }
177
+ for (const keyword of ['$ref', '$dynamicRef']) {
178
+ const reference = node[keyword];
179
+ if (typeof reference !== 'string' || !reference.startsWith('#/'))
180
+ continue;
181
+ const target = resolveLocalPointer(schema, reference);
182
+ if (isSchemaObject(target))
183
+ enqueue(target, reference.slice(1));
184
+ }
185
+ for (const keyword of SCHEMA_MAP_KEYWORDS) {
186
+ const map = node[keyword];
187
+ if (!isSchemaObject(map))
188
+ continue;
189
+ for (const [key, value] of Object.entries(map)) {
190
+ if (keyword === 'patternProperties' && !compiles(key)) {
191
+ return { path: `${path}/patternProperties/${pointerPart(key)}`, reason: 'must be a valid regular expression' };
192
+ }
193
+ if (isSchemaObject(value))
194
+ enqueue(value, `${path}/${keyword}/${pointerPart(key)}`);
195
+ }
196
+ }
197
+ for (const keyword of SCHEMA_VALUED_KEYWORDS) {
198
+ const value = node[keyword];
199
+ if (Array.isArray(value)) {
200
+ value.forEach((item, index) => {
201
+ if (isSchemaObject(item))
202
+ enqueue(item, `${path}/${keyword}/${index}`);
203
+ });
204
+ }
205
+ else if (isSchemaObject(value)) {
206
+ enqueue(value, `${path}/${keyword}`);
207
+ }
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+ function isSchemaObject(value) {
213
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
214
+ }
215
+ /** The node a local JSON Pointer (`#/$defs/x`) names in `root`, or undefined. */
216
+ function resolveLocalPointer(root, pointer) {
217
+ let node = root;
218
+ for (const part of pointer.slice(2).split('/')) {
219
+ if (!isSchemaObject(node) && !Array.isArray(node))
220
+ return undefined;
221
+ const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
222
+ // Own members only: `#/__proto__` must name nothing, not `Object.prototype`.
223
+ if (!Object.prototype.hasOwnProperty.call(node, key))
224
+ return undefined;
225
+ node = node[key];
226
+ }
227
+ return node;
228
+ }
229
+ function compiles(pattern) {
230
+ try {
231
+ new RegExp(pattern, 'u');
232
+ return true;
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ }
238
+ /** A JSON Pointer path segment: `~` and `/` in a name have to be escaped. */
239
+ function pointerPart(name) {
240
+ return name.replace(/~/g, '~0').replace(/\//g, '~1');
241
+ }
242
+ /**
243
+ * The sentence the tool's owner reads, wherever the marker is shown — the tool
244
+ * setup page and `list_tool_setup` say the same thing in the same words,
245
+ * because they are the same finding.
246
+ */
247
+ export function schemaDefectMarker(defect) {
248
+ return `Hidden from agents: its schema is invalid at ${defect.path} (${defect.reason}).`;
249
+ }
250
+ /** ajv says `must be string`; a sentence a person reads says `must be a string`. */
251
+ function withArticle(message) {
252
+ return message.replace(/^must be (array|boolean|integer|null|number|object|string)$/, (_full, type) => `must be a${/^[aeiou]/.test(type) ? 'n' : ''} ${type}`);
253
+ }
254
+ //# sourceMappingURL=schema-validity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema-validity.js","sourceRoot":"","sources":["../src/schema-validity.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-mcp-core",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "The transport-agnostic half of Bevel's MCP surface: UTCP manual registration, tool discovery, MCP listing/dispatch and the code-mode meta-tools. Shared by the hosted proxy and the local stdio server.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,6 +28,7 @@
28
28
  "@utcp/http": "^1.1.12",
29
29
  "@utcp/mcp": "^1.2.0",
30
30
  "@utcp/sdk": "^1.2.0",
31
+ "ajv": "^8.20.0",
31
32
  "zod": "^4.0.0"
32
33
  },
33
34
  "devDependencies": {
package/src/index.ts CHANGED
@@ -34,6 +34,12 @@ export {
34
34
  flattenDiscoveredTool,
35
35
  } from './proxied-tool.js';
36
36
 
37
+ export {
38
+ type SchemaDefect,
39
+ inputSchemaDefect,
40
+ schemaDefectMarker,
41
+ } from './schema-validity.js';
42
+
37
43
  export {
38
44
  describeToolFailure,
39
45
  withTransportDetail,
package/src/meta-tools.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  *
26
26
  * What a chain DOES, though — to a failure, to a large result, to an image —
27
27
  * is true of every call, and those rules are stated once, in the handshake
28
- * instructions and in the platform-managed agent guide, rather than on each
28
+ * instructions and in the platform's agent guide, rather than on each
29
29
  * tool they cover. So on a surface that has those rules the chain description
30
30
  * ends with the same pointer sentence every file tool ends with, composed
31
31
  * where the tool is served and the guide's configured name is known
@@ -77,6 +77,69 @@ export function toListedTool(tool: ProxiedTool): McpTool | null {
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * How deep {@link sanitizeInputSchema} descends before it stops walking and
82
+ * passes the remainder through untouched. Generous on purpose: a real schema
83
+ * costs two levels per nesting (the `properties` keyword, then the field name),
84
+ * and the whole reason for this constant is the call stack, not the schema.
85
+ */
86
+ const MAX_SANITIZE_DEPTH = 200;
87
+
88
+ /**
89
+ * How many nodes one schema's `$ref` inlining may produce before a `$ref`
90
+ * stops being expanded.
91
+ *
92
+ * The recursion guard tracks only the pointers on the ACTIVE path, which is
93
+ * what `$ref` recursion means — but it does not make expansion cheap. A
94
+ * schema whose references form a DAG rather than a tree (two `allOf` branches
95
+ * pointing at one `$defs` entry, that entry doing the same) expands its target
96
+ * once per branch, and that doubles per level: a few hundred bytes on the wire
97
+ * can ask `tools/list` for a reply no amount of memory will hold. Past this
98
+ * budget a `$ref` degrades to the same permissive `{}` a recursive one does,
99
+ * which stands at a schema position and leaves the result valid JSON Schema.
100
+ */
101
+ const MAX_INLINED_NODES = 20_000;
102
+
103
+ /**
104
+ * The stand-in for a subtree past {@link MAX_SANITIZE_DEPTH}: every plain
105
+ * object becomes `{}`, while arrays and scalars keep their shape. Iterative,
106
+ * because the whole reason for being here is that the recursion has to stop.
107
+ *
108
+ * `{}` is legal exactly where a SCHEMA stands, and an object this deep inside a
109
+ * schema is at a schema position. The positions the old cap destroyed were an
110
+ * `anyOf` LIST, a `required` entry and a `type` STRING — none of them an
111
+ * object, all of them handed back as they came. And because no object survives,
112
+ * nothing past the cap can carry a `$ref` left dangling by the `$defs` block
113
+ * this walk drops, or a `format` the Anthropic validator refuses.
114
+ */
115
+ function stripPastDepth(node: unknown): unknown {
116
+ if (!node || typeof node !== 'object') return node;
117
+ if (!Array.isArray(node)) return {};
118
+ const out: unknown[] = [];
119
+ const queue: Array<{ from: readonly unknown[]; to: unknown[] }> = [{ from: node, to: out }];
120
+ for (let i = 0; i < queue.length; i += 1) {
121
+ const { from, to } = queue[i];
122
+ for (const item of from) {
123
+ if (!item || typeof item !== 'object') to.push(item);
124
+ else if (!Array.isArray(item)) to.push({});
125
+ else {
126
+ const nested: unknown[] = [];
127
+ to.push(nested);
128
+ queue.push({ from: item, to: nested });
129
+ }
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Keywords whose value is INSTANCE DATA rather than a schema. A schema says
137
+ * what a value may be; these carry values themselves, so nothing in them is a
138
+ * construct for the sanitizer to touch — and nothing in them is a place where
139
+ * `{}` would mean "any value" either.
140
+ */
141
+ const DATA_VALUED_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']);
142
+
80
143
  /** JSON-Schema string `format` values the Anthropic tool validator accepts. */
81
144
  const SUPPORTED_SCHEMA_FORMATS = new Set([
82
145
  'date-time',
@@ -99,9 +162,28 @@ const SUPPORTED_SCHEMA_FORMATS = new Set([
99
162
  * - drop non-standard `format` values (OpenAPI's `int32`/`byte`/… — only the
100
163
  * JSON-Schema-standard formats above are accepted; `format` is advisory, so
101
164
  * dropping it doesn't change tool behavior).
102
- * Depth-bounded so a recursive schema degrades to a permissive `{}` node instead
103
- * of hanging or emitting the unsupported recursion; non-local/external refs
104
- * degrade the same way. Exported for direct testing.
165
+ * Everything else is passed through UNCHANGED, and both recursion guards are
166
+ * built so that hitting one cannot change a schema either:
167
+ *
168
+ * - a `$ref` that resolves back onto a schema we are already inlining is
169
+ * recursive, and inlining has no finite answer for it. It degrades to a
170
+ * permissive `{}` — which is legal, because a `$ref` only ever stands where
171
+ * a SCHEMA is expected and `{}` there means "any value". Non-local and
172
+ * unresolvable refs degrade the same way, and so does one past
173
+ * {@link MAX_INLINED_NODES}.
174
+ * - the depth cap keeps the rest of the subtree's shape, replacing only the
175
+ * objects in it with `{}` — see {@link stripPastDepth}. It cannot reach
176
+ * inside instance data, because the walk never descends into a
177
+ * {@link DATA_VALUED_KEYWORDS} value in the first place.
178
+ *
179
+ * That second rule is the fix for three tools AI clients silently dropped. The
180
+ * cap used to return `{}` at WHATEVER position it stopped at, and most
181
+ * positions in a schema are not schema positions: a tool nested deeper than
182
+ * the cap reached clients with `anyOf: {}`, `required: [{}]` or `type: {}` in
183
+ * it. None of those is valid JSON Schema, so the client refused the tool — and
184
+ * said so about a server that had sent a perfectly good schema.
185
+ *
186
+ * Exported for direct testing.
105
187
  */
106
188
  export function sanitizeInputSchema(schema: unknown): unknown {
107
189
  const root = schema;
@@ -111,30 +193,131 @@ export function sanitizeInputSchema(schema: unknown): unknown {
111
193
  for (const partRaw of pointer.slice(2).split('/')) {
112
194
  const part = partRaw.replace(/~1/g, '/').replace(/~0/g, '~');
113
195
  if (!node || typeof node !== 'object') return undefined;
196
+ // Own members only: `#/constructor` names nothing, not `Object`.
197
+ if (!Object.prototype.hasOwnProperty.call(node, part)) return undefined;
114
198
  node = (node as Record<string, unknown>)[part];
115
199
  }
116
200
  return node;
117
201
  };
118
- // `isPropertyMap` marks the value of `properties`/`patternProperties`: its
119
- // keys are the tool's OWN field names, not schema keywords, so a field
120
- // literally named `format`, `$ref` or `definitions` must survive untouched
121
- // (its VALUE is still a schema and is walked as one).
202
+ // The pointers currently being inlined, on THIS path. A `$ref` that points
203
+ // at a schema we are already inside is recursive: JSON Schema says that with
204
+ // the reference, and an inlined copy has no finite form.
205
+ const inlining = new Set<string>();
206
+ // Nodes charged to the expansion budget so far, against MAX_INLINED_NODES.
207
+ let inlined = 0;
208
+ // The size of each referenced target — objects, arrays, their entries and
209
+ // scalars, one count per pointer — stopped early past the budget, since
210
+ // past it the exact number no longer matters.
211
+ const costs = new Map<string, number>();
212
+ const costOf = (pointer: string): number => {
213
+ const known = costs.get(pointer);
214
+ if (known !== undefined) return known;
215
+ // Counted with a bounded frontier: children are pushed one at a time and
216
+ // only while the count is under the cap, so a target wider than the
217
+ // budget costs the cap in work and in memory, never its own width.
218
+ let count = 0;
219
+ const stack: unknown[] = [resolvePointer(pointer)];
220
+ const over = () => count + stack.length > MAX_INLINED_NODES;
221
+ while (stack.length > 0 && !over()) {
222
+ const item = stack.pop();
223
+ count += 1;
224
+ if (!item || typeof item !== 'object') continue;
225
+ if (Array.isArray(item)) {
226
+ for (let i = 0; i < item.length && !over(); i += 1) stack.push(item[i]);
227
+ } else {
228
+ for (const key in item as Record<string, unknown>) {
229
+ if (over()) break;
230
+ if (Object.prototype.hasOwnProperty.call(item, key)) stack.push((item as Record<string, unknown>)[key]);
231
+ }
232
+ }
233
+ }
234
+ const cost = over() ? MAX_INLINED_NODES + 1 : count;
235
+ costs.set(pointer, cost);
236
+ return cost;
237
+ };
238
+ // `isPropertyMap` marks the value of `properties`/`patternProperties`, and
239
+ // of `dependentSchemas`/`dependencies`: its keys are the tool's OWN field
240
+ // names, not schema keywords, so a field literally named `format`, `$ref`,
241
+ // `definitions` or `default` must survive untouched (its VALUE is still a
242
+ // schema and is walked as one).
122
243
  const walk = (node: unknown, depth: number, isPropertyMap = false): unknown => {
123
- if (depth > 20) return {}; // recursion/cycle guard — permissive fallback
124
- if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
244
+ // Stack guard, not a schema rule: `$ref` recursion is caught below, so
245
+ // nothing a server legitimately sends reaches this. Stopping must never
246
+ // make a schema INVALID — which is exactly what the old cap did, by
247
+ // returning `{}` at whatever position it had reached.
248
+ if (depth > MAX_SANITIZE_DEPTH) return stripPastDepth(node);
125
249
  if (!node || typeof node !== 'object') return node;
250
+ if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
126
251
  const obj = node as Record<string, unknown>;
127
- if (!isPropertyMap && typeof obj.$ref === 'string') {
128
- const target = resolvePointer(obj.$ref);
252
+ // `$dynamicRef` is a reference like `$ref` (2020-12's late-bound form):
253
+ // resolved the same way when it is a local pointer, degraded the same way
254
+ // when it is not — never left standing, because the `$defs` block it
255
+ // reaches into is dropped below and a dangling reference is what clients
256
+ // reject. Both at once is legal and both apply, so both are inlined, as
257
+ // the `allOf` they amount to.
258
+ const references = (['$ref', '$dynamicRef'] as const)
259
+ .map((keyword) => obj[keyword])
260
+ .filter((value): value is string => typeof value === 'string');
261
+ if (!isPropertyMap && references.length > 0) {
129
262
  // JSON Schema allows siblings next to $ref; keep them, target wins ties.
130
263
  const siblings: Record<string, unknown> = { ...obj };
131
264
  delete siblings.$ref;
132
- const resolved = walk(target ?? {}, depth + 1);
133
- return resolved && typeof resolved === 'object' && !Array.isArray(resolved)
134
- ? { ...siblings, ...(resolved as Record<string, unknown>) }
135
- : Object.keys(siblings).length
136
- ? siblings
137
- : resolved ?? {};
265
+ delete siblings.$dynamicRef;
266
+ // Sanitized ONCE, here, because every path below can return them: the
267
+ // siblings are schema keywords in their own right, and an unsupported
268
+ // `format` or a nested `$ref` left in them is precisely what this
269
+ // function exists to keep out of a listing. (`siblings` no longer holds
270
+ // a `$ref`, so this cannot re-enter this branch at this node; deeper
271
+ // ones terminate on `inlining` or on the depth cap.)
272
+ const kept = Object.keys(siblings).length ? (walk(siblings, depth + 1) as Record<string, unknown>) : null;
273
+ // Recursive, or past the expansion budget: `{}` ("any value") is the only
274
+ // finite answer, and a `$ref` stands at a schema position, so `{}` there
275
+ // is valid JSON Schema. The reference itself has to go — the `$defs`
276
+ // block it points into is dropped below, and a dangling `$ref` is what
277
+ // clients reject.
278
+ //
279
+ // The budget is charged AT THE REFERENCE, by the whole size of what it
280
+ // would copy — every object, array, array entry and scalar in the
281
+ // target, counted once per pointer — before a byte of it is copied. A
282
+ // target that does not fit the remaining budget is `{}` whole, never a
283
+ // partial copy: the only thing a copy can cost is what the reference
284
+ // multiplies, and that is the target's full size whatever shapes it is
285
+ // made of (a `required` list of ten thousand names as much as ten
286
+ // thousand properties).
287
+ const resolveOne = (pointer: string): unknown => {
288
+ if (inlining.has(pointer)) return {};
289
+ const cost = costOf(pointer);
290
+ if (inlined + cost > MAX_INLINED_NODES) return {};
291
+ inlined += cost;
292
+ inlining.add(pointer);
293
+ try {
294
+ return walk(resolvePointer(pointer) ?? {}, depth + 1);
295
+ } finally {
296
+ inlining.delete(pointer);
297
+ }
298
+ };
299
+ const asObject = (value: unknown): Record<string, unknown> =>
300
+ value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
301
+ if (references.length === 1) {
302
+ const resolved = resolveOne(references[0]!);
303
+ // An object target merges over the siblings; a boolean target is a
304
+ // schema in its own right (`false` rejects everything) and stands
305
+ // alone, or under `allOf` beside siblings; anything else a pointer
306
+ // can land on — a string, a number, an array — is not a schema and
307
+ // becomes `{}`, never the raw value.
308
+ if (typeof resolved === 'boolean') {
309
+ return kept ? { ...kept, allOf: [...(Array.isArray(kept.allOf) ? kept.allOf : []), resolved] } : resolved;
310
+ }
311
+ return { ...(kept ?? {}), ...asObject(resolved) };
312
+ }
313
+ // A boolean target is a schema too (`false` rejects everything) and is
314
+ // kept as it is; anything that is not an object schema is `{}`.
315
+ const targets = references.map((pointer) => {
316
+ const resolved = resolveOne(pointer);
317
+ return typeof resolved === 'boolean' ? resolved : asObject(resolved);
318
+ });
319
+ const allOf = Array.isArray(kept?.allOf) ? (kept!.allOf as unknown[]) : [];
320
+ return { ...(kept ?? {}), allOf: [...allOf, ...targets] };
138
321
  }
139
322
  const out: Record<string, unknown> = {};
140
323
  for (const [key, value] of Object.entries(obj)) {
@@ -143,12 +326,25 @@ export function sanitizeInputSchema(schema: unknown): unknown {
143
326
  continue;
144
327
  }
145
328
  if (key === '$defs' || key === 'definitions') continue; // inlined above
329
+ // Instance DATA, not a schema: handed back exactly as it came. A `default`
330
+ // or an `enum` entry that happens to carry a key named `format` or `$ref`
331
+ // is a value the tool expects, not a construct to rewrite — and since the
332
+ // walk never descends into one, the depth cap cannot reach inside it
333
+ // either.
334
+ if (DATA_VALUED_KEYWORDS.has(key)) {
335
+ out[key] = value;
336
+ continue;
337
+ }
146
338
  // Drop a non-standard `format` (OpenAPI `int32`/`byte`/…) — the validator
147
339
  // only allows the JSON-Schema-standard set; the annotation is non-load-bearing.
148
340
  if (key === 'format' && (typeof value !== 'string' || !SUPPORTED_SCHEMA_FORMATS.has(value))) {
149
341
  continue;
150
342
  }
151
- out[key] = walk(value, depth + 1, key === 'properties' || key === 'patternProperties');
343
+ out[key] = walk(
344
+ value,
345
+ depth + 1,
346
+ key === 'properties' || key === 'patternProperties' || key === 'dependentSchemas' || key === 'dependencies',
347
+ );
152
348
  }
153
349
  return out;
154
350
  };