@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.
- package/dist/call-guards.d.ts +47 -0
- package/dist/call-guards.d.ts.map +1 -0
- package/dist/call-guards.js +215 -0
- package/dist/call-guards.js.map +1 -0
- package/dist/dispatch.d.ts +5 -1
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +19 -2
- package/dist/dispatch.js.map +1 -1
- package/dist/get-has-no-body.d.ts +49 -0
- package/dist/get-has-no-body.d.ts.map +1 -0
- package/dist/get-has-no-body.js +104 -0
- package/dist/get-has-no-body.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp-app.d.ts +118 -0
- package/dist/mcp-app.d.ts.map +1 -0
- package/dist/mcp-app.js +126 -0
- package/dist/mcp-app.js.map +1 -0
- package/dist/meta-tools.d.ts +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +28 -6
- package/dist/meta-tools.js.map +1 -1
- package/dist/proxied-tool.d.ts +33 -3
- package/dist/proxied-tool.d.ts.map +1 -1
- package/dist/proxied-tool.js +234 -21
- package/dist/proxied-tool.js.map +1 -1
- package/dist/results.d.ts +20 -1
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js +117 -3
- package/dist/results.js.map +1 -1
- package/dist/schema-validity.d.ts +50 -0
- package/dist/schema-validity.d.ts.map +1 -0
- package/dist/schema-validity.js +254 -0
- package/dist/schema-validity.js.map +1 -0
- package/dist/tool-interface.d.ts +137 -0
- package/dist/tool-interface.d.ts.map +1 -0
- package/dist/tool-interface.js +640 -0
- package/dist/tool-interface.js.map +1 -0
- package/dist/utcp-namespace.d.ts +14 -0
- package/dist/utcp-namespace.d.ts.map +1 -1
- package/dist/utcp-namespace.js +7 -2
- package/dist/utcp-namespace.js.map +1 -1
- package/package.json +2 -1
- package/src/call-guards.ts +237 -0
- package/src/dispatch.ts +18 -1
- package/src/get-has-no-body.ts +110 -0
- package/src/index.ts +45 -0
- package/src/mcp-app.ts +194 -0
- package/src/meta-tools.ts +31 -6
- package/src/proxied-tool.ts +237 -19
- package/src/results.ts +129 -3
- package/src/schema-validity.ts +264 -0
- package/src/tool-interface.ts +673 -0
- package/src/utcp-namespace.ts +7 -2
package/src/results.ts
CHANGED
|
@@ -119,7 +119,7 @@ export function describeToolFailure(err: unknown): string {
|
|
|
119
119
|
return inner;
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
if (typeof data === 'string' && data.length > 0) return data;
|
|
122
|
+
if (typeof data === 'string' && data.length > 0) return nonJsonFailure(err, data) ?? data;
|
|
123
123
|
// Total, like `safeJsonText`: a thrown value whose own `toString` throws
|
|
124
124
|
// (e.g. a null-prototype object) must still come back as a description —
|
|
125
125
|
// this function runs inside catch paths, where a second throw would turn
|
|
@@ -131,6 +131,105 @@ export function describeToolFailure(err: unknown): string {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** The `kind` an answer that was not JSON where JSON was expected carries. */
|
|
135
|
+
export const NOT_JSON_KIND = 'not-json';
|
|
136
|
+
|
|
137
|
+
/** How much of a non-JSON answer reaches the agent. The rest is a web page, not information. */
|
|
138
|
+
const NON_JSON_MAX = 200;
|
|
139
|
+
|
|
140
|
+
/** Collapse every run of whitespace, so a page's indentation doesn't fill the budget. */
|
|
141
|
+
function firstLine(text: string): string {
|
|
142
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
143
|
+
return collapsed.length > NON_JSON_MAX ? `${collapsed.slice(0, NON_JSON_MAX - 1)}…` : collapsed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function looksLikeJson(text: string): boolean {
|
|
147
|
+
try {
|
|
148
|
+
JSON.parse(text);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A SUCCESSFUL answer that is a web page rather than JSON, cut to its first
|
|
157
|
+
* {@link NON_JSON_MAX} characters — or `undefined` when the value is not a
|
|
158
|
+
* page. Used by the call guards, which know whether the tool answers over HTTP
|
|
159
|
+
* at all; a markdown file that happens to start with a tag is not a page, and
|
|
160
|
+
* only an http-family tool's bare string body can be one.
|
|
161
|
+
*/
|
|
162
|
+
export function pageInsteadOfJson(value: string): string | undefined {
|
|
163
|
+
if (!/^\s*<(!doctype|html|\?xml|head|body)\b/i.test(value)) return undefined;
|
|
164
|
+
return firstLine(value);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The short form of a failure whose body was not JSON: the status, the host
|
|
169
|
+
* that answered, and the first {@link NON_JSON_MAX} characters.
|
|
170
|
+
*
|
|
171
|
+
* A service's edge answers a refused request with its own HTML page, and that
|
|
172
|
+
* page used to reach the agent whole — thousands of characters of markup in
|
|
173
|
+
* place of a reason. The status and the first line of it say everything the
|
|
174
|
+
* agent can act on. A body that IS JSON keeps today's wording: it is the
|
|
175
|
+
* service's own message, however long, and cutting it would lose the reason.
|
|
176
|
+
*/
|
|
177
|
+
function nonJsonFailure(err: unknown, body: string): string | undefined {
|
|
178
|
+
if (looksLikeJson(body)) return undefined;
|
|
179
|
+
const status = readNumber(err, ['response', 'status']) ?? readNumber(err, ['status']);
|
|
180
|
+
const host = failureHost(err);
|
|
181
|
+
const where = [status === undefined ? '' : String(status), host === undefined ? '' : `from ${host}`]
|
|
182
|
+
.filter((p) => p !== '')
|
|
183
|
+
.join(' ');
|
|
184
|
+
const line = firstLine(body);
|
|
185
|
+
return where === '' ? line : `${where}: ${line}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function readNumber(source: unknown, path: string[]): number | undefined {
|
|
189
|
+
let node: unknown = source;
|
|
190
|
+
for (const key of path) {
|
|
191
|
+
if (node === null || typeof node !== 'object') return undefined;
|
|
192
|
+
try {
|
|
193
|
+
node = (node as Record<string, unknown>)[key];
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return typeof node === 'number' ? node : undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The host that answered, from wherever the transport left the request's URL. */
|
|
202
|
+
function failureHost(err: unknown): string | undefined {
|
|
203
|
+
for (const path of [
|
|
204
|
+
['response', 'config', 'url'],
|
|
205
|
+
['config', 'url'],
|
|
206
|
+
['response', 'url'],
|
|
207
|
+
['url'],
|
|
208
|
+
]) {
|
|
209
|
+
let node: unknown = err;
|
|
210
|
+
for (const key of path) {
|
|
211
|
+
if (node === null || typeof node !== 'object') {
|
|
212
|
+
node = undefined;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
node = (node as Record<string, unknown>)[key];
|
|
217
|
+
} catch {
|
|
218
|
+
node = undefined;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (typeof node === 'string' && node.length > 0) {
|
|
223
|
+
try {
|
|
224
|
+
return new URL(node).host;
|
|
225
|
+
} catch {
|
|
226
|
+
// not an absolute URL — nothing to name, try the next place
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
|
|
134
233
|
/**
|
|
135
234
|
* Does `message` already state `status` AS a status code?
|
|
136
235
|
*
|
|
@@ -319,7 +418,19 @@ function noteText(value: unknown): string | undefined {
|
|
|
319
418
|
return undefined;
|
|
320
419
|
}
|
|
321
420
|
|
|
322
|
-
export function toCallToolResult(
|
|
421
|
+
export function toCallToolResult(
|
|
422
|
+
value: unknown,
|
|
423
|
+
options?: {
|
|
424
|
+
/**
|
|
425
|
+
* Also answer a plain-object result as `structuredContent`. Asked for by
|
|
426
|
+
* a tool that carries an MCP App view: the view reads the result's fields
|
|
427
|
+
* from `structuredContent` (it has no other place to read them), while
|
|
428
|
+
* the model keeps reading the text block. Off for every other tool, so no
|
|
429
|
+
* client is handed each result twice.
|
|
430
|
+
*/
|
|
431
|
+
structured?: boolean;
|
|
432
|
+
},
|
|
433
|
+
): CallToolResult {
|
|
323
434
|
// An image sentinel (see McpImageResult): the tool's result IS a picture.
|
|
324
435
|
// Emit a native image content block so a multimodal client renders it, plus
|
|
325
436
|
// the note as a text block so the transcript stays self-describing.
|
|
@@ -374,7 +485,22 @@ export function toCallToolResult(value: unknown): CallToolResult {
|
|
|
374
485
|
return value as CallToolResult;
|
|
375
486
|
}
|
|
376
487
|
const text = typeof value === 'string' ? value : safeJsonText(value ?? null);
|
|
377
|
-
|
|
488
|
+
const result: CallToolResult = { content: [{ type: 'text', text: text || '(tool produced no output)' }] };
|
|
489
|
+
if (options?.structured && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
490
|
+
// The JSON-safe reading of the value, the same one the text block carries:
|
|
491
|
+
// the object itself may hold a BigInt, a cycle or a `toJSON` that throws,
|
|
492
|
+
// and the response serializer would fail on it where the text did not. A
|
|
493
|
+
// value with no JSON object reading carries no structured content.
|
|
494
|
+
try {
|
|
495
|
+
const structured: unknown = JSON.parse(text);
|
|
496
|
+
if (structured !== null && typeof structured === 'object' && !Array.isArray(structured)) {
|
|
497
|
+
result.structuredContent = structured as Record<string, unknown>;
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
/* text was not JSON: nothing structured to attach */
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
378
504
|
}
|
|
379
505
|
|
|
380
506
|
/**
|
|
@@ -0,0 +1,264 @@
|
|
|
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
|
+
|
|
29
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
30
|
+
|
|
31
|
+
/** Where a schema is not valid JSON Schema, and why. */
|
|
32
|
+
export interface SchemaDefect {
|
|
33
|
+
/** JSON Pointer into the schema, e.g. `/properties/value/required/0`. */
|
|
34
|
+
path: string;
|
|
35
|
+
/** The violation in the validator's own words, e.g. `must be a string`. */
|
|
36
|
+
reason: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One validator for the process. `strict: false` keeps this to the
|
|
41
|
+
* meta-schema: ajv's strict mode objects to things no client refuses (an
|
|
42
|
+
* unknown keyword, a `required` naming an undeclared property), and hiding a
|
|
43
|
+
* tool that works is worse than listing one whose schema is merely unusual.
|
|
44
|
+
*
|
|
45
|
+
* `validateFormats: false` is the same rule applied to the META-schema's own
|
|
46
|
+
* `format` annotations (`$id` and `$ref` as `uri-reference`, `pattern` as
|
|
47
|
+
* `regex`). Switching them on is INERT, which is worth knowing before anyone
|
|
48
|
+
* reaches for it: `validateSchema` does not assert the meta-schema's formats
|
|
49
|
+
* at all, so every schema gets the same verdict with `validateFormats: true`
|
|
50
|
+
* as with it off — including `pattern: "["`, which ajv-formats' own `regex`
|
|
51
|
+
* function rejects when called directly. The switch changes configuration,
|
|
52
|
+
* not behaviour; `__tests__/schema-validity.test.ts` pins that.
|
|
53
|
+
*
|
|
54
|
+
* It would be the wrong thing to want in any case. An AI client never
|
|
55
|
+
* meta-validates the schema document: the MCP SDK's validator runs
|
|
56
|
+
* `{ strict: false, validateFormats: true, validateSchema: false }`
|
|
57
|
+
* (`validation/ajv-provider.js`) — formats on the INSTANCE, the schema
|
|
58
|
+
* document itself never checked against the meta-schema — so a malformed
|
|
59
|
+
* `$id` or `$ref` compiles there without complaint, and flagging one here
|
|
60
|
+
* would hide a tool every client accepts. The one URI-valued construct a
|
|
61
|
+
* client really refuses is a `$ref` it cannot RESOLVE (`can't resolve
|
|
62
|
+
* reference …` out of `compile`), and no format assertion catches that one
|
|
63
|
+
* either; none is ever offered, because `sanitizeInputSchema` replaces an
|
|
64
|
+
* unresolvable or non-local `$ref` with `{}` before the listing goes out.
|
|
65
|
+
* Each half is pinned where it belongs: what this check does NOT flag in
|
|
66
|
+
* `__tests__/schema-validity.test.ts`, and what the proxy offers in place of
|
|
67
|
+
* such a `$ref` in `__tests__/schema-pass-through.test.ts`.
|
|
68
|
+
*
|
|
69
|
+
* So the one format that does decide a client's verdict is `regex` — a client
|
|
70
|
+
* COMPILES a `pattern` — and because the meta-check misses it under either
|
|
71
|
+
* setting, it is checked directly, below.
|
|
72
|
+
*/
|
|
73
|
+
const ajv = new Ajv2020({ strict: false, allErrors: false, validateFormats: false });
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The first place `schema` is not valid JSON Schema, or `null` when it is.
|
|
77
|
+
*
|
|
78
|
+
* FAIL-OPEN: a schema the validator itself cannot process (an unknown
|
|
79
|
+
* `$schema` dialect, say) is reported as having no defect. A validator fault
|
|
80
|
+
* is not evidence against the tool, and the cost of being wrong here is one
|
|
81
|
+
* tool hidden from every agent.
|
|
82
|
+
*/
|
|
83
|
+
export function inputSchemaDefect(schema: unknown): SchemaDefect | null {
|
|
84
|
+
// MCP requires an OBJECT input schema. `true`/`false` are legal JSON Schema
|
|
85
|
+
// but not legal here, and an array or a string is neither. Only `undefined`
|
|
86
|
+
// is ABSENT: a server that advertises `"inputSchema": null` has declared one
|
|
87
|
+
// and declared it wrong, which is a thing its owner wants to hear about.
|
|
88
|
+
if (schema === undefined) return null; // absent: `toListedTool` supplies `{}`
|
|
89
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
90
|
+
return { path: '/', reason: 'must be an object' };
|
|
91
|
+
}
|
|
92
|
+
let valid: boolean;
|
|
93
|
+
try {
|
|
94
|
+
valid = ajv.validateSchema(schema) as boolean;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (!valid) {
|
|
99
|
+
const first = ajv.errors?.[0];
|
|
100
|
+
if (!first) return null;
|
|
101
|
+
return {
|
|
102
|
+
path: first.instancePath || '/',
|
|
103
|
+
reason: withArticle(first.message ?? 'is not valid JSON Schema'),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return regexDefect(schema as Record<string, unknown>);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** JSON Schema keywords whose value is a schema, or a list of schemas. */
|
|
110
|
+
const SCHEMA_VALUED_KEYWORDS = [
|
|
111
|
+
'additionalItems',
|
|
112
|
+
'additionalProperties',
|
|
113
|
+
'allOf',
|
|
114
|
+
'anyOf',
|
|
115
|
+
'contains',
|
|
116
|
+
'contentSchema',
|
|
117
|
+
'else',
|
|
118
|
+
'if',
|
|
119
|
+
'items',
|
|
120
|
+
'not',
|
|
121
|
+
'oneOf',
|
|
122
|
+
'prefixItems',
|
|
123
|
+
'propertyNames',
|
|
124
|
+
'then',
|
|
125
|
+
'unevaluatedItems',
|
|
126
|
+
'unevaluatedProperties',
|
|
127
|
+
] as const;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Keywords whose value is a MAP of schemas: the keys are names, the values
|
|
131
|
+
* schemas. `$defs`/`definitions` are NOT walked as such: an entry there is a
|
|
132
|
+
* schema only once a reference reaches it (ajv compiles nothing else, and the
|
|
133
|
+
* listing the proxy serves drops the block and inlines only what a `$ref`
|
|
134
|
+
* reaches), so the walk follows the references instead — see
|
|
135
|
+
* {@link regexDefect}.
|
|
136
|
+
*/
|
|
137
|
+
const SCHEMA_MAP_KEYWORDS = ['dependentSchemas', 'patternProperties', 'properties'] as const;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A bound on the walk below, for a schema whose shape is the sender's choice.
|
|
141
|
+
* It caps the QUEUE, so neither depth nor breadth can make the check cost more
|
|
142
|
+
* than this many entries.
|
|
143
|
+
*/
|
|
144
|
+
const MAX_REGEX_CHECK_NODES = 50_000;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The first regex-bearing keyword in `schema` that does not compile, or `null`.
|
|
148
|
+
*
|
|
149
|
+
* The meta-schema does NOT catch these: `{ "type": "string", "pattern": "[" }`
|
|
150
|
+
* is a perfectly well-formed schema document. But a client does not merely read
|
|
151
|
+
* a `pattern` — it COMPILES it (the MCP SDK's own validator is `ajv.compile`),
|
|
152
|
+
* so that schema throws there and the tool is dropped just as silently as one
|
|
153
|
+
* the meta-schema rejects. Hexis has to find it for the same reason it finds
|
|
154
|
+
* the others.
|
|
155
|
+
*
|
|
156
|
+
* Tested with the `u` flag, which is how ajv compiles a `pattern`
|
|
157
|
+
* (`unicodeRegExp`, on by default) — so the verdict matches the validator the
|
|
158
|
+
* clients actually run rather than a looser reading. The walk is iterative and
|
|
159
|
+
* only ever descends through keywords whose value IS a schema, so a tool's own
|
|
160
|
+
* field named `pattern` (or a `pattern` string inside a `const` value) is data
|
|
161
|
+
* and is left alone.
|
|
162
|
+
*
|
|
163
|
+
* A `$defs`/`definitions` entry is reached only through a local `$ref` (or
|
|
164
|
+
* `$dynamicRef`) that names it, and reported at ITS OWN path — the place in
|
|
165
|
+
* the schema the server sent, which is what its owner edits. An entry nothing
|
|
166
|
+
* references is never compiled by a client and never listed by the proxy, so
|
|
167
|
+
* a bad regex in it hides no tool anywhere and is not a defect here: this
|
|
168
|
+
* module's contract is "hidden on exactly what a client would refuse".
|
|
169
|
+
*/
|
|
170
|
+
function regexDefect(schema: Record<string, unknown>): SchemaDefect | null {
|
|
171
|
+
const queue: Array<{ node: Record<string, unknown>; path: string }> = [{ node: schema, path: '' }];
|
|
172
|
+
// Each node once, by path: two references to one entry are one schema.
|
|
173
|
+
const seen = new Set<string>(['']);
|
|
174
|
+
// The cap bounds what is ENQUEUED, not only what is dequeued: a wide schema
|
|
175
|
+
// reaches its limit by breadth rather than depth, and a queue entry costs a
|
|
176
|
+
// path string that the node it describes does not. Past the cap the check
|
|
177
|
+
// simply stops looking, which is the fail-open rule this whole file follows.
|
|
178
|
+
const enqueue = (node: Record<string, unknown>, path: string) => {
|
|
179
|
+
if (seen.has(path) || queue.length >= MAX_REGEX_CHECK_NODES) return;
|
|
180
|
+
seen.add(path);
|
|
181
|
+
queue.push({ node, path });
|
|
182
|
+
};
|
|
183
|
+
for (let i = 0; i < queue.length; i += 1) {
|
|
184
|
+
const { node, path } = queue[i];
|
|
185
|
+
if (typeof node.pattern === 'string' && !compiles(node.pattern)) {
|
|
186
|
+
return { path: `${path}/pattern`, reason: 'must be a valid regular expression' };
|
|
187
|
+
}
|
|
188
|
+
for (const keyword of ['$ref', '$dynamicRef'] as const) {
|
|
189
|
+
const reference = node[keyword];
|
|
190
|
+
if (typeof reference !== 'string' || !reference.startsWith('#/')) continue;
|
|
191
|
+
const target = resolveLocalPointer(schema, reference);
|
|
192
|
+
if (isSchemaObject(target)) enqueue(target, reference.slice(1));
|
|
193
|
+
}
|
|
194
|
+
for (const keyword of SCHEMA_MAP_KEYWORDS) {
|
|
195
|
+
const map = node[keyword];
|
|
196
|
+
if (!isSchemaObject(map)) continue;
|
|
197
|
+
for (const [key, value] of Object.entries(map)) {
|
|
198
|
+
if (keyword === 'patternProperties' && !compiles(key)) {
|
|
199
|
+
return { path: `${path}/patternProperties/${pointerPart(key)}`, reason: 'must be a valid regular expression' };
|
|
200
|
+
}
|
|
201
|
+
if (isSchemaObject(value)) enqueue(value, `${path}/${keyword}/${pointerPart(key)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const keyword of SCHEMA_VALUED_KEYWORDS) {
|
|
205
|
+
const value = node[keyword];
|
|
206
|
+
if (Array.isArray(value)) {
|
|
207
|
+
value.forEach((item, index) => {
|
|
208
|
+
if (isSchemaObject(item)) enqueue(item, `${path}/${keyword}/${index}`);
|
|
209
|
+
});
|
|
210
|
+
} else if (isSchemaObject(value)) {
|
|
211
|
+
enqueue(value, `${path}/${keyword}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isSchemaObject(value: unknown): value is Record<string, unknown> {
|
|
219
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The node a local JSON Pointer (`#/$defs/x`) names in `root`, or undefined. */
|
|
223
|
+
function resolveLocalPointer(root: unknown, pointer: string): unknown {
|
|
224
|
+
let node: unknown = root;
|
|
225
|
+
for (const part of pointer.slice(2).split('/')) {
|
|
226
|
+
if (!isSchemaObject(node) && !Array.isArray(node)) return undefined;
|
|
227
|
+
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
228
|
+
// Own members only: `#/__proto__` must name nothing, not `Object.prototype`.
|
|
229
|
+
if (!Object.prototype.hasOwnProperty.call(node, key)) return undefined;
|
|
230
|
+
node = (node as Record<string, unknown>)[key];
|
|
231
|
+
}
|
|
232
|
+
return node;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function compiles(pattern: string): boolean {
|
|
236
|
+
try {
|
|
237
|
+
new RegExp(pattern, 'u');
|
|
238
|
+
return true;
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A JSON Pointer path segment: `~` and `/` in a name have to be escaped. */
|
|
245
|
+
function pointerPart(name: string): string {
|
|
246
|
+
return name.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The sentence the tool's owner reads, wherever the marker is shown — the tool
|
|
251
|
+
* setup page and `list_tool_setup` say the same thing in the same words,
|
|
252
|
+
* because they are the same finding.
|
|
253
|
+
*/
|
|
254
|
+
export function schemaDefectMarker(defect: SchemaDefect): string {
|
|
255
|
+
return `Hidden from agents: its schema is invalid at ${defect.path} (${defect.reason}).`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** ajv says `must be string`; a sentence a person reads says `must be a string`. */
|
|
259
|
+
function withArticle(message: string): string {
|
|
260
|
+
return message.replace(
|
|
261
|
+
/^must be (array|boolean|integer|null|number|object|string)$/,
|
|
262
|
+
(_full, type: string) => `must be a${/^[aeiou]/.test(type) ? 'n' : ''} ${type}`,
|
|
263
|
+
);
|
|
264
|
+
}
|