@nanobpm/nano-workforce 0.158.1 → 0.159.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.158.1",
3
+ "version": "0.159.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -49,6 +49,8 @@
49
49
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
50
50
  "sync:nav": "node --experimental-strip-types scripts/sync-nav.ts",
51
51
  "sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
52
+ "gen:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts",
53
+ "check:mcp-bodies": "node --experimental-strip-types scripts/inline-mcp-bodies.ts --check",
52
54
  "dev": "urban dev",
53
55
  "pretest": "urban gen",
54
56
  "test": "node --experimental-strip-types --test",
@@ -73,7 +75,8 @@
73
75
  "conventional-changelog-conventionalcommits": "^8.0.0",
74
76
  "linkedom": "^0.18.13",
75
77
  "semantic-release": "^25.0.0",
76
- "typescript": "^5.6.0"
78
+ "typescript": "^5.6.0",
79
+ "yaml": "^2.9.0"
77
80
  },
78
81
  "overrides": {
79
82
  "@semantic-release/npm": "^13.1.5"
@@ -0,0 +1,316 @@
1
+ // Single source of truth for the projected MCP tool INPUT schemas (epic nano-workforce#605, S0).
2
+ //
3
+ // The Urban runtime projects this app's `openapi.yaml` into MCP tools (ADR 0067) — there is
4
+ // intentionally ZERO MCP server code in nwf. The projector (`@nanobpm/urban`
5
+ // `collectOperations` → `toolInputSchema`) copies each operation's request-body schema VERBATIM
6
+ // into the tool's `inputSchema.properties.body`; it does NOT resolve `$ref`s. So a request body
7
+ // authored as `schema: { $ref: "#/components/schemas/DeliveryGraph" }` projects as an opaque
8
+ // `body: { "$ref": … }` a standard MCP client cannot resolve — and, unable to see it is an object,
9
+ // the client stringifies the argument and the door rejects it (`expected object, got string`).
10
+ // The upstream fix (a projector that bundles refs into self-contained schemas) is tracked in
11
+ // nano-ide#501 (P0 #502 self-contained schemas, P1 #503 faithful object-body transport, P2 #504
12
+ // real-spec conformance guard); THIS script is the nwf-side mitigation that keeps the surface
13
+ // callable today: it authors each projected request body as a self-contained, `$ref`-free
14
+ // `type: object` schema INLINE in `openapi.yaml`.
15
+ //
16
+ // Derivation over duplication (AGENTS.md): the `components.schemas` remain the single source of
17
+ // truth. This script DERIVES the inline body by fully dereferencing that component (merging
18
+ // `allOf`, inlining `oneOf` variants, dropping `discriminator` ref-mappings) and splicing the
19
+ // result into the operation's `requestBody` between sentinel markers, in place, without
20
+ // reformatting the rest of the hand-maintained file. Re-run it whenever a source component
21
+ // changes:
22
+ //
23
+ // node --experimental-strip-types scripts/inline-mcp-bodies.ts # write openapi.yaml
24
+ // node --experimental-strip-types scripts/inline-mcp-bodies.ts --check # verify (CI)
25
+ //
26
+ // The convention every projected request-body operation MUST follow (and every later slice that
27
+ // adds one inherits): author the body as a SINGLE top-level `$ref` to a `components.schemas` entry,
28
+ // yielding a `type: object` with inline `properties`, NO `$ref` in the projected schema, and a
29
+ // description that carries the contract; the graph doors (`compileDeliveryGraph`/
30
+ // `previewDeliveryGraph`) additionally carry a worked `example` (enforced by
31
+ // `test/mcp-tool-schemas.test.ts`). `test/mcp-tool-schemas.test.ts` is the runtime drift guard — it
32
+ // runs the REAL urban projector over the spec and fails if any projected tool body reintroduces a
33
+ // `$ref` or loses its explicit type.
34
+ //
35
+ // Drift detection survives inlining: the generated `# BEGIN` sentinel records the source component
36
+ // (`source=#/components/schemas/…`), so on every subsequent run the generator re-derives the inline
37
+ // body from the CURRENT component — even though the operation's `schema:` no longer carries a
38
+ // `$ref` — and `--check` fails if a source component changed but its inline body was not
39
+ // regenerated. (Without the recorded source the check would be a permanent false green: once the
40
+ // `$ref` is inlined there is nothing left to re-derive from.)
41
+ //
42
+ // Mirrors the repo's other derive/verify pairs (layout-bpmn --check, sync-nav --check,
43
+ // check-contracts / reconcile-contracts).
44
+ import { readFileSync, writeFileSync } from "node:fs";
45
+ import process from "node:process";
46
+ // `yaml` (eemeli) is the SAME parser `@nanobpm/urban` uses to read this spec at runtime
47
+ // (openapi/spec.ts), so a round-trip here matches what the projector sees.
48
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
49
+
50
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
51
+ const SPEC_PATH = `${ROOT}openapi.yaml`;
52
+
53
+ const BEGIN_PREFIX = "# BEGIN generated:mcp-body";
54
+ const BEGIN_TAIL = "(scripts/inline-mcp-bodies.ts — do not hand-edit)";
55
+ const END = "# END generated:mcp-body";
56
+
57
+ /** The `# BEGIN` sentinel for a block, embedding the source component so a later run can re-derive. */
58
+ function beginMarker(sourceRef: string): string {
59
+ return `${BEGIN_PREFIX} source=${sourceRef} ${BEGIN_TAIL}`;
60
+ }
61
+
62
+ type Schema = Record<string, unknown>;
63
+
64
+ function isRecord(v: unknown): v is Record<string, unknown> {
65
+ return typeof v === "object" && v !== null && !Array.isArray(v);
66
+ }
67
+
68
+ const HTTP_METHODS = ["get", "put", "post", "delete", "patch"] as const;
69
+
70
+ /**
71
+ * Fully dereference a schema against `components.schemas`, producing a `$ref`-free equivalent:
72
+ * - a `$ref` is replaced by the (recursively dereferenced) target component;
73
+ * - an `allOf` is MERGED into one object (union of `properties`/`required`) so a node variant
74
+ * like `DeliveryNodeAgent` (allOf [DeliveryNodeCommon, {inline}]) becomes one flat `type: object`;
75
+ * - `oneOf`/`anyOf` members are dereferenced but kept as a union;
76
+ * - `discriminator` is dropped (its `mapping` values are `#/components/...` pointers that would
77
+ * dangle once inlined, and MCP clients do not need it);
78
+ * - every other keyword is copied through, recursing into `properties`/`items`/arrays.
79
+ * Cycles (none today) are guarded by the visited-name set.
80
+ */
81
+ function deref(node: unknown, comps: Record<string, unknown>, seen: ReadonlySet<string>): unknown {
82
+ if (Array.isArray(node)) return node.map((n) => deref(n, comps, seen));
83
+ if (!isRecord(node)) return node;
84
+
85
+ if (typeof node.$ref === "string") {
86
+ const m = node.$ref.match(/^#\/components\/schemas\/(.+)$/);
87
+ if (!m) throw new Error(`non-local $ref cannot be inlined: ${node.$ref}`);
88
+ const name = m[1];
89
+ if (seen.has(name)) throw new Error(`cyclic $ref: ${name}`);
90
+ const target = comps[name];
91
+ if (!target) throw new Error(`dangling $ref: ${node.$ref}`);
92
+ return deref(target, comps, new Set(seen).add(name));
93
+ }
94
+
95
+ if (Array.isArray(node.allOf)) {
96
+ const props: Record<string, unknown> = {};
97
+ const required: string[] = [];
98
+ const merged: Schema = { type: "object", properties: props, required };
99
+ let additionalProperties: unknown;
100
+ for (const part of node.allOf) {
101
+ const d = deref(part, comps, seen);
102
+ if (!isRecord(d)) continue;
103
+ if (isRecord(d.properties)) Object.assign(props, d.properties);
104
+ if (Array.isArray(d.required)) {
105
+ for (const r of d.required) if (typeof r === "string") required.push(r);
106
+ }
107
+ if ("additionalProperties" in d) additionalProperties = d.additionalProperties;
108
+ if (typeof d.type === "string") merged.type = d.type;
109
+ }
110
+ // Sibling keywords authored alongside `allOf` (e.g. `description`) win over the merged parts.
111
+ for (const [k, v] of Object.entries(node)) {
112
+ if (k === "allOf") continue;
113
+ merged[k] = deref(v, comps, seen);
114
+ }
115
+ if (required.length === 0) delete merged.required;
116
+ else merged.required = [...new Set(required)];
117
+ if (additionalProperties !== undefined && !("additionalProperties" in node)) {
118
+ merged.additionalProperties = additionalProperties;
119
+ }
120
+ return merged;
121
+ }
122
+
123
+ const out: Schema = {};
124
+ for (const [k, v] of Object.entries(node)) {
125
+ if (k === "discriminator") continue;
126
+ out[k] = deref(v, comps, seen);
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** Does this (raw, un-dereferenced) schema contain a `$ref` anywhere? */
132
+ function hasRef(node: unknown): boolean {
133
+ if (Array.isArray(node)) return node.some(hasRef);
134
+ if (!isRecord(node)) return false;
135
+ if (typeof node.$ref === "string") return true;
136
+ return Object.values(node).some(hasRef);
137
+ }
138
+
139
+ /** `false` iff the operation is `x-mcp`-excluded (operator-only door). */
140
+ function isProjected(op: Record<string, unknown>): boolean {
141
+ const x = op["x-mcp"];
142
+ if (x === false) return false;
143
+ if (isRecord(x) && x.exclude === true) return false;
144
+ return true;
145
+ }
146
+
147
+ interface Target {
148
+ operationId: string;
149
+ sourceRef: string;
150
+ inline: Schema;
151
+ }
152
+
153
+ /**
154
+ * Recover `operationId -> source component $ref` from the `# BEGIN … source=…` sentinels already in
155
+ * the raw text. This is what lets `--check` keep detecting drift AFTER the `$ref` has been inlined:
156
+ * the operation's parsed `schema:` no longer carries a `$ref`, but the recorded source does, so the
157
+ * generator can re-derive the block from the current component. Scans by operation region (the same
158
+ * indentation-free `operationId:` split `spliceSchema` uses) so a block is attributed to its owner.
159
+ */
160
+ function recordedSources(text: string): Map<string, string> {
161
+ const out = new Map<string, string>();
162
+ const opRe = /^ *operationId: (\S+)/gm;
163
+ const ops: Array<{ id: string; at: number }> = [];
164
+ for (let m = opRe.exec(text); m !== null; m = opRe.exec(text)) {
165
+ ops.push({ id: m[1], at: m.index });
166
+ }
167
+ for (let i = 0; i < ops.length; i++) {
168
+ const end = i + 1 < ops.length ? ops[i + 1].at : text.length;
169
+ const region = text.slice(ops[i].at, end);
170
+ const bm = region.match(/# BEGIN generated:mcp-body source=(\S+)/);
171
+ if (bm) out.set(ops[i].id, bm[1]);
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Collect the projected request-body operations to (re)generate. A body is a managed target when it
178
+ * is authored as a single top-level `$ref` (first authoring) OR already carries a generated block
179
+ * whose source component was recorded in its sentinel (subsequent runs). Either way the inline body
180
+ * is DERIVED from the CURRENT `components.schemas`, so a source-component change is always re-derived
181
+ * (and caught by `--check`). A projected body that leaks a NON-top-level `$ref` violates the
182
+ * single-top-level-`$ref` convention and is rejected loudly rather than silently half-inlined.
183
+ */
184
+ function collectTargets(doc: Record<string, unknown>, recorded: Map<string, string>): Target[] {
185
+ const comps: Record<string, unknown> =
186
+ isRecord(doc.components) && isRecord(doc.components.schemas) ? doc.components.schemas : {};
187
+ const paths = isRecord(doc.paths) ? doc.paths : {};
188
+ const targets: Target[] = [];
189
+ for (const item of Object.values(paths)) {
190
+ if (!isRecord(item)) continue;
191
+ for (const method of HTTP_METHODS) {
192
+ const op = item[method];
193
+ if (!isRecord(op) || typeof op.operationId !== "string") continue;
194
+ if (!isProjected(op)) continue;
195
+ const body = isRecord(op.requestBody) ? op.requestBody : undefined;
196
+ const jsonNode =
197
+ body && isRecord(body.content) && isRecord(body.content["application/json"])
198
+ ? body.content["application/json"]
199
+ : undefined;
200
+ const json = isRecord(jsonNode) ? jsonNode.schema : undefined;
201
+ if (!isRecord(json)) continue;
202
+ let sourceRef: string | undefined;
203
+ if (typeof json.$ref === "string") {
204
+ sourceRef = json.$ref;
205
+ } else if (recorded.has(op.operationId)) {
206
+ sourceRef = recorded.get(op.operationId);
207
+ } else {
208
+ if (hasRef(json)) {
209
+ throw new Error(
210
+ `${op.operationId}: projected request body must be authored as a single top-level ` +
211
+ "`$ref` to a components.schemas entry (found a nested `$ref`) — see the MCP schema " +
212
+ "convention in openapi.yaml.",
213
+ );
214
+ }
215
+ continue;
216
+ }
217
+ if (sourceRef === undefined) continue;
218
+ const derefed = deref({ $ref: sourceRef }, comps, new Set());
219
+ const inline: Schema = isRecord(derefed) ? derefed : {};
220
+ if (typeof inline.type !== "string") {
221
+ // A `oneOf`/`anyOf` body root has no single `type`; every projected body IS an object, so
222
+ // pin the explicit `type: object` the MCP contract requires alongside the variant union.
223
+ inline.type = "object";
224
+ }
225
+ targets.push({ operationId: op.operationId, sourceRef, inline });
226
+ }
227
+ }
228
+ targets.sort((a, b) => a.operationId.localeCompare(b.operationId));
229
+ return targets;
230
+ }
231
+
232
+ /** Render an inline schema as a YAML block indented to `pad` spaces, wrapped in the sentinels. */
233
+ function renderBlock(inline: Schema, pad: string, sourceRef: string): string {
234
+ const dumped = stringifyYaml(inline, { indent: 2, lineWidth: 0, singleQuote: true });
235
+ const body = dumped
236
+ .replace(/\n$/, "")
237
+ .split("\n")
238
+ .map((line) => (line.length ? pad + line : line))
239
+ .join("\n");
240
+ return `${pad}${beginMarker(sourceRef)}\n${body}\n${pad}${END}`;
241
+ }
242
+
243
+ /**
244
+ * Replace the `requestBody` → `application/json` → `schema:` value of `operationId` in the raw
245
+ * text with `block`, in place. Returns the new text. Boundaries are found by indentation, the same
246
+ * span technique `scripts/sync-nav.ts` uses, so nothing else in the file is reformatted.
247
+ */
248
+ function spliceSchema(text: string, operationId: string, inline: Schema, sourceRef: string): string {
249
+ const opMarker = `operationId: ${operationId}\n`;
250
+ const opAt = text.indexOf(opMarker);
251
+ if (opAt < 0) throw new Error(`operationId not found in text: ${operationId}`);
252
+ // Bound the search to this operation (up to the next operationId).
253
+ const nextOp = text.indexOf("operationId: ", opAt + opMarker.length);
254
+ const region = nextOp < 0 ? text.slice(opAt) : text.slice(opAt, nextOp);
255
+ const rbRel = region.indexOf("requestBody:");
256
+ if (rbRel < 0) throw new Error(`requestBody not found for ${operationId}`);
257
+ // First `schema:` after requestBody is the request body schema.
258
+ const schemaRel = region.indexOf("schema:", rbRel);
259
+ if (schemaRel < 0) throw new Error(`request schema not found for ${operationId}`);
260
+ const schemaAbs = opAt + schemaRel;
261
+ const lineStart = text.lastIndexOf("\n", schemaAbs) + 1;
262
+ const schemaIndent = schemaAbs - lineStart; // columns before `schema:`
263
+ const pad = " ".repeat(schemaIndent + 2); // schema value is nested one level deeper
264
+ // The schema value spans from the end of the `schema:` line to the first following line whose
265
+ // indent is <= the `schema:` line's indent (a sibling/closing key), skipping blank lines.
266
+ const afterSchemaLine = text.indexOf("\n", schemaAbs) + 1;
267
+ const lines = text.slice(afterSchemaLine).split("\n");
268
+ let consumed = 0;
269
+ for (const line of lines) {
270
+ if (line.trim() === "") {
271
+ consumed += line.length + 1;
272
+ continue;
273
+ }
274
+ const indent = line.length - line.trimStart().length;
275
+ if (indent <= schemaIndent) break;
276
+ consumed += line.length + 1;
277
+ }
278
+ const valueEnd = afterSchemaLine + consumed;
279
+ const block = renderBlock(inline, pad, sourceRef);
280
+ return `${text.slice(0, afterSchemaLine)}${block}\n${text.slice(valueEnd)}`;
281
+ }
282
+
283
+ function generate(text: string): string {
284
+ const parsed = parseYaml(text);
285
+ const doc: Record<string, unknown> = isRecord(parsed) ? parsed : {};
286
+ const targets = collectTargets(doc, recordedSources(text));
287
+ let out = text;
288
+ for (const t of targets) out = spliceSchema(out, t.operationId, t.inline, t.sourceRef);
289
+ return out;
290
+ }
291
+
292
+ function main(): void {
293
+ const check = process.argv.includes("--check");
294
+ const original = readFileSync(SPEC_PATH, "utf8");
295
+ const next = generate(original);
296
+ if (check) {
297
+ if (next !== original) {
298
+ process.stderr.write(
299
+ "openapi.yaml projected MCP request bodies are STALE.\n" +
300
+ "A source component schema changed but its inline `body` was not regenerated.\n" +
301
+ "Run: npm run gen:mcp-bodies (node --experimental-strip-types scripts/inline-mcp-bodies.ts)\n",
302
+ );
303
+ process.exit(1);
304
+ }
305
+ process.stdout.write("openapi.yaml projected MCP request bodies are up to date.\n");
306
+ return;
307
+ }
308
+ if (next !== original) {
309
+ writeFileSync(SPEC_PATH, next);
310
+ process.stdout.write("openapi.yaml projected MCP request bodies regenerated.\n");
311
+ } else {
312
+ process.stdout.write("openapi.yaml projected MCP request bodies already up to date.\n");
313
+ }
314
+ }
315
+
316
+ main();
@@ -0,0 +1,127 @@
1
+ // Drift guard for the projected MCP tool INPUT schemas (epic nano-workforce#605, S0).
2
+ //
3
+ // The Urban runtime projects `openapi.yaml` into MCP tools (ADR 0067) and copies each operation's
4
+ // request-body schema VERBATIM into the tool's `inputSchema.properties.body` — it does NOT resolve
5
+ // `$ref`s. A leaked `$ref` is therefore unresolvable in a standard MCP client, and, unable to see
6
+ // the body is an object, the client stringifies the argument and the door rejects it
7
+ // (`expected object, got string`). This test drives the REAL projector (`collectOperations` from
8
+ // `@nanobpm/urban`, plus the runtime's own `toolInputSchema` shape) over the checked-in spec and
9
+ // fails the build if any projected tool body reintroduces a `$ref`, loses its explicit `type`, or a
10
+ // graph door drops its worked example. It is the runtime counterpart to
11
+ // `scripts/inline-mcp-bodies.ts --check` (which guards the derive step); together they keep the
12
+ // convention every later slice inherits: `type: object`, inline `properties`, no `$ref`, an example.
13
+ //
14
+ // Extension seam: a sibling slice adding a projected request-body operation to `openapi.yaml` needs
15
+ // no change here — this walks EVERY projected operation. A new graph door should be added to
16
+ // GRAPH_DOORS so its example is required too.
17
+ import { test } from "node:test";
18
+ import { assert } from "#test-assert";
19
+ import { readFileSync } from "node:fs";
20
+ import { collectOperations, type OperationInfo, parseSpec } from "@nanobpm/urban/toolkit";
21
+
22
+ const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
23
+ // Parse the spec exactly as the runtime does (`@nanobpm/urban/toolkit` `parseSpec`), so this guard
24
+ // sees precisely the document the MCP projector projects.
25
+ const SPEC = parseSpec(readFileSync(`${ROOT}openapi.yaml`, "utf8"));
26
+
27
+ /** Graph doors that MUST carry at least one worked example (epic #605 acceptance). */
28
+ const GRAPH_DOORS = new Set(["compileDeliveryGraph", "previewDeliveryGraph"]);
29
+
30
+ /** The client-visible tool `inputSchema`, reproduced exactly from the runtime's `toolInputSchema`
31
+ * (mcp.ts) — path/query params plus the request body copied verbatim under `body`. */
32
+ function toolInputSchema(op: OperationInfo): Record<string, unknown> {
33
+ const properties: Record<string, unknown> = {};
34
+ const required: string[] = [];
35
+ for (const p of op.parameters) {
36
+ if (p.in === "path" || p.in === "query") {
37
+ properties[p.name] = p.schema ?? {};
38
+ if (p.required) required.push(p.name);
39
+ }
40
+ }
41
+ if (op.requestBodySchema) {
42
+ properties.body = op.requestBodySchema;
43
+ if (op.requestBodyRequired) required.push("body");
44
+ }
45
+ const schema: Record<string, unknown> = { type: "object", properties };
46
+ if (required.length > 0) schema.required = required;
47
+ return schema;
48
+ }
49
+
50
+ /** Every `$ref` string reachable in a schema, with a JSON-path for the failure message. */
51
+ function findRefs(node: unknown, path: string, out: string[]): void {
52
+ if (Array.isArray(node)) {
53
+ node.forEach((n, i) => findRefs(n, `${path}[${i}]`, out));
54
+ return;
55
+ }
56
+ if (typeof node !== "object" || node === null) return;
57
+ for (const [k, v] of Object.entries(node)) {
58
+ if (k === "$ref" && typeof v === "string") out.push(`${path}.$ref -> ${v}`);
59
+ else findRefs(v, `${path}.${k}`, out);
60
+ }
61
+ }
62
+
63
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
64
+ typeof v === "object" && v !== null && !Array.isArray(v);
65
+
66
+ /** The projected operations whose tool carries a request body (the object-body tools). */
67
+ function projectedBodyOps(): OperationInfo[] {
68
+ return collectOperations(SPEC).filter((op) => !op.mcpExcluded && op.requestBodySchema);
69
+ }
70
+
71
+ test("every projected object-body tool schema is $ref-free (self-contained)", () => {
72
+ const ops = projectedBodyOps();
73
+ assert(ops.length >= 10, `expected the full projected object-body surface, saw ${ops.length}`);
74
+ for (const op of ops) {
75
+ const refs: string[] = [];
76
+ findRefs(toolInputSchema(op).properties, `${op.operationId}.properties`, refs);
77
+ assert(
78
+ refs.length === 0,
79
+ `${op.operationId}: projected tool schema leaks $ref(s) a standard MCP client cannot ` +
80
+ `resolve — inline the component(s) (run npm run gen:mcp-bodies): ${refs.join(", ")}`,
81
+ );
82
+ }
83
+ });
84
+
85
+ test("every projected object-body tool declares an explicit body type: object", () => {
86
+ for (const op of projectedBodyOps()) {
87
+ const body = op.requestBodySchema as Record<string, unknown>;
88
+ // A `oneOf`/`anyOf` body still carries an explicit `type: object` (the generator pins it) so a
89
+ // client knows to pass an object, not a string.
90
+ assert(
91
+ body.type === "object",
92
+ `${op.operationId}: request body must declare an explicit \`type: object\` (saw ` +
93
+ `type=${JSON.stringify(body.type)})`,
94
+ );
95
+ assert(
96
+ isRecord(body.properties) || Array.isArray(body.oneOf) || Array.isArray(body.anyOf),
97
+ `${op.operationId}: request body must expose inline \`properties\` (or a \`oneOf\`/\`anyOf\` ` +
98
+ "of object variants) so the shape is discoverable from the tool surface",
99
+ );
100
+ }
101
+ });
102
+
103
+ test("each graph door carries at least one worked example", () => {
104
+ const byId = new Map(projectedBodyOps().map((op) => [op.operationId, op]));
105
+ for (const door of GRAPH_DOORS) {
106
+ const op = byId.get(door);
107
+ assert(op, `expected graph door ${door} to be a projected object-body tool`);
108
+ const body = op!.requestBodySchema as Record<string, unknown>;
109
+ const hasExample =
110
+ "example" in body ||
111
+ "examples" in body ||
112
+ (isRecord(body.properties) &&
113
+ Object.values(body.properties).some((p) => isRecord(p) && "example" in p));
114
+ assert(hasExample, `${door}: request body must embed a worked \`example\` (§9.5 canonical graph)`);
115
+ }
116
+ });
117
+
118
+ test("operator-only delivery-graph doors stay withheld from the MCP tool surface", () => {
119
+ const excluded = new Set(
120
+ collectOperations(SPEC)
121
+ .filter((op) => op.mcpExcluded)
122
+ .map((op) => op.operationId),
123
+ );
124
+ for (const door of ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"]) {
125
+ assert(excluded.has(door), `${door} must remain x-mcp-excluded (operator-only dispatch gate)`);
126
+ }
127
+ });