@nanobpm/urban 0.87.0 → 0.88.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/runtime/core/modules/mcp.d.ts +28 -0
- package/dist/runtime/core/modules/mcp.js +76 -2
- package/dist/runtime/mcp-conformance.d.ts +49 -0
- package/dist/runtime/mcp-conformance.js +132 -0
- package/package.json +2 -1
- package/src/openapi/consumer-specs/README.md +51 -0
- package/src/openapi/consumer-specs/nano-workforce.openapi.yaml +5050 -0
- package/src/openapi/mcp-parity.fixture.yaml +27 -0
- package/src/openapi/mcp-tools.snapshot.json +31 -0
- package/src/runtime/core/modules/mcp.test.ts +124 -0
- package/src/runtime/core/modules/mcp.ts +85 -1
- package/src/runtime/mcp-conformance.test.ts +127 -0
- package/src/runtime/mcp-conformance.ts +199 -0
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
# self-contained (no `$ref`, explicit
|
|
20
20
|
# `type`) — an MCP client cannot resolve
|
|
21
21
|
# `#/components/...` outside this document.
|
|
22
|
+
# - an INLINE object-body op (POST) → its request body declares `type: object`
|
|
23
|
+
# inline, exercising the faithful object-body
|
|
24
|
+
# transport path (object arg → door receives
|
|
25
|
+
# an object; a pre-encoded JSON string is
|
|
26
|
+
# parsed, not double-encoded).
|
|
22
27
|
#
|
|
23
28
|
# Edit this fixture ONLY together with a reviewed `npm run gen:mcp-tools`.
|
|
24
29
|
openapi: 3.1.0
|
|
@@ -93,3 +98,25 @@ paths:
|
|
|
93
98
|
responses:
|
|
94
99
|
"200":
|
|
95
100
|
description: ok
|
|
101
|
+
/widgets/bulk:
|
|
102
|
+
post:
|
|
103
|
+
operationId: bulkCreateWidgets
|
|
104
|
+
summary: "Bulk create widgets (mutating — inline `type: object` request body, object-arg round-trip case)."
|
|
105
|
+
requestBody:
|
|
106
|
+
required: true
|
|
107
|
+
content:
|
|
108
|
+
application/json:
|
|
109
|
+
schema:
|
|
110
|
+
type: object
|
|
111
|
+
required:
|
|
112
|
+
- items
|
|
113
|
+
properties:
|
|
114
|
+
items:
|
|
115
|
+
type: array
|
|
116
|
+
items:
|
|
117
|
+
type: string
|
|
118
|
+
dryRun:
|
|
119
|
+
type: boolean
|
|
120
|
+
responses:
|
|
121
|
+
"201":
|
|
122
|
+
description: created
|
|
@@ -64,5 +64,36 @@
|
|
|
64
64
|
"type": "object",
|
|
65
65
|
"properties": {}
|
|
66
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
|
+
}
|
|
67
98
|
}
|
|
68
99
|
]
|
|
@@ -356,6 +356,130 @@ test("a $ref-bodied tool projects a self-contained body schema (no $ref, explici
|
|
|
356
356
|
assert.ok(!JSON.stringify(schema).includes("$ref"), "projected body schema must not leak a $ref");
|
|
357
357
|
});
|
|
358
358
|
|
|
359
|
+
test("an object body reaches the door as an object; a pre-encoded JSON-string body is parsed, not double-encoded; a malformed string body is a clear error", async () => {
|
|
360
|
+
// `createInvoice`'s requestBody is `{ $ref: "#/components/schemas/Invoice" }` — an object body
|
|
361
|
+
// whose concrete `type` is only visible after the projection resolver inlines the component. The
|
|
362
|
+
// transport must classify it as an object body FROM THE RESOLVED SCHEMA (not the raw `$ref`) so the
|
|
363
|
+
// string-parse path triggers for exactly this motivating shape, not merely an inline-`type` body.
|
|
364
|
+
let seenBody: unknown = "unset";
|
|
365
|
+
const { router } = buildHarness({
|
|
366
|
+
modules: {
|
|
367
|
+
"/app/operations/createInvoice": {
|
|
368
|
+
default: (input: { body: unknown }) => {
|
|
369
|
+
seenBody = input.body;
|
|
370
|
+
return { body: { ok: true } };
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
const session = await connect(router);
|
|
376
|
+
|
|
377
|
+
// 1. A genuine object argument reaches the door (the reused delegate) AS AN OBJECT.
|
|
378
|
+
const objCall = await rpc(router, session, "tools/call", {
|
|
379
|
+
name: "createInvoice",
|
|
380
|
+
arguments: { body: { id: "7", amount: 3 } },
|
|
381
|
+
});
|
|
382
|
+
assert.notEqual(objCall.result?.isError, true, `object body call must succeed: ${toolContentText(objCall.result)}`);
|
|
383
|
+
assert.deepEqual(seenBody, { id: "7", amount: 3 }, "an object body argument must reach the door as an object");
|
|
384
|
+
|
|
385
|
+
// 2. A PRE-ENCODED JSON-string body is accepted and parsed — NOT double-encoded into a quoted
|
|
386
|
+
// string the door would reject with "expected object, got string".
|
|
387
|
+
seenBody = "unset";
|
|
388
|
+
const strCall = await rpc(router, session, "tools/call", {
|
|
389
|
+
name: "createInvoice",
|
|
390
|
+
arguments: { body: JSON.stringify({ id: "9", amount: 5 }) },
|
|
391
|
+
});
|
|
392
|
+
assert.notEqual(strCall.result?.isError, true, `pre-encoded string body must be accepted: ${toolContentText(strCall.result)}`);
|
|
393
|
+
assert.deepEqual(seenBody, { id: "9", amount: 5 }, "a pre-encoded JSON-string body must be parsed to an object at the door");
|
|
394
|
+
|
|
395
|
+
// 3. A malformed string body is a CLEAR tool error naming the expected shape — never a silent 4xx
|
|
396
|
+
// from a forwarded double-encoded body, and the delegate must not run.
|
|
397
|
+
seenBody = "unset";
|
|
398
|
+
const badCall = await rpc(router, session, "tools/call", {
|
|
399
|
+
name: "createInvoice",
|
|
400
|
+
arguments: { body: "{not valid json" },
|
|
401
|
+
});
|
|
402
|
+
assert.equal(badCall.result?.isError, true, "a malformed string body must return an isError result");
|
|
403
|
+
assert.match(toolContentText(badCall.result), /object/i, "the error must name the expected object shape");
|
|
404
|
+
assert.equal(seenBody, "unset", "the door delegate must not run for a malformed string body");
|
|
405
|
+
|
|
406
|
+
// 4. A string that parses to the WRONG shape (a scalar) is likewise a clear error, not a
|
|
407
|
+
// double-encoded forward — the object body path fails loudly on a non-object.
|
|
408
|
+
seenBody = "unset";
|
|
409
|
+
const wrongShape = await rpc(router, session, "tools/call", {
|
|
410
|
+
name: "createInvoice",
|
|
411
|
+
arguments: { body: "42" },
|
|
412
|
+
});
|
|
413
|
+
assert.equal(wrongShape.result?.isError, true, "a string parsing to a scalar must return an isError result");
|
|
414
|
+
assert.match(toolContentText(wrongShape.result), /object/i, "the error must name the expected object shape");
|
|
415
|
+
assert.equal(seenBody, "unset", "the door delegate must not run for a wrong-shape string body");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("an array body reaches the door as an array; a pre-encoded JSON-string array body is parsed, not double-encoded; a wrong-shape string is a clear error", async () => {
|
|
419
|
+
// Mirror of the object-body transport test for the ARRAY branch: `structuredBodyKind` can return
|
|
420
|
+
// "array" and `normalizeBodyArg` has an `Array.isArray(parsed)` path, so a regression in array
|
|
421
|
+
// handling must be caught here. `createBatch`'s requestBody declares a `type: array` body — the
|
|
422
|
+
// transport must classify it as an array body and parse a pre-encoded JSON-string array once so it
|
|
423
|
+
// round-trips as an array rather than being double-encoded into a quoted string the door rejects.
|
|
424
|
+
const arraySpec = JSON.stringify({
|
|
425
|
+
openapi: "3.0.0",
|
|
426
|
+
paths: {
|
|
427
|
+
"/batch": {
|
|
428
|
+
post: {
|
|
429
|
+
operationId: "createBatch",
|
|
430
|
+
requestBody: {
|
|
431
|
+
required: true,
|
|
432
|
+
content: { "application/json": { schema: { type: "array", items: { type: "string" } } } },
|
|
433
|
+
},
|
|
434
|
+
responses: { "201": {} },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
let seenBody: unknown = "unset";
|
|
440
|
+
const { router } = buildHarness({
|
|
441
|
+
spec: arraySpec,
|
|
442
|
+
modules: {
|
|
443
|
+
"/app/operations/createBatch": {
|
|
444
|
+
default: (input: { body: unknown }) => {
|
|
445
|
+
seenBody = input.body;
|
|
446
|
+
return { body: { ok: true } };
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
const session = await connect(router);
|
|
452
|
+
|
|
453
|
+
// 1. A genuine array argument reaches the door (the reused delegate) AS AN ARRAY.
|
|
454
|
+
const arrCall = await rpc(router, session, "tools/call", {
|
|
455
|
+
name: "createBatch",
|
|
456
|
+
arguments: { body: ["a", "b"] },
|
|
457
|
+
});
|
|
458
|
+
assert.notEqual(arrCall.result?.isError, true, `array body call must succeed: ${toolContentText(arrCall.result)}`);
|
|
459
|
+
assert.deepEqual(seenBody, ["a", "b"], "an array body argument must reach the door as an array");
|
|
460
|
+
|
|
461
|
+
// 2. A PRE-ENCODED JSON-string array body is accepted and parsed — NOT double-encoded into a quoted
|
|
462
|
+
// string the door would reject with "expected array, got string".
|
|
463
|
+
seenBody = "unset";
|
|
464
|
+
const strCall = await rpc(router, session, "tools/call", {
|
|
465
|
+
name: "createBatch",
|
|
466
|
+
arguments: { body: JSON.stringify(["a", "b"]) },
|
|
467
|
+
});
|
|
468
|
+
assert.notEqual(strCall.result?.isError, true, `pre-encoded string array body must be accepted: ${toolContentText(strCall.result)}`);
|
|
469
|
+
assert.deepEqual(seenBody, ["a", "b"], "a pre-encoded JSON-string array body must be parsed to an array at the door");
|
|
470
|
+
|
|
471
|
+
// 3. A string that parses to the WRONG shape (an object) is a clear error naming the expected
|
|
472
|
+
// array shape, not a double-encoded forward — and the delegate must not run.
|
|
473
|
+
seenBody = "unset";
|
|
474
|
+
const wrongShape = await rpc(router, session, "tools/call", {
|
|
475
|
+
name: "createBatch",
|
|
476
|
+
arguments: { body: JSON.stringify({ not: "an array" }) },
|
|
477
|
+
});
|
|
478
|
+
assert.equal(wrongShape.result?.isError, true, "a string parsing to an object must return an isError result");
|
|
479
|
+
assert.match(toolContentText(wrongShape.result), /array/i, "the error must name the expected array shape");
|
|
480
|
+
assert.equal(seenBody, "unset", "the door delegate must not run for a wrong-shape string array body");
|
|
481
|
+
});
|
|
482
|
+
|
|
359
483
|
test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
|
|
360
484
|
let seenParams: Record<string, unknown> | undefined;
|
|
361
485
|
const { router, imported } = buildHarness({
|
|
@@ -50,9 +50,13 @@ import {
|
|
|
50
50
|
collectOperations,
|
|
51
51
|
evaluateSecurity,
|
|
52
52
|
isMutatingMethod,
|
|
53
|
+
isObjectSchema,
|
|
53
54
|
type OpenApiDoc,
|
|
55
|
+
type OpenApiSchema,
|
|
54
56
|
type OperationInfo,
|
|
55
57
|
parseSpec,
|
|
58
|
+
resolveSchema,
|
|
59
|
+
schemaHasType,
|
|
56
60
|
type SecurityDecision,
|
|
57
61
|
sharedSecretSchemeName,
|
|
58
62
|
toolInputSchema,
|
|
@@ -336,6 +340,10 @@ function toHttpRequest(
|
|
|
336
340
|
args: Record<string, unknown>,
|
|
337
341
|
forwardHeaders: Record<string, string | string[] | undefined>,
|
|
338
342
|
): HttpRequest {
|
|
343
|
+
// `args.body` is expected to already be the structured value the operation declares — a
|
|
344
|
+
// pre-encoded JSON-string body is normalized upstream by `normalizeBodyArg` (called in
|
|
345
|
+
// `dispatchOperationTool`) so `JSON.stringify` here serializes an object/array ONCE rather than
|
|
346
|
+
// double-encoding a quoted string the door would reject (ADR 0067).
|
|
339
347
|
const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
|
|
340
348
|
? JSON.stringify(args.body)
|
|
341
349
|
: "";
|
|
@@ -354,6 +362,76 @@ function toHttpRequest(
|
|
|
354
362
|
};
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
/** The operation's effective request-body schema for classifying a tool-call `body`: P0's resolved,
|
|
366
|
+
* `$ref`-free schema ({@link OperationInfo.requestBodySchemaResolved}, produced by
|
|
367
|
+
* `collectOperations`). As defence in depth, if that metadata is somehow still a bare `$ref` (it
|
|
368
|
+
* should never be after resolution), re-resolve it via {@link resolveSchema} against `doc` so the
|
|
369
|
+
* classification below never keys off an opaque `#/components/...` pointer. */
|
|
370
|
+
export function effectiveRequestBodySchema(
|
|
371
|
+
op: OperationInfo,
|
|
372
|
+
doc: OpenApiDoc | undefined,
|
|
373
|
+
): OpenApiSchema | undefined {
|
|
374
|
+
const schema = op.requestBodySchemaResolved ?? op.requestBodySchema;
|
|
375
|
+
if (schema?.$ref) return doc ? resolveSchema(doc, schema) : undefined;
|
|
376
|
+
return schema;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Whether the operation declares a STRUCTURED (object/array) request body — the shapes the app door
|
|
380
|
+
* expects as a JSON object/array rather than a bare scalar. Classified from the RESOLVED schema so a
|
|
381
|
+
* `$ref`-bodied op (e.g. `previewDeliveryGraph`, whose object `type` is only visible after P0's
|
|
382
|
+
* resolution) is recognized, not merely an inline-`type` object body. */
|
|
383
|
+
export function structuredBodyKind(schema: OpenApiSchema | undefined): "object" | "array" | undefined {
|
|
384
|
+
if (!schema) return undefined;
|
|
385
|
+
if (isObjectSchema(schema)) return "object";
|
|
386
|
+
if (schemaHasType(schema, "array")) return "array";
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Normalize a tool-call `body` argument for FAITHFUL transport (ADR 0067). A client that saw an
|
|
391
|
+
* opaque input schema — or that coerces non-primitive args — may send `body` as a pre-encoded JSON
|
|
392
|
+
* *string* instead of the object/array the operation declares. Left as-is, `toHttpRequest`'s
|
|
393
|
+
* `JSON.stringify` would DOUBLE-encode it into a quoted string and the door would reject it with
|
|
394
|
+
* "expected object, got string". So when the op declares an object/array body and `body` arrives as
|
|
395
|
+
* a string, parse it once so it round-trips as the structured value. A genuine object/array argument
|
|
396
|
+
* passes through untouched (still serialized by the transport). A string that does not parse to the
|
|
397
|
+
* declared shape is a CLEAR tool error naming the expected shape — never a silently double-encoded
|
|
398
|
+
* (and 4xx-rejected) request. */
|
|
399
|
+
export function normalizeBodyArg(
|
|
400
|
+
op: OperationInfo,
|
|
401
|
+
args: Record<string, unknown>,
|
|
402
|
+
doc: OpenApiDoc | undefined,
|
|
403
|
+
): { ok: true; args: Record<string, unknown> } | { ok: false; error: string } {
|
|
404
|
+
const body = args.body;
|
|
405
|
+
const kind = structuredBodyKind(effectiveRequestBodySchema(op, doc));
|
|
406
|
+
if (body === undefined || kind === undefined || typeof body !== "string") {
|
|
407
|
+
return { ok: true, args };
|
|
408
|
+
}
|
|
409
|
+
let parsed: unknown;
|
|
410
|
+
try {
|
|
411
|
+
parsed = JSON.parse(body);
|
|
412
|
+
} catch {
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
error:
|
|
416
|
+
`validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; the string is not valid JSON`,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const parsedKind = Array.isArray(parsed)
|
|
420
|
+
? "array"
|
|
421
|
+
: parsed !== null && typeof parsed === "object"
|
|
422
|
+
? "object"
|
|
423
|
+
: undefined;
|
|
424
|
+
if (parsedKind !== kind) {
|
|
425
|
+
const got = parsedKind ?? (parsed === null ? "null" : typeof parsed);
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
error:
|
|
429
|
+
`validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; it parsed to a ${got}`,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return { ok: true, args: { ...args, body: parsed } };
|
|
433
|
+
}
|
|
434
|
+
|
|
357
435
|
/** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
|
|
358
436
|
* non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
|
|
359
437
|
function textResult(value: unknown, isError = false): {
|
|
@@ -838,10 +916,16 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
|
|
|
838
916
|
true,
|
|
839
917
|
);
|
|
840
918
|
}
|
|
919
|
+
// Faithful body transport (ADR 0067): a client may send an object/array `body` pre-encoded as a
|
|
920
|
+
// JSON string. Normalize it to the structured value from the operation's RESOLVED body schema so
|
|
921
|
+
// it is not double-encoded by `toHttpRequest`; a string that cannot parse to the declared shape
|
|
922
|
+
// is a clear tool error rather than a silent 4xx from a double-encoded body.
|
|
923
|
+
const normalized = normalizeBodyArg(op, args, (await loadSpecDoc()).doc);
|
|
924
|
+
if (!normalized.ok) return textResult({ error: normalized.error }, true);
|
|
841
925
|
// Forward the MCP connection's headers so the reused router enforces the operation's own
|
|
842
926
|
// OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
|
|
843
927
|
// route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
|
|
844
|
-
const res = await apiRouter(toHttpRequest(op, args, headers));
|
|
928
|
+
const res = await apiRouter(toHttpRequest(op, normalized.args, headers));
|
|
845
929
|
const status = res.status ?? 200;
|
|
846
930
|
return textResult(res.body ?? "", status >= 400);
|
|
847
931
|
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { collectOperations, parseSpec } from "../openapi/spec.ts";
|
|
5
|
+
import {
|
|
6
|
+
type BodyNormalizer,
|
|
7
|
+
CONSUMER_SPECS,
|
|
8
|
+
findSpecConformanceViolations,
|
|
9
|
+
loadConsumerSpec,
|
|
10
|
+
runConsumerConformance,
|
|
11
|
+
} from "./mcp-conformance.ts";
|
|
12
|
+
import { normalizeBodyArg, structuredBodyKind, effectiveRequestBodySchema } from "./core/modules/mcp.ts";
|
|
13
|
+
|
|
14
|
+
// The whole corpus conforms: every projected tool in every vendored real consumer spec is
|
|
15
|
+
// self-contained (P0 #502) and every object-body op round-trips through the real transport (P1 #503).
|
|
16
|
+
test("consumer conformance corpus projects only conformant MCP tools", () => {
|
|
17
|
+
const results = runConsumerConformance();
|
|
18
|
+
assert.ok(results.length > 0, "expected at least one vendored consumer spec");
|
|
19
|
+
for (const { label, violations } of results) {
|
|
20
|
+
assert.deepEqual(violations, [], `${label} should project conformant tools, got:\n${violations.join("\n")}`);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Guard against a VACUOUS pass: the vendored spec must actually contain the shape the projection
|
|
25
|
+
// previously broke on — projected, non-excluded, structured-body operations — or the round-trip
|
|
26
|
+
// property below never runs and the guard proves nothing.
|
|
27
|
+
test("nano-workforce spec actually exercises the structured-body projection path", () => {
|
|
28
|
+
const spec = CONSUMER_SPECS.find((s) => s.label === "nano-workforce");
|
|
29
|
+
assert.ok(spec, "nano-workforce must be in the corpus");
|
|
30
|
+
const doc = loadConsumerSpec(spec);
|
|
31
|
+
const structured = collectOperations(doc).filter(
|
|
32
|
+
(op) => !op.mcpExcluded && structuredBodyKind(effectiveRequestBodySchema(op, doc)) !== undefined,
|
|
33
|
+
);
|
|
34
|
+
assert.ok(
|
|
35
|
+
structured.length > 0,
|
|
36
|
+
"expected nano-workforce to project at least one non-excluded object/array-body tool",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// The pinned ref recorded in code is the single source of truth; keep it a 40-hex commit SHA so a
|
|
41
|
+
// refresh cannot land a mutable ref (branch/tag) that would make the guard non-deterministic.
|
|
42
|
+
test("every consumer spec is pinned to a concrete commit SHA", () => {
|
|
43
|
+
for (const spec of CONSUMER_SPECS) {
|
|
44
|
+
assert.match(spec.ref, /^[0-9a-f]{40}$/, `${spec.label} ref must be a full commit SHA, got ${spec.ref}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Negative — REINTRODUCED DOUBLE-ENCODING: a transport that leaves a pre-encoded string body a
|
|
49
|
+
// string (the pre-P1 #503 behaviour) must make the conformance guard FAIL on the real spec.
|
|
50
|
+
test("a reintroduced double-encoding path fails conformance", () => {
|
|
51
|
+
const spec = CONSUMER_SPECS.find((s) => s.label === "nano-workforce");
|
|
52
|
+
assert.ok(spec);
|
|
53
|
+
const doc = loadConsumerSpec(spec);
|
|
54
|
+
|
|
55
|
+
const doubleEncoding: BodyNormalizer = (_op, args) => ({ ok: true, args }); // never parses a string body
|
|
56
|
+
|
|
57
|
+
const violations = findSpecConformanceViolations(spec.label, doc, doubleEncoding);
|
|
58
|
+
assert.ok(violations.length > 0, "double-encoding transport must be caught");
|
|
59
|
+
assert.ok(
|
|
60
|
+
violations.some((v) => v.includes("double-encoding path was reintroduced")),
|
|
61
|
+
`expected a double-encoding violation, got:\n${violations.join("\n")}`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Positive control: with the REAL transport the same spec conforms — so the negative test above is
|
|
66
|
+
// detecting the injected regression, not a pre-existing failure.
|
|
67
|
+
test("the real transport conforms on the same spec", () => {
|
|
68
|
+
const spec = CONSUMER_SPECS.find((s) => s.label === "nano-workforce");
|
|
69
|
+
assert.ok(spec);
|
|
70
|
+
const doc = loadConsumerSpec(spec);
|
|
71
|
+
assert.deepEqual(findSpecConformanceViolations(spec.label, doc, normalizeBodyArg), []);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Negative — REINTRODUCED $ref LEAK: a body whose `$ref` cannot be made self-contained (dangling
|
|
75
|
+
// component) must fail the guard rather than leak an opaque `#/components/...` pointer to a client.
|
|
76
|
+
test("an unresolvable $ref body fails conformance", () => {
|
|
77
|
+
const doc = parseSpec(`
|
|
78
|
+
openapi: 3.0.0
|
|
79
|
+
info: { title: leak, version: "1" }
|
|
80
|
+
paths:
|
|
81
|
+
/widgets:
|
|
82
|
+
post:
|
|
83
|
+
operationId: createWidget
|
|
84
|
+
requestBody:
|
|
85
|
+
required: true
|
|
86
|
+
content:
|
|
87
|
+
application/json:
|
|
88
|
+
schema: { $ref: "#/components/schemas/DoesNotExist" }
|
|
89
|
+
responses:
|
|
90
|
+
"200": { description: ok }
|
|
91
|
+
components:
|
|
92
|
+
schemas: {}
|
|
93
|
+
`);
|
|
94
|
+
const violations = findSpecConformanceViolations("leak-fixture", doc);
|
|
95
|
+
assert.ok(violations.length > 0, "an unresolvable $ref body must be caught");
|
|
96
|
+
assert.ok(
|
|
97
|
+
violations.some((v) => v.includes("projection failed")),
|
|
98
|
+
`expected a projection-failure violation, got:\n${violations.join("\n")}`,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Negative — a `body` that projects WITHOUT an explicit `type` (P0's other property) is caught: a
|
|
103
|
+
// client cannot tell it is an object/array and mis-encodes the argument.
|
|
104
|
+
test("a body schema without an explicit type fails conformance", () => {
|
|
105
|
+
const doc = parseSpec(`
|
|
106
|
+
openapi: 3.0.0
|
|
107
|
+
info: { title: untyped, version: "1" }
|
|
108
|
+
paths:
|
|
109
|
+
/widgets:
|
|
110
|
+
post:
|
|
111
|
+
operationId: createWidget
|
|
112
|
+
requestBody:
|
|
113
|
+
required: true
|
|
114
|
+
content:
|
|
115
|
+
application/json:
|
|
116
|
+
schema:
|
|
117
|
+
properties:
|
|
118
|
+
name: { type: string }
|
|
119
|
+
responses:
|
|
120
|
+
"200": { description: ok }
|
|
121
|
+
`);
|
|
122
|
+
const violations = findSpecConformanceViolations("untyped-fixture", doc);
|
|
123
|
+
assert.ok(
|
|
124
|
+
violations.some((v) => v.includes("no explicit `type`")),
|
|
125
|
+
`expected an explicit-type violation, got:\n${violations.join("\n")}`,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Real-spec MCP projection conformance guard (ADR 0067 · epic nanobpm/nano-ide#501, P2 #504).
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// The `check:mcp` parity guard exercises the projection's branches on a MINIMAL SYNTHETIC document
|
|
5
|
+
// (`mcp-parity.fixture.yaml`). That is exactly why the `$ref`-body defect shipped: the fixture never
|
|
6
|
+
// contained a `$ref`-bodied operation, while a real consumer app's `openapi.yaml` (nano-workforce,
|
|
7
|
+
// ADR 0067's first consumer) does. This guard closes that gap — it projects the projection over
|
|
8
|
+
// VENDORED, pinned, REAL consumer specs and asserts, for every projected (non-`x-mcp`-excluded)
|
|
9
|
+
// tool, the two properties the fix established:
|
|
10
|
+
//
|
|
11
|
+
// - P0 (#502) — the tool's `inputSchema` is SELF-CONTAINED: no unresolved `$ref` (a client cannot
|
|
12
|
+
// follow `#/components/...` outside the source document) and an explicit `type` on `body`.
|
|
13
|
+
// - P1 (#503) — an object/array body ROUND-TRIPS through the real transport: an object argument
|
|
14
|
+
// reaches the door as an object, and a pre-encoded JSON-string body is parsed once, never
|
|
15
|
+
// double-encoded into a quoted string the door would reject.
|
|
16
|
+
//
|
|
17
|
+
// It reuses the EXACT projection + transport code both properties landed in (`toolInputSchema` /
|
|
18
|
+
// `findToolSchemaViolations` from `openapi/spec.ts`, and `normalizeBodyArg` from `mcp.ts`) rather
|
|
19
|
+
// than reimplementing a second, driftable copy (ADR 0053) — so a regression in either surface is
|
|
20
|
+
// caught here over a real spec, not discovered by a client. It needs NO running instance: the
|
|
21
|
+
// vendored file is projected directly.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { dirname, resolve } from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
collectOperations,
|
|
29
|
+
findToolSchemaViolations,
|
|
30
|
+
type OpenApiDoc,
|
|
31
|
+
type OperationInfo,
|
|
32
|
+
parseSpec,
|
|
33
|
+
toolInputSchema,
|
|
34
|
+
} from "../openapi/spec.ts";
|
|
35
|
+
import { effectiveRequestBodySchema, normalizeBodyArg, structuredBodyKind } from "./core/modules/mcp.ts";
|
|
36
|
+
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const SPECS_DIR = resolve(HERE, "../openapi/consumer-specs");
|
|
39
|
+
|
|
40
|
+
/** A vendored, pinned consumer OpenAPI spec the MCP projection is held to. Pinned to a specific
|
|
41
|
+
* commit so the guard is deterministic and offline (no live-instance dependency). The `ref` here is
|
|
42
|
+
* the single in-code source of truth for the pin — `mcp-conformance.test.ts` asserts it is a full
|
|
43
|
+
* 40-hex commit SHA (so a mutable branch/tag ref can't make the guard non-deterministic), and
|
|
44
|
+
* `consumer-specs/README.md` documents the same commit. The test does not verify that `ref` matches
|
|
45
|
+
* the vendored bytes' provenance, so keep code, README, and bytes in sync when refreshing a pin. */
|
|
46
|
+
export interface ConsumerSpec {
|
|
47
|
+
/** Short label used in violation messages, e.g. "nano-workforce". */
|
|
48
|
+
label: string;
|
|
49
|
+
/** Source repository the spec was vendored from, `owner/repo`. */
|
|
50
|
+
repo: string;
|
|
51
|
+
/** The commit SHA the vendored file is pinned to. */
|
|
52
|
+
ref: string;
|
|
53
|
+
/** Absolute path to the vendored `openapi.yaml`. */
|
|
54
|
+
file: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The conformance corpus: real hosted-app specs, pinned. nano-workforce first (ADR 0067's first
|
|
58
|
+
* consumer, where the `$ref`-body mismatch surfaced). Add further consumer specs here as they
|
|
59
|
+
* adopt the projection. */
|
|
60
|
+
export const CONSUMER_SPECS: ConsumerSpec[] = [
|
|
61
|
+
{
|
|
62
|
+
label: "nano-workforce",
|
|
63
|
+
repo: "nanobpm/nano-workforce",
|
|
64
|
+
ref: "2018020a290c2f416e703e3584b27f92ccf27753",
|
|
65
|
+
file: resolve(SPECS_DIR, "nano-workforce.openapi.yaml"),
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** The transport normalizer the round-trip property is checked against — the real
|
|
70
|
+
* {@link normalizeBodyArg} in production, injectable so a test can prove a REINTRODUCED
|
|
71
|
+
* double-encoding path (a normalizer that leaves a pre-encoded string body a string) fails the
|
|
72
|
+
* guard. */
|
|
73
|
+
export type BodyNormalizer = (
|
|
74
|
+
op: OperationInfo,
|
|
75
|
+
args: Record<string, unknown>,
|
|
76
|
+
doc: OpenApiDoc | undefined,
|
|
77
|
+
) => { ok: true; args: Record<string, unknown> } | { ok: false; error: string };
|
|
78
|
+
|
|
79
|
+
function describe(e: unknown): string {
|
|
80
|
+
return e instanceof Error ? e.message : String(e);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a value is the structured JSON shape the operation declares — an object for an `"object"`
|
|
84
|
+
* body, an array for an `"array"` body. Used to assert a body reaches the door as the right shape
|
|
85
|
+
* (not a JSON string, not a scalar). */
|
|
86
|
+
function isKind(value: unknown, kind: "object" | "array"): boolean {
|
|
87
|
+
if (kind === "array") return Array.isArray(value);
|
|
88
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The transport round-trip property (P1) for one structured-body operation, checked against the
|
|
92
|
+
* real {@link normalizeBodyArg}. A representative empty instance of the declared shape must reach
|
|
93
|
+
* the door as that shape both when the client sends it as a value AND when the client sends it
|
|
94
|
+
* pre-encoded as a JSON string — the latter is what catches a reintroduced double-encoding path. */
|
|
95
|
+
function checkBodyRoundTrip(
|
|
96
|
+
label: string,
|
|
97
|
+
op: OperationInfo,
|
|
98
|
+
kind: "object" | "array",
|
|
99
|
+
doc: OpenApiDoc,
|
|
100
|
+
normalizeBody: BodyNormalizer,
|
|
101
|
+
): string[] {
|
|
102
|
+
const violations: string[] = [];
|
|
103
|
+
const sample: unknown = kind === "array" ? [] : {};
|
|
104
|
+
|
|
105
|
+
// (1) An object/array argument passes through the transport as that structured value.
|
|
106
|
+
const asValue = normalizeBody(op, { body: sample }, doc);
|
|
107
|
+
if (!asValue.ok) {
|
|
108
|
+
violations.push(`${label}: ${op.operationId}: object-body argument rejected by transport: ${asValue.error}`);
|
|
109
|
+
} else if (!isKind(asValue.args.body, kind)) {
|
|
110
|
+
violations.push(
|
|
111
|
+
`${label}: ${op.operationId}: object-body argument did not reach the door as a JSON ${kind}`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// (2) A pre-encoded JSON string round-trips to the structured value — NOT a double-encoded string.
|
|
116
|
+
const asString = normalizeBody(op, { body: JSON.stringify(sample) }, doc);
|
|
117
|
+
if (!asString.ok) {
|
|
118
|
+
violations.push(
|
|
119
|
+
`${label}: ${op.operationId}: pre-encoded JSON-string body rejected by transport: ${asString.error}`,
|
|
120
|
+
);
|
|
121
|
+
} else if (!isKind(asString.args.body, kind)) {
|
|
122
|
+
violations.push(
|
|
123
|
+
`${label}: ${op.operationId}: pre-encoded JSON-string body did not round-trip to a JSON ${kind} ` +
|
|
124
|
+
"(a double-encoding path was reintroduced)",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return violations;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Every conformance violation the projection would advertise for one spec document. Empty means
|
|
132
|
+
* every projected tool is self-contained (P0) and every object/array body round-trips faithfully
|
|
133
|
+
* through the real transport (P1). `normalizeBody` is injectable for negative tests; production uses
|
|
134
|
+
* the real {@link normalizeBodyArg}. */
|
|
135
|
+
export function findSpecConformanceViolations(
|
|
136
|
+
label: string,
|
|
137
|
+
doc: OpenApiDoc,
|
|
138
|
+
normalizeBody: BodyNormalizer = normalizeBodyArg,
|
|
139
|
+
): string[] {
|
|
140
|
+
let ops: OperationInfo[];
|
|
141
|
+
try {
|
|
142
|
+
// `collectOperations` deep-resolves each projected op's `$ref`s and throws loudly on a cyclic or
|
|
143
|
+
// unresolvable ref — a spec that cannot project a self-contained schema fails HERE.
|
|
144
|
+
ops = collectOperations(doc);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return [`${label}: projection failed (unresolvable or cyclic $ref?): ${describe(e)}`];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const violations: string[] = [];
|
|
150
|
+
for (const op of ops) {
|
|
151
|
+
if (op.mcpExcluded) continue;
|
|
152
|
+
|
|
153
|
+
// P0: the projected input schema must be self-contained (no `$ref`, `body` explicitly typed).
|
|
154
|
+
let inputSchema: Record<string, unknown>;
|
|
155
|
+
try {
|
|
156
|
+
inputSchema = toolInputSchema(op);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
violations.push(`${label}: ${op.operationId}: input schema could not be resolved: ${describe(e)}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const v of findToolSchemaViolations(op.operationId, inputSchema)) {
|
|
162
|
+
violations.push(`${label}: ${v}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// P1: an object/array body must round-trip through the real transport.
|
|
166
|
+
const kind = structuredBodyKind(effectiveRequestBodySchema(op, doc));
|
|
167
|
+
if (kind) {
|
|
168
|
+
violations.push(...checkBodyRoundTrip(label, op, kind, doc, normalizeBody));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return violations;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Parse a vendored consumer spec from disk into an {@link OpenApiDoc}. */
|
|
175
|
+
export function loadConsumerSpec(spec: ConsumerSpec): OpenApiDoc {
|
|
176
|
+
return parseSpec(readFileSync(spec.file, "utf8"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One consumer spec's conformance outcome — its label and every violation (empty when it conforms).
|
|
180
|
+
* A spec whose FILE cannot be read/parsed surfaces as a single violation rather than throwing, so
|
|
181
|
+
* the guard reports it uniformly. */
|
|
182
|
+
export interface SpecConformanceResult {
|
|
183
|
+
label: string;
|
|
184
|
+
violations: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Run the conformance guard over the whole {@link CONSUMER_SPECS} corpus (or an injected set). Each
|
|
188
|
+
* spec is loaded and projected independently so one broken spec does not mask the others. */
|
|
189
|
+
export function runConsumerConformance(specs: readonly ConsumerSpec[] = CONSUMER_SPECS): SpecConformanceResult[] {
|
|
190
|
+
return specs.map((spec) => {
|
|
191
|
+
let doc: OpenApiDoc;
|
|
192
|
+
try {
|
|
193
|
+
doc = loadConsumerSpec(spec);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { label: spec.label, violations: [`${spec.label}: cannot load ${spec.file}: ${describe(e)}`] };
|
|
196
|
+
}
|
|
197
|
+
return { label: spec.label, violations: findSpecConformanceViolations(spec.label, doc) };
|
|
198
|
+
});
|
|
199
|
+
}
|