@nanobpm/urban 0.87.1 → 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.
@@ -367,7 +367,7 @@ function toHttpRequest(
367
367
  * `collectOperations`). As defence in depth, if that metadata is somehow still a bare `$ref` (it
368
368
  * should never be after resolution), re-resolve it via {@link resolveSchema} against `doc` so the
369
369
  * classification below never keys off an opaque `#/components/...` pointer. */
370
- function effectiveRequestBodySchema(
370
+ export function effectiveRequestBodySchema(
371
371
  op: OperationInfo,
372
372
  doc: OpenApiDoc | undefined,
373
373
  ): OpenApiSchema | undefined {
@@ -380,7 +380,7 @@ function effectiveRequestBodySchema(
380
380
  * expects as a JSON object/array rather than a bare scalar. Classified from the RESOLVED schema so a
381
381
  * `$ref`-bodied op (e.g. `previewDeliveryGraph`, whose object `type` is only visible after P0's
382
382
  * resolution) is recognized, not merely an inline-`type` object body. */
383
- function structuredBodyKind(schema: OpenApiSchema | undefined): "object" | "array" | undefined {
383
+ export function structuredBodyKind(schema: OpenApiSchema | undefined): "object" | "array" | undefined {
384
384
  if (!schema) return undefined;
385
385
  if (isObjectSchema(schema)) return "object";
386
386
  if (schemaHasType(schema, "array")) return "array";
@@ -396,7 +396,7 @@ function structuredBodyKind(schema: OpenApiSchema | undefined): "object" | "arra
396
396
  * passes through untouched (still serialized by the transport). A string that does not parse to the
397
397
  * declared shape is a CLEAR tool error naming the expected shape — never a silently double-encoded
398
398
  * (and 4xx-rejected) request. */
399
- function normalizeBodyArg(
399
+ export function normalizeBodyArg(
400
400
  op: OperationInfo,
401
401
  args: Record<string, unknown>,
402
402
  doc: OpenApiDoc | undefined,
@@ -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
+ }