@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +1 -1
- package/dist/meta-tools.js +1 -1
- package/dist/proxied-tool.d.ts +22 -3
- package/dist/proxied-tool.d.ts.map +1 -1
- package/dist/proxied-tool.js +221 -20
- package/dist/proxied-tool.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/package.json +2 -1
- package/src/index.ts +6 -0
- package/src/meta-tools.ts +1 -1
- package/src/proxied-tool.ts +214 -18
- package/src/schema-validity.ts +264 -0
|
@@ -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
|
+
}
|