@nanobpm/urban 0.86.1 → 0.87.1
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/openapi/spec.d.ts +63 -5
- package/dist/openapi/spec.js +172 -3
- package/dist/runtime/core/modules/mcp.js +80 -25
- package/package.json +1 -1
- package/src/openapi/mcp-parity.fixture.yaml +73 -0
- package/src/openapi/mcp-tools.snapshot.json +81 -2
- package/src/openapi/spec.test.ts +221 -0
- package/src/openapi/spec.ts +204 -7
- package/src/runtime/core/modules/mcp.test.ts +153 -0
- package/src/runtime/core/modules/mcp.ts +90 -21
|
@@ -1,4 +1,44 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"operationId": "previewDeliveryGraph",
|
|
4
|
+
"method": "post",
|
|
5
|
+
"mutating": true,
|
|
6
|
+
"excluded": false,
|
|
7
|
+
"inputSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"body": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": [
|
|
13
|
+
"root"
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"root": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": [
|
|
19
|
+
"id"
|
|
20
|
+
],
|
|
21
|
+
"properties": {
|
|
22
|
+
"id": {
|
|
23
|
+
"type": "string"
|
|
24
|
+
},
|
|
25
|
+
"weight": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"minimum": 0
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"note": {
|
|
32
|
+
"type": "string"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"required": [
|
|
38
|
+
"body"
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
},
|
|
2
42
|
{
|
|
3
43
|
"operationId": "reindexWidgets",
|
|
4
44
|
"method": "post",
|
|
@@ -9,12 +49,51 @@
|
|
|
9
49
|
"operationId": "listWidgets",
|
|
10
50
|
"method": "get",
|
|
11
51
|
"mutating": false,
|
|
12
|
-
"excluded": false
|
|
52
|
+
"excluded": false,
|
|
53
|
+
"inputSchema": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": {}
|
|
56
|
+
}
|
|
13
57
|
},
|
|
14
58
|
{
|
|
15
59
|
"operationId": "createWidget",
|
|
16
60
|
"method": "post",
|
|
17
61
|
"mutating": true,
|
|
18
|
-
"excluded": false
|
|
62
|
+
"excluded": false,
|
|
63
|
+
"inputSchema": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"operationId": "bulkCreateWidgets",
|
|
70
|
+
"method": "post",
|
|
71
|
+
"mutating": true,
|
|
72
|
+
"excluded": false,
|
|
73
|
+
"inputSchema": {
|
|
74
|
+
"type": "object",
|
|
75
|
+
"properties": {
|
|
76
|
+
"body": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"required": [
|
|
79
|
+
"items"
|
|
80
|
+
],
|
|
81
|
+
"properties": {
|
|
82
|
+
"items": {
|
|
83
|
+
"type": "array",
|
|
84
|
+
"items": {
|
|
85
|
+
"type": "string"
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"dryRun": {
|
|
89
|
+
"type": "boolean"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
"required": [
|
|
95
|
+
"body"
|
|
96
|
+
]
|
|
97
|
+
}
|
|
19
98
|
}
|
|
20
99
|
]
|
package/src/openapi/spec.test.ts
CHANGED
|
@@ -2,7 +2,12 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import {
|
|
4
4
|
collectOperations,
|
|
5
|
+
collectMcpToolProjection,
|
|
6
|
+
diffMcpToolProjection,
|
|
5
7
|
evaluateSecurity,
|
|
8
|
+
findAllToolSchemaViolations,
|
|
9
|
+
findSchemaRefLeaks,
|
|
10
|
+
findToolSchemaViolations,
|
|
6
11
|
isObjectSchema,
|
|
7
12
|
isSafeOperationId,
|
|
8
13
|
isMutatingMethod,
|
|
@@ -12,10 +17,12 @@ import {
|
|
|
12
17
|
operationsWithUnsafeId,
|
|
13
18
|
parseSpec,
|
|
14
19
|
resolveSchema,
|
|
20
|
+
resolveSchemaDeep,
|
|
15
21
|
responseSchemaForStatus,
|
|
16
22
|
sharedRequestBodySchemas,
|
|
17
23
|
sharedSecretSchemeName,
|
|
18
24
|
sharedSecretSchemeNames,
|
|
25
|
+
toolInputSchema,
|
|
19
26
|
toRouteMatcher,
|
|
20
27
|
undeclaredPathParams,
|
|
21
28
|
undeclaredSecuritySchemes,
|
|
@@ -179,6 +186,220 @@ test("resolveSchema follows a local $ref and guards cycles", () => {
|
|
|
179
186
|
assert.equal(resolveSchema(cyclic, { $ref: "#/components/schemas/A" }), undefined);
|
|
180
187
|
});
|
|
181
188
|
|
|
189
|
+
test("resolveSchemaDeep inlines nested $refs into a self-contained, $ref-free schema", () => {
|
|
190
|
+
const nestedDoc: OpenApiDoc = {
|
|
191
|
+
openapi: "3.1.0",
|
|
192
|
+
components: {
|
|
193
|
+
schemas: {
|
|
194
|
+
Submit: {
|
|
195
|
+
type: "object",
|
|
196
|
+
required: ["root"],
|
|
197
|
+
properties: { root: { $ref: "#/components/schemas/Node" }, note: { type: "string" } },
|
|
198
|
+
},
|
|
199
|
+
Node: {
|
|
200
|
+
type: "object",
|
|
201
|
+
required: ["id"],
|
|
202
|
+
properties: { id: { type: "string" }, meta: { $ref: "#/components/schemas/Meta" } },
|
|
203
|
+
},
|
|
204
|
+
Meta: { type: "object", properties: { label: { type: "string" } } },
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
paths: {},
|
|
208
|
+
};
|
|
209
|
+
const resolved = resolveSchemaDeep(nestedDoc, { $ref: "#/components/schemas/Submit" });
|
|
210
|
+
// A bare `$ref` body with no inline `type` resolves to an explicit `type: object`, and the nested
|
|
211
|
+
// `Node` ref inside `root` (and the deeper `Meta` ref inside `Node`) are inlined too — no `$ref`
|
|
212
|
+
// survives at any depth.
|
|
213
|
+
assert.equal(resolved?.type, "object");
|
|
214
|
+
assert.ok(!JSON.stringify(resolved).includes("$ref"));
|
|
215
|
+
const root = resolved?.properties?.root;
|
|
216
|
+
assert.equal(root?.type, "object");
|
|
217
|
+
assert.equal(root?.properties?.id?.type, "string");
|
|
218
|
+
assert.equal(root?.properties?.meta?.type, "object");
|
|
219
|
+
assert.equal(root?.properties?.meta?.properties?.label?.type, "string");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("resolveSchemaDeep inlines a diamond (same component in sibling branches) without a false cycle", () => {
|
|
223
|
+
const diamond: OpenApiDoc = {
|
|
224
|
+
openapi: "3.1.0",
|
|
225
|
+
components: {
|
|
226
|
+
schemas: {
|
|
227
|
+
Pair: {
|
|
228
|
+
type: "object",
|
|
229
|
+
properties: { left: { $ref: "#/components/schemas/Leaf" }, right: { $ref: "#/components/schemas/Leaf" } },
|
|
230
|
+
},
|
|
231
|
+
Leaf: { type: "object", properties: { v: { type: "integer" } } },
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
paths: {},
|
|
235
|
+
};
|
|
236
|
+
const resolved = resolveSchemaDeep(diamond, { $ref: "#/components/schemas/Pair" });
|
|
237
|
+
assert.equal(resolved?.properties?.left?.properties?.v?.type, "integer");
|
|
238
|
+
assert.equal(resolved?.properties?.right?.properties?.v?.type, "integer");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("resolveSchemaDeep fails loudly on an irreducible cycle rather than leaking a $ref", () => {
|
|
242
|
+
const cyclic: OpenApiDoc = {
|
|
243
|
+
openapi: "3.1.0",
|
|
244
|
+
components: { schemas: { A: { type: "object", properties: { self: { $ref: "#/components/schemas/A" } } } } },
|
|
245
|
+
paths: {},
|
|
246
|
+
};
|
|
247
|
+
assert.throws(
|
|
248
|
+
() => resolveSchemaDeep(cyclic, { $ref: "#/components/schemas/A" }),
|
|
249
|
+
/\$ref cycle/,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("resolveSchemaDeep fails loudly on a dangling/non-local $ref rather than leaking it", () => {
|
|
254
|
+
assert.throws(
|
|
255
|
+
() => resolveSchemaDeep(doc, { $ref: "#/components/schemas/Missing" }),
|
|
256
|
+
/does not resolve to a local component/,
|
|
257
|
+
);
|
|
258
|
+
assert.throws(
|
|
259
|
+
() => resolveSchemaDeep(doc, { $ref: "https://example.com/external.json#/X" }),
|
|
260
|
+
/does not resolve to a local component/,
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("collectOperations exposes the resolved request-body schema as operation metadata", () => {
|
|
265
|
+
// Downstream consumers (ADR 0067 P1) read the resolved body type from `requestBodySchemaResolved`;
|
|
266
|
+
// the raw `requestBodySchema` keeps its `$ref` for the type deriver + shared-ref fingerprinting.
|
|
267
|
+
const create = collectOperations(doc).find((o) => o.operationId === "createInvoice");
|
|
268
|
+
assert.equal(create?.requestBodySchema?.$ref, "#/components/schemas/Invoice");
|
|
269
|
+
assert.equal(create?.requestBodySchemaResolved?.type, "object");
|
|
270
|
+
assert.ok(!JSON.stringify(create?.requestBodySchemaResolved).includes("$ref"));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("toolInputSchema resolves a $ref body to a self-contained typed schema", () => {
|
|
274
|
+
const create = collectOperations(doc).find((o) => o.operationId === "createInvoice");
|
|
275
|
+
assert.ok(create);
|
|
276
|
+
const schema = toolInputSchema(create);
|
|
277
|
+
assert.ok(!JSON.stringify(schema).includes("$ref"));
|
|
278
|
+
assert.deepEqual(schema, {
|
|
279
|
+
type: "object",
|
|
280
|
+
properties: {
|
|
281
|
+
body: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
|
|
284
|
+
required: ["id", "amount"],
|
|
285
|
+
additionalProperties: false,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
required: ["body"],
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("findSchemaRefLeaks reports every surviving $ref path, empty when self-contained", () => {
|
|
293
|
+
assert.deepEqual(findSchemaRefLeaks({ type: "object", properties: { a: { type: "string" } } }), []);
|
|
294
|
+
assert.deepEqual(
|
|
295
|
+
findSchemaRefLeaks({ properties: { body: { $ref: "#/components/schemas/X" } } }),
|
|
296
|
+
["properties.body"],
|
|
297
|
+
);
|
|
298
|
+
// A property literally named `$ref` whose value is NOT a string is an ordinary schema key, not a
|
|
299
|
+
// JSON-Schema reference — it must not be reported as a leak, and real leaks nested beneath it are
|
|
300
|
+
// still caught.
|
|
301
|
+
assert.deepEqual(
|
|
302
|
+
findSchemaRefLeaks({ type: "object", properties: { $ref: { type: "string" } } }),
|
|
303
|
+
[],
|
|
304
|
+
);
|
|
305
|
+
assert.deepEqual(
|
|
306
|
+
findSchemaRefLeaks({ properties: { $ref: { body: { $ref: "#/components/schemas/X" } } } }),
|
|
307
|
+
["properties.$ref.body"],
|
|
308
|
+
);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("findToolSchemaViolations flags a leaked $ref and a body missing an explicit type", () => {
|
|
312
|
+
assert.deepEqual(findToolSchemaViolations("ok", { type: "object", properties: { body: { type: "object" } } }), []);
|
|
313
|
+
const leak = findToolSchemaViolations("leaky", { type: "object", properties: { body: { $ref: "#/components/schemas/X" } } });
|
|
314
|
+
assert.equal(leak.length, 2); // both the $ref leak AND the missing body type
|
|
315
|
+
assert.ok(leak.some((m) => /leaks an unresolved \$ref/.test(m)));
|
|
316
|
+
assert.ok(leak.some((m) => /body has no explicit `type`/.test(m)));
|
|
317
|
+
const untyped = findToolSchemaViolations("untyped", { type: "object", properties: { body: { properties: {} } } });
|
|
318
|
+
assert.deepEqual(untyped, ["untyped: inputSchema.properties.body has no explicit `type`"]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("findAllToolSchemaViolations passes a resolved-body doc and catches a would-be $ref leak", () => {
|
|
322
|
+
// The real doc (a $ref body) is self-contained AFTER projection resolution — no violations.
|
|
323
|
+
assert.deepEqual(findAllToolSchemaViolations(doc), []);
|
|
324
|
+
// Excluded ops are never advertised, so their schema is not asserted.
|
|
325
|
+
const excludedDoc: OpenApiDoc = {
|
|
326
|
+
openapi: "3.1.0",
|
|
327
|
+
components: { schemas: { Invoice: { type: "object", properties: { id: { type: "string" } } } } },
|
|
328
|
+
paths: {
|
|
329
|
+
"/x": {
|
|
330
|
+
post: {
|
|
331
|
+
operationId: "hiddenOp",
|
|
332
|
+
"x-mcp": false,
|
|
333
|
+
requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
|
|
334
|
+
responses: { "200": {} },
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
assert.deepEqual(findAllToolSchemaViolations(excludedDoc), []);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("an x-mcp-excluded op with a dangling/cyclic $ref never crashes projection or guards", () => {
|
|
343
|
+
// An EXCLUDED door is withheld from the tool surface, so its schema is never advertised — a
|
|
344
|
+
// broken `$ref` on it must not make the whole document's projection/guards throw. Regression:
|
|
345
|
+
// deep resolution used to run eagerly, before the exclusion check.
|
|
346
|
+
const excludedBrokenDoc: OpenApiDoc = {
|
|
347
|
+
openapi: "3.1.0",
|
|
348
|
+
components: { schemas: { Cyclic: { $ref: "#/components/schemas/Cyclic" } } },
|
|
349
|
+
paths: {
|
|
350
|
+
"/hidden": {
|
|
351
|
+
post: {
|
|
352
|
+
operationId: "hiddenBroken",
|
|
353
|
+
"x-mcp": false,
|
|
354
|
+
parameters: [{ name: "q", in: "query", schema: { $ref: "#/components/schemas/Missing" } }],
|
|
355
|
+
requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/Cyclic" } } } },
|
|
356
|
+
responses: { "200": {} },
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
"/shown": {
|
|
360
|
+
post: {
|
|
361
|
+
operationId: "shownOp",
|
|
362
|
+
requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } },
|
|
363
|
+
responses: { "200": {} },
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
const ops = collectOperations(excludedBrokenDoc);
|
|
369
|
+
const hidden = ops.find((o) => o.operationId === "hiddenBroken");
|
|
370
|
+
assert.equal(hidden?.mcpExcluded, true);
|
|
371
|
+
assert.equal(hidden?.requestBodySchemaResolved, undefined);
|
|
372
|
+
assert.deepEqual(findAllToolSchemaViolations(excludedBrokenDoc), []);
|
|
373
|
+
const projection = collectMcpToolProjection(excludedBrokenDoc);
|
|
374
|
+
assert.equal(projection.find((p) => p.operationId === "hiddenBroken")?.excluded, true);
|
|
375
|
+
assert.equal(projection.find((p) => p.operationId === "shownOp")?.inputSchema?.type, "object");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("diffMcpToolProjection reports an inputSchema skew concisely, not by inlining payloads", () => {
|
|
379
|
+
const projection = collectMcpToolProjection(doc);
|
|
380
|
+
const big = { type: "object", properties: { body: { type: "object", properties: { huge: { type: "string" } } } }, required: ["body"] };
|
|
381
|
+
const stale = projection.map((p) => (p.operationId === "createInvoice" ? { ...p, inputSchema: big } : p));
|
|
382
|
+
const drift = diffMcpToolProjection(stale, projection);
|
|
383
|
+
const line = drift.find((l) => /createInvoice: inputSchema changed/.test(l));
|
|
384
|
+
assert.ok(line, "expected an inputSchema-changed drift line for createInvoice");
|
|
385
|
+
assert.ok(!line?.includes("huge"), "drift line must not inline the full schema payload");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("collectMcpToolProjection carries a self-contained inputSchema; diff catches an inputSchema skew", () => {
|
|
389
|
+
const projection = collectMcpToolProjection(doc);
|
|
390
|
+
const create = projection.find((p) => p.operationId === "createInvoice");
|
|
391
|
+
assert.equal(create?.inputSchema?.type, "object");
|
|
392
|
+
assert.ok(!JSON.stringify(create?.inputSchema).includes("$ref"));
|
|
393
|
+
// A snapshot whose body schema regressed to a $ref (or otherwise differs) is reported as drift.
|
|
394
|
+
const stale = projection.map((p) =>
|
|
395
|
+
p.operationId === "createInvoice"
|
|
396
|
+
? { ...p, inputSchema: { type: "object", properties: { body: { $ref: "#/components/schemas/Invoice" } }, required: ["body"] } }
|
|
397
|
+
: p,
|
|
398
|
+
);
|
|
399
|
+
const drift = diffMcpToolProjection(stale, projection);
|
|
400
|
+
assert.ok(drift.some((line) => /createInvoice: inputSchema changed/.test(line)));
|
|
401
|
+
});
|
|
402
|
+
|
|
182
403
|
test("validateValue enforces the JSON-Schema subset via $ref", () => {
|
|
183
404
|
const schema = { $ref: "#/components/schemas/Invoice" };
|
|
184
405
|
assert.deepEqual(validateValue(doc, schema, { id: "i1", amount: 5 }, "body"), []);
|
package/src/openapi/spec.ts
CHANGED
|
@@ -106,6 +106,11 @@ export interface OpenApiParameter {
|
|
|
106
106
|
in: "path" | "query" | "header" | "cookie";
|
|
107
107
|
required?: boolean;
|
|
108
108
|
schema?: OpenApiSchema;
|
|
109
|
+
/** The parameter's schema with every local component `$ref` inlined (via {@link resolveSchemaDeep}),
|
|
110
|
+
* so a consumer that cannot resolve `#/components/...` — e.g. an MCP client reading the projected
|
|
111
|
+
* tool `inputSchema` — sees a self-contained schema. Absent when the parameter declares no schema.
|
|
112
|
+
* The raw {@link schema} is retained for the type deriver, which maps a `$ref` to its named type. */
|
|
113
|
+
resolvedSchema?: OpenApiSchema;
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
export interface OpenApiMediaType {
|
|
@@ -178,6 +183,16 @@ export interface OperationInfo {
|
|
|
178
183
|
path: string;
|
|
179
184
|
parameters: OpenApiParameter[];
|
|
180
185
|
requestBodySchema?: OpenApiSchema;
|
|
186
|
+
/** The request-body schema with every local component `$ref` deeply inlined (via
|
|
187
|
+
* {@link resolveSchemaDeep}), so it is self-contained: no `$ref`, and an explicit `type` where the
|
|
188
|
+
* referenced component declared one. This is what the MCP tool projection copies into a tool's
|
|
189
|
+
* `inputSchema.properties.body` — an MCP client cannot resolve `#/components/...` outside the
|
|
190
|
+
* original document, so the projected schema must carry the concrete shape. The raw
|
|
191
|
+
* {@link requestBodySchema} is retained alongside it for the type deriver (which maps a `$ref` to
|
|
192
|
+
* its named TypeScript type) and for `sharedRequestBodySchemas` (which fingerprints shared refs).
|
|
193
|
+
* Absent exactly when {@link requestBodySchema} is absent. Downstream slices (ADR 0067) read the
|
|
194
|
+
* resolved body `type` from here rather than re-resolving a possibly-`$ref` raw schema. */
|
|
195
|
+
requestBodySchemaResolved?: OpenApiSchema;
|
|
181
196
|
requestBodyRequired: boolean;
|
|
182
197
|
/** Every documented JSON response, keyed by status code ("200","400",…,"default"), in a stable
|
|
183
198
|
* order. The type layer unions these into the operation's response type; the runtime validates a
|
|
@@ -254,6 +269,62 @@ export function resolveSchema(
|
|
|
254
269
|
return current;
|
|
255
270
|
}
|
|
256
271
|
|
|
272
|
+
/** Deeply inline every local component `$ref` in a schema, returning a self-contained schema with no
|
|
273
|
+
* `$ref` anywhere in its tree (top-level chain plus nested `properties`/`items`/
|
|
274
|
+
* `additionalProperties`/`allOf`/`oneOf`/`anyOf`). This is the resolver the MCP tool projection uses
|
|
275
|
+
* so a tool's `inputSchema` carries the concrete shape rather than an opaque `#/components/...`
|
|
276
|
+
* pointer an MCP client cannot follow (ADR 0067). It reuses the same local-ref semantics as
|
|
277
|
+
* {@link resolveSchema}, extended to recurse into subschemas.
|
|
278
|
+
*
|
|
279
|
+
* Cycles and unresolvable refs FAIL LOUDLY rather than leak a `$ref`:
|
|
280
|
+
* - a `$ref` that reappears on its own resolution path (an irreducible cycle — a schema that
|
|
281
|
+
* cannot be finitely inlined) throws;
|
|
282
|
+
* - a `$ref` that is non-local or dangles (no matching `#/components/schemas/*`) throws.
|
|
283
|
+
* `seenRefs` tracks refs on the CURRENT path only, so a component referenced twice in sibling
|
|
284
|
+
* branches (a diamond) inlines fine — only genuine self-reference is rejected. */
|
|
285
|
+
export function resolveSchemaDeep(
|
|
286
|
+
doc: OpenApiDoc,
|
|
287
|
+
schema: OpenApiSchema | undefined,
|
|
288
|
+
seenRefs: ReadonlySet<string> = new Set(),
|
|
289
|
+
): OpenApiSchema | undefined {
|
|
290
|
+
if (!schema) return undefined;
|
|
291
|
+
let current: OpenApiSchema = schema;
|
|
292
|
+
let seen = seenRefs;
|
|
293
|
+
while (current.$ref) {
|
|
294
|
+
const ref = current.$ref;
|
|
295
|
+
if (seen.has(ref)) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`OpenAPI schema $ref cycle: ${[...seen, ref].join(" -> ")} cannot be inlined into a self-contained schema`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const name = refName(ref);
|
|
301
|
+
const next = name ? doc.components?.schemas?.[name] : undefined;
|
|
302
|
+
if (!next) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`OpenAPI schema $ref "${ref}" does not resolve to a local component — a self-contained schema cannot contain an unresolvable $ref`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
seen = new Set(seen).add(ref);
|
|
308
|
+
current = next;
|
|
309
|
+
}
|
|
310
|
+
const out: OpenApiSchema = { ...current };
|
|
311
|
+
delete out.$ref;
|
|
312
|
+
if (current.properties) {
|
|
313
|
+
out.properties = Object.fromEntries(
|
|
314
|
+
Object.entries(current.properties).map(([k, v]) => [k, resolveSchemaDeep(doc, v, seen) ?? {}]),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (current.items) out.items = resolveSchemaDeep(doc, current.items, seen);
|
|
318
|
+
if (current.additionalProperties && typeof current.additionalProperties === "object") {
|
|
319
|
+
out.additionalProperties = resolveSchemaDeep(doc, current.additionalProperties, seen);
|
|
320
|
+
}
|
|
321
|
+
for (const key of ["allOf", "oneOf", "anyOf"] as const) {
|
|
322
|
+
const arr = current[key];
|
|
323
|
+
if (arr) out[key] = arr.map((s) => resolveSchemaDeep(doc, s, seen) ?? {});
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
257
328
|
function firstJsonSchema(
|
|
258
329
|
content: Record<string, unknown> | undefined,
|
|
259
330
|
): OpenApiSchema | undefined {
|
|
@@ -289,12 +360,31 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
289
360
|
if (!isRecord(opRaw)) continue;
|
|
290
361
|
const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
|
|
291
362
|
if (!operationId || !isSafeOperationId(operationId)) continue;
|
|
363
|
+
// Resolve exclusion BEFORE any deep `$ref` inlining. An `x-mcp`-excluded operation is never
|
|
364
|
+
// advertised as a tool, so its schema is never projected or self-containment-guarded; doing
|
|
365
|
+
// its (throwing) deep resolution eagerly would let a dangling/cyclic `$ref` on an EXCLUDED
|
|
366
|
+
// door crash projection/guards for the whole document — even though that door is withheld.
|
|
367
|
+
// Excluded ops therefore carry no `resolvedSchema`/`requestBodySchemaResolved` (unused).
|
|
368
|
+
const mcpExcluded = isMcpExcluded(opRaw["x-mcp"]);
|
|
292
369
|
const opParams = Array.isArray(opRaw.parameters) ? opRaw.parameters : [];
|
|
293
|
-
const parameters = normalizeParameters([...pathParams, ...opParams])
|
|
370
|
+
const parameters = normalizeParameters([...pathParams, ...opParams]).map((p) => ({
|
|
371
|
+
...p,
|
|
372
|
+
// Inline any local component `$ref` so the projected MCP tool schema is self-contained. The
|
|
373
|
+
// raw `p.schema` is kept for the type deriver; the tool projection reads `resolvedSchema`.
|
|
374
|
+
resolvedSchema: mcpExcluded ? undefined : resolveSchemaDeep(doc, p.schema),
|
|
375
|
+
}));
|
|
294
376
|
const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
|
|
295
377
|
const requestBodySchema = firstJsonSchema(
|
|
296
378
|
isRecord(requestBody?.content) ? requestBody.content : undefined,
|
|
297
379
|
);
|
|
380
|
+
// Deeply inline component `$ref`s so downstream consumers (the MCP tool projection, ADR 0067)
|
|
381
|
+
// get a self-contained, explicitly-typed body schema — never an opaque `#/components/...`
|
|
382
|
+
// pointer. Raw `requestBodySchema` is retained (deriver + shared-ref fingerprinting). A cyclic
|
|
383
|
+
// or unresolvable body ref fails loudly here rather than leaking a `$ref` — but only for a
|
|
384
|
+
// projected (non-excluded) op; an excluded op skips this throwing work (see above).
|
|
385
|
+
const requestBodySchemaResolved = mcpExcluded
|
|
386
|
+
? undefined
|
|
387
|
+
: resolveSchemaDeep(doc, requestBodySchema);
|
|
298
388
|
const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
|
|
299
389
|
// Effective security: an operation's own `security` (even an explicit `[]`, which opts out of
|
|
300
390
|
// a document-level default) wins; otherwise inherit the document-level default; otherwise open.
|
|
@@ -308,11 +398,12 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
|
|
|
308
398
|
path,
|
|
309
399
|
parameters,
|
|
310
400
|
requestBodySchema,
|
|
401
|
+
requestBodySchemaResolved,
|
|
311
402
|
requestBodyRequired: requestBody?.required === true,
|
|
312
403
|
responseSchemas: collectResponseSchemas(responses),
|
|
313
404
|
security,
|
|
314
405
|
eject: opRaw["x-urban-eject"] === true,
|
|
315
|
-
mcpExcluded
|
|
406
|
+
mcpExcluded,
|
|
316
407
|
summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
|
|
317
408
|
});
|
|
318
409
|
}
|
|
@@ -349,15 +440,114 @@ export function isMutatingMethod(method: HttpMethodLower): boolean {
|
|
|
349
440
|
return !READ_ONLY_METHODS.includes(method);
|
|
350
441
|
}
|
|
351
442
|
|
|
352
|
-
/**
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
443
|
+
/** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
|
|
444
|
+
* path/query parameters and its request-body schema. It is the SINGLE derivation both the runtime
|
|
445
|
+
* MCP surface (`tools/list`) and the CI parity guard read, so a tool's advertised input schema and
|
|
446
|
+
* the HTTP validation it dispatches to can never drift (ADR 0053/0067).
|
|
447
|
+
*
|
|
448
|
+
* Every property schema is taken from the operation's resolved (`$ref`-free) metadata — the
|
|
449
|
+
* parameters' `resolvedSchema` and the body's `requestBodySchemaResolved`, both produced by
|
|
450
|
+
* {@link resolveSchemaDeep} in {@link collectOperations} — so the emitted schema is self-contained:
|
|
451
|
+
* an MCP client, which cannot follow a `#/components/...` pointer outside the original document,
|
|
452
|
+
* sees the concrete shape (and an explicit `type` on the body). */
|
|
453
|
+
export function toolInputSchema(op: OperationInfo): Record<string, unknown> {
|
|
454
|
+
const properties: Record<string, unknown> = {};
|
|
455
|
+
const required: string[] = [];
|
|
456
|
+
for (const p of op.parameters) {
|
|
457
|
+
if (p.in === "path" || p.in === "query") {
|
|
458
|
+
properties[p.name] = p.resolvedSchema ?? p.schema ?? {};
|
|
459
|
+
if (p.required) required.push(p.name);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const body = op.requestBodySchemaResolved;
|
|
463
|
+
if (body) {
|
|
464
|
+
properties.body = body;
|
|
465
|
+
if (op.requestBodyRequired) required.push("body");
|
|
466
|
+
}
|
|
467
|
+
const schema: Record<string, unknown> = { type: "object", properties };
|
|
468
|
+
if (required.length > 0) schema.required = required;
|
|
469
|
+
return schema;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Walk a JSON value (a projected tool schema) and return a pointer for every `$ref` still present.
|
|
473
|
+
* Empty means the schema is self-contained — no `#/components/...` an MCP client cannot resolve. The
|
|
474
|
+
* path is a dotted trail to each leak (e.g. `properties.body.properties.foo`). Pure/total. */
|
|
475
|
+
export function findSchemaRefLeaks(value: unknown, path = ""): string[] {
|
|
476
|
+
const leaks: string[] = [];
|
|
477
|
+
const visit = (v: unknown, p: string): void => {
|
|
478
|
+
if (Array.isArray(v)) {
|
|
479
|
+
v.forEach((item, i) => visit(item, `${p}[${i}]`));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (v && typeof v === "object") {
|
|
483
|
+
for (const [k, child] of Object.entries(v)) {
|
|
484
|
+
// A JSON-Schema `$ref` is a *string* pointer; only that form is an unresolvable leak. A
|
|
485
|
+
// property literally named `$ref` with a non-string value is an ordinary schema key, so
|
|
486
|
+
// recurse into it (real leaks can still hide nested beneath such a property).
|
|
487
|
+
if (k === "$ref" && typeof child === "string") leaks.push(p || "<root>");
|
|
488
|
+
else visit(child, p ? `${p}.${k}` : k);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
visit(value, path);
|
|
493
|
+
return leaks;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Whether a (record-typed) schema declares an explicit JSON-Schema `type` — a non-empty string or a
|
|
497
|
+
* non-empty array of types. Operates on a raw record (not a typed {@link OpenApiSchema}) so it can
|
|
498
|
+
* vet a projected tool schema's `body` property without an unchecked cast. */
|
|
499
|
+
function hasExplicitType(schema: Record<string, unknown>): boolean {
|
|
500
|
+
const t = schema.type;
|
|
501
|
+
if (typeof t === "string") return t.length > 0;
|
|
502
|
+
return Array.isArray(t) && t.length > 0;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Self-containment violations in one projected tool's `inputSchema`: any surviving `$ref` (an
|
|
506
|
+
* MCP client cannot resolve `#/components/...`), and — when the tool has a `body` property — a
|
|
507
|
+
* `body` that carries no explicit `type` (so the client cannot tell it is an object/array and
|
|
508
|
+
* mis-encodes the argument). Empty means the schema is a usable, self-contained input contract.
|
|
509
|
+
* This is the pure assertion the `check:mcp` guard runs over every projected tool (ADR 0067). */
|
|
510
|
+
export function findToolSchemaViolations(
|
|
511
|
+
operationId: string,
|
|
512
|
+
inputSchema: Record<string, unknown>,
|
|
513
|
+
): string[] {
|
|
514
|
+
const violations: string[] = [];
|
|
515
|
+
for (const leak of findSchemaRefLeaks(inputSchema)) {
|
|
516
|
+
violations.push(`${operationId}: inputSchema leaks an unresolved $ref at ${leak}`);
|
|
517
|
+
}
|
|
518
|
+
const properties = isRecord(inputSchema.properties) ? inputSchema.properties : undefined;
|
|
519
|
+
const body = properties && isRecord(properties.body) ? properties.body : undefined;
|
|
520
|
+
if (body && !hasExplicitType(body)) {
|
|
521
|
+
violations.push(`${operationId}: inputSchema.properties.body has no explicit \`type\``);
|
|
522
|
+
}
|
|
523
|
+
return violations;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Every self-containment violation across all projected (non-excluded) tools for a document — the
|
|
527
|
+
* aggregate the `check:mcp` guard fails on. Excluded operations are skipped: they are withheld from
|
|
528
|
+
* the live tool surface, so their schema is never advertised to a client. */
|
|
529
|
+
export function findAllToolSchemaViolations(doc: OpenApiDoc): string[] {
|
|
530
|
+
const violations: string[] = [];
|
|
531
|
+
for (const op of collectOperations(doc)) {
|
|
532
|
+
if (op.mcpExcluded) continue;
|
|
533
|
+
violations.push(...findToolSchemaViolations(op.operationId, toolInputSchema(op)));
|
|
534
|
+
}
|
|
535
|
+
return violations;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, whether
|
|
539
|
+
* `x-mcp` excludes it from projection, and its self-contained input schema. This is the shared unit
|
|
540
|
+
* the runtime MCP surface AND the CI spec↔tool parity guard both read — a single derivation of
|
|
541
|
+
* "which operations become tools and what each accepts", never a second walker (ADR 0053). */
|
|
356
542
|
export interface McpToolProjection {
|
|
357
543
|
operationId: string;
|
|
358
544
|
method: HttpMethodLower;
|
|
359
545
|
mutating: boolean;
|
|
360
546
|
excluded: boolean;
|
|
547
|
+
/** The tool's `inputSchema` as advertised to an MCP client (absent for an excluded op, which is
|
|
548
|
+
* never advertised). Captured in the snapshot so a projection change — including a body `$ref`
|
|
549
|
+
* that stops being resolved — is caught by `check:mcp` drift, not discovered by a client. */
|
|
550
|
+
inputSchema?: Record<string, unknown>;
|
|
361
551
|
}
|
|
362
552
|
|
|
363
553
|
/**
|
|
@@ -366,7 +556,7 @@ export interface McpToolProjection {
|
|
|
366
556
|
* `excluded: true`) rather than dropped, so the parity guard can positively assert that an
|
|
367
557
|
* `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
|
|
368
558
|
* tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
|
|
369
|
-
* `mountApi` routes.
|
|
559
|
+
* `mountApi` routes. A non-excluded op also carries its self-contained {@link toolInputSchema}.
|
|
370
560
|
*/
|
|
371
561
|
export function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[] {
|
|
372
562
|
return collectOperations(doc).map((op) => ({
|
|
@@ -374,6 +564,7 @@ export function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[] {
|
|
|
374
564
|
method: op.method,
|
|
375
565
|
mutating: isMutatingMethod(op.method),
|
|
376
566
|
excluded: op.mcpExcluded,
|
|
567
|
+
...(op.mcpExcluded ? {} : { inputSchema: toolInputSchema(op) }),
|
|
377
568
|
}));
|
|
378
569
|
}
|
|
379
570
|
|
|
@@ -408,6 +599,12 @@ export function diffMcpToolProjection(
|
|
|
408
599
|
`excluded:${cur.excluded}}`,
|
|
409
600
|
);
|
|
410
601
|
}
|
|
602
|
+
if (JSON.stringify(snap.inputSchema ?? null) !== JSON.stringify(cur.inputSchema ?? null)) {
|
|
603
|
+
// Emit a concise, constant-size line rather than inlining both full `inputSchema` payloads:
|
|
604
|
+
// for a non-trivial schema that would explode CI logs and bury the signal. The committed
|
|
605
|
+
// snapshot's own diff carries the exact field-level detail; this line just names the drift.
|
|
606
|
+
drift.push(`~ ${id}: inputSchema changed (regenerate the snapshot to see the field-level diff)`);
|
|
607
|
+
}
|
|
411
608
|
}
|
|
412
609
|
for (const id of snapshotById.keys()) {
|
|
413
610
|
if (!currentById.has(id)) {
|