@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.
@@ -1,3 +1,4 @@
1
+ import { type OpenApiDoc, type OpenApiSchema, type OperationInfo } from "../../../openapi/spec.ts";
1
2
  import type { AppApi, RuntimeContext } from "../context.ts";
2
3
  import type { HttpRequest } from "../host.ts";
3
4
  import { type Route } from "../router.ts";
@@ -64,6 +65,33 @@ export declare function readHeader(headers: Record<string, string | string[] | u
64
65
  * required is enough to have missing (or whitespace-only) calls rejected rather than silently
65
66
  * substituted with an empty string. */
66
67
  export declare function missingRequiredArgs(inputSchema: Record<string, unknown>, args: Record<string, unknown>): string[];
68
+ /** The operation's effective request-body schema for classifying a tool-call `body`: P0's resolved,
69
+ * `$ref`-free schema ({@link OperationInfo.requestBodySchemaResolved}, produced by
70
+ * `collectOperations`). As defence in depth, if that metadata is somehow still a bare `$ref` (it
71
+ * should never be after resolution), re-resolve it via {@link resolveSchema} against `doc` so the
72
+ * classification below never keys off an opaque `#/components/...` pointer. */
73
+ export declare function effectiveRequestBodySchema(op: OperationInfo, doc: OpenApiDoc | undefined): OpenApiSchema | undefined;
74
+ /** Whether the operation declares a STRUCTURED (object/array) request body — the shapes the app door
75
+ * expects as a JSON object/array rather than a bare scalar. Classified from the RESOLVED schema so a
76
+ * `$ref`-bodied op (e.g. `previewDeliveryGraph`, whose object `type` is only visible after P0's
77
+ * resolution) is recognized, not merely an inline-`type` object body. */
78
+ export declare function structuredBodyKind(schema: OpenApiSchema | undefined): "object" | "array" | undefined;
79
+ /** Normalize a tool-call `body` argument for FAITHFUL transport (ADR 0067). A client that saw an
80
+ * opaque input schema — or that coerces non-primitive args — may send `body` as a pre-encoded JSON
81
+ * *string* instead of the object/array the operation declares. Left as-is, `toHttpRequest`'s
82
+ * `JSON.stringify` would DOUBLE-encode it into a quoted string and the door would reject it with
83
+ * "expected object, got string". So when the op declares an object/array body and `body` arrives as
84
+ * a string, parse it once so it round-trips as the structured value. A genuine object/array argument
85
+ * passes through untouched (still serialized by the transport). A string that does not parse to the
86
+ * declared shape is a CLEAR tool error naming the expected shape — never a silently double-encoded
87
+ * (and 4xx-rejected) request. */
88
+ export declare function normalizeBodyArg(op: OperationInfo, args: Record<string, unknown>, doc: OpenApiDoc | undefined): {
89
+ ok: true;
90
+ args: Record<string, unknown>;
91
+ } | {
92
+ ok: false;
93
+ error: string;
94
+ };
67
95
  /**
68
96
  * Mount the runtime-served MCP surface at `/app/mcp` (ADR 0067). Returns a single `*`-method route
69
97
  * (mounted unconditionally, like `/app/agent`); the handler bridges the host request/response seam
@@ -34,7 +34,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
34
34
  import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, isInitializeRequest, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
35
35
  import { GENERATED_DIR } from "../../../toolkit/artifact.js";
36
36
  import { SYSTEM_BRIEF_JSON } from "../../../toolkit/derivers/system-brief.js";
37
- import { collectOperations, evaluateSecurity, isMutatingMethod, parseSpec, sharedSecretSchemeName, toolInputSchema, } from "../../../openapi/spec.js";
37
+ import { collectOperations, evaluateSecurity, isMutatingMethod, isObjectSchema, parseSpec, resolveSchema, schemaHasType, sharedSecretSchemeName, toolInputSchema, } from "../../../openapi/spec.js";
38
38
  import { errorMessage } from "../guards.js";
39
39
  import { presentFormIdentifier } from "../host.js";
40
40
  import { resolveBindMode } from "../manifest.js";
@@ -257,6 +257,10 @@ export function missingRequiredArgs(inputSchema, args) {
257
257
  * read-only) app operation is then guarded exactly as its HTTP route is — the app operation tools
258
258
  * inherit the OpenAPI `security` contract rather than a bespoke MCP gate (ADR 0067 §4). */
259
259
  function toHttpRequest(op, args, forwardHeaders) {
260
+ // `args.body` is expected to already be the structured value the operation declares — a
261
+ // pre-encoded JSON-string body is normalized upstream by `normalizeBodyArg` (called in
262
+ // `dispatchOperationTool`) so `JSON.stringify` here serializes an object/array ONCE rather than
263
+ // double-encoding a quoted string the door would reject (ADR 0067).
260
264
  const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
261
265
  ? JSON.stringify(args.body)
262
266
  : "";
@@ -277,6 +281,69 @@ function toHttpRequest(op, args, forwardHeaders) {
277
281
  text: () => Promise.resolve(bodyText),
278
282
  };
279
283
  }
284
+ /** The operation's effective request-body schema for classifying a tool-call `body`: P0's resolved,
285
+ * `$ref`-free schema ({@link OperationInfo.requestBodySchemaResolved}, produced by
286
+ * `collectOperations`). As defence in depth, if that metadata is somehow still a bare `$ref` (it
287
+ * should never be after resolution), re-resolve it via {@link resolveSchema} against `doc` so the
288
+ * classification below never keys off an opaque `#/components/...` pointer. */
289
+ export function effectiveRequestBodySchema(op, doc) {
290
+ const schema = op.requestBodySchemaResolved ?? op.requestBodySchema;
291
+ if (schema?.$ref)
292
+ return doc ? resolveSchema(doc, schema) : undefined;
293
+ return schema;
294
+ }
295
+ /** Whether the operation declares a STRUCTURED (object/array) request body — the shapes the app door
296
+ * expects as a JSON object/array rather than a bare scalar. Classified from the RESOLVED schema so a
297
+ * `$ref`-bodied op (e.g. `previewDeliveryGraph`, whose object `type` is only visible after P0's
298
+ * resolution) is recognized, not merely an inline-`type` object body. */
299
+ export function structuredBodyKind(schema) {
300
+ if (!schema)
301
+ return undefined;
302
+ if (isObjectSchema(schema))
303
+ return "object";
304
+ if (schemaHasType(schema, "array"))
305
+ return "array";
306
+ return undefined;
307
+ }
308
+ /** Normalize a tool-call `body` argument for FAITHFUL transport (ADR 0067). A client that saw an
309
+ * opaque input schema — or that coerces non-primitive args — may send `body` as a pre-encoded JSON
310
+ * *string* instead of the object/array the operation declares. Left as-is, `toHttpRequest`'s
311
+ * `JSON.stringify` would DOUBLE-encode it into a quoted string and the door would reject it with
312
+ * "expected object, got string". So when the op declares an object/array body and `body` arrives as
313
+ * a string, parse it once so it round-trips as the structured value. A genuine object/array argument
314
+ * passes through untouched (still serialized by the transport). A string that does not parse to the
315
+ * declared shape is a CLEAR tool error naming the expected shape — never a silently double-encoded
316
+ * (and 4xx-rejected) request. */
317
+ export function normalizeBodyArg(op, args, doc) {
318
+ const body = args.body;
319
+ const kind = structuredBodyKind(effectiveRequestBodySchema(op, doc));
320
+ if (body === undefined || kind === undefined || typeof body !== "string") {
321
+ return { ok: true, args };
322
+ }
323
+ let parsed;
324
+ try {
325
+ parsed = JSON.parse(body);
326
+ }
327
+ catch {
328
+ return {
329
+ ok: false,
330
+ error: `validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; the string is not valid JSON`,
331
+ };
332
+ }
333
+ const parsedKind = Array.isArray(parsed)
334
+ ? "array"
335
+ : parsed !== null && typeof parsed === "object"
336
+ ? "object"
337
+ : undefined;
338
+ if (parsedKind !== kind) {
339
+ const got = parsedKind ?? (parsed === null ? "null" : typeof parsed);
340
+ return {
341
+ ok: false,
342
+ error: `validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; it parsed to a ${got}`,
343
+ };
344
+ }
345
+ return { ok: true, args: { ...args, body: parsed } };
346
+ }
280
347
  /** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
281
348
  * non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
282
349
  function textResult(value, isError = false) {
@@ -716,10 +783,17 @@ export function mountMcp(ctx, app, apiRoutes) {
716
783
  // empty-string substitution — the calling agent sees exactly which required params it omitted.
717
784
  return textResult({ error: `validation failed: missing required path parameter(s): ${missing.join(", ")}` }, true);
718
785
  }
786
+ // Faithful body transport (ADR 0067): a client may send an object/array `body` pre-encoded as a
787
+ // JSON string. Normalize it to the structured value from the operation's RESOLVED body schema so
788
+ // it is not double-encoded by `toHttpRequest`; a string that cannot parse to the declared shape
789
+ // is a clear tool error rather than a silent 4xx from a double-encoded body.
790
+ const normalized = normalizeBodyArg(op, args, (await loadSpecDoc()).doc);
791
+ if (!normalized.ok)
792
+ return textResult({ error: normalized.error }, true);
719
793
  // Forward the MCP connection's headers so the reused router enforces the operation's own
720
794
  // OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
721
795
  // route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
722
- const res = await apiRouter(toHttpRequest(op, args, headers));
796
+ const res = await apiRouter(toHttpRequest(op, normalized.args, headers));
723
797
  const status = res.status ?? 200;
724
798
  return textResult(res.body ?? "", status >= 400);
725
799
  };
@@ -0,0 +1,49 @@
1
+ import { type OpenApiDoc, type OperationInfo } from "../openapi/spec.ts";
2
+ /** A vendored, pinned consumer OpenAPI spec the MCP projection is held to. Pinned to a specific
3
+ * commit so the guard is deterministic and offline (no live-instance dependency). The `ref` here is
4
+ * the single in-code source of truth for the pin — `mcp-conformance.test.ts` asserts it is a full
5
+ * 40-hex commit SHA (so a mutable branch/tag ref can't make the guard non-deterministic), and
6
+ * `consumer-specs/README.md` documents the same commit. The test does not verify that `ref` matches
7
+ * the vendored bytes' provenance, so keep code, README, and bytes in sync when refreshing a pin. */
8
+ export interface ConsumerSpec {
9
+ /** Short label used in violation messages, e.g. "nano-workforce". */
10
+ label: string;
11
+ /** Source repository the spec was vendored from, `owner/repo`. */
12
+ repo: string;
13
+ /** The commit SHA the vendored file is pinned to. */
14
+ ref: string;
15
+ /** Absolute path to the vendored `openapi.yaml`. */
16
+ file: string;
17
+ }
18
+ /** The conformance corpus: real hosted-app specs, pinned. nano-workforce first (ADR 0067's first
19
+ * consumer, where the `$ref`-body mismatch surfaced). Add further consumer specs here as they
20
+ * adopt the projection. */
21
+ export declare const CONSUMER_SPECS: ConsumerSpec[];
22
+ /** The transport normalizer the round-trip property is checked against — the real
23
+ * {@link normalizeBodyArg} in production, injectable so a test can prove a REINTRODUCED
24
+ * double-encoding path (a normalizer that leaves a pre-encoded string body a string) fails the
25
+ * guard. */
26
+ export type BodyNormalizer = (op: OperationInfo, args: Record<string, unknown>, doc: OpenApiDoc | undefined) => {
27
+ ok: true;
28
+ args: Record<string, unknown>;
29
+ } | {
30
+ ok: false;
31
+ error: string;
32
+ };
33
+ /** Every conformance violation the projection would advertise for one spec document. Empty means
34
+ * every projected tool is self-contained (P0) and every object/array body round-trips faithfully
35
+ * through the real transport (P1). `normalizeBody` is injectable for negative tests; production uses
36
+ * the real {@link normalizeBodyArg}. */
37
+ export declare function findSpecConformanceViolations(label: string, doc: OpenApiDoc, normalizeBody?: BodyNormalizer): string[];
38
+ /** Parse a vendored consumer spec from disk into an {@link OpenApiDoc}. */
39
+ export declare function loadConsumerSpec(spec: ConsumerSpec): OpenApiDoc;
40
+ /** One consumer spec's conformance outcome — its label and every violation (empty when it conforms).
41
+ * A spec whose FILE cannot be read/parsed surfaces as a single violation rather than throwing, so
42
+ * the guard reports it uniformly. */
43
+ export interface SpecConformanceResult {
44
+ label: string;
45
+ violations: string[];
46
+ }
47
+ /** Run the conformance guard over the whole {@link CONSUMER_SPECS} corpus (or an injected set). Each
48
+ * spec is loaded and projected independently so one broken spec does not mask the others. */
49
+ export declare function runConsumerConformance(specs?: readonly ConsumerSpec[]): SpecConformanceResult[];
@@ -0,0 +1,132 @@
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
+ import { readFileSync } from "node:fs";
23
+ import { dirname, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { collectOperations, findToolSchemaViolations, parseSpec, toolInputSchema, } from "../openapi/spec.js";
26
+ import { effectiveRequestBodySchema, normalizeBodyArg, structuredBodyKind } from "./core/modules/mcp.js";
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+ const SPECS_DIR = resolve(HERE, "../openapi/consumer-specs");
29
+ /** The conformance corpus: real hosted-app specs, pinned. nano-workforce first (ADR 0067's first
30
+ * consumer, where the `$ref`-body mismatch surfaced). Add further consumer specs here as they
31
+ * adopt the projection. */
32
+ export const CONSUMER_SPECS = [
33
+ {
34
+ label: "nano-workforce",
35
+ repo: "nanobpm/nano-workforce",
36
+ ref: "2018020a290c2f416e703e3584b27f92ccf27753",
37
+ file: resolve(SPECS_DIR, "nano-workforce.openapi.yaml"),
38
+ },
39
+ ];
40
+ function describe(e) {
41
+ return e instanceof Error ? e.message : String(e);
42
+ }
43
+ /** Whether a value is the structured JSON shape the operation declares — an object for an `"object"`
44
+ * body, an array for an `"array"` body. Used to assert a body reaches the door as the right shape
45
+ * (not a JSON string, not a scalar). */
46
+ function isKind(value, kind) {
47
+ if (kind === "array")
48
+ return Array.isArray(value);
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+ /** The transport round-trip property (P1) for one structured-body operation, checked against the
52
+ * real {@link normalizeBodyArg}. A representative empty instance of the declared shape must reach
53
+ * the door as that shape both when the client sends it as a value AND when the client sends it
54
+ * pre-encoded as a JSON string — the latter is what catches a reintroduced double-encoding path. */
55
+ function checkBodyRoundTrip(label, op, kind, doc, normalizeBody) {
56
+ const violations = [];
57
+ const sample = kind === "array" ? [] : {};
58
+ // (1) An object/array argument passes through the transport as that structured value.
59
+ const asValue = normalizeBody(op, { body: sample }, doc);
60
+ if (!asValue.ok) {
61
+ violations.push(`${label}: ${op.operationId}: object-body argument rejected by transport: ${asValue.error}`);
62
+ }
63
+ else if (!isKind(asValue.args.body, kind)) {
64
+ violations.push(`${label}: ${op.operationId}: object-body argument did not reach the door as a JSON ${kind}`);
65
+ }
66
+ // (2) A pre-encoded JSON string round-trips to the structured value — NOT a double-encoded string.
67
+ const asString = normalizeBody(op, { body: JSON.stringify(sample) }, doc);
68
+ if (!asString.ok) {
69
+ violations.push(`${label}: ${op.operationId}: pre-encoded JSON-string body rejected by transport: ${asString.error}`);
70
+ }
71
+ else if (!isKind(asString.args.body, kind)) {
72
+ violations.push(`${label}: ${op.operationId}: pre-encoded JSON-string body did not round-trip to a JSON ${kind} ` +
73
+ "(a double-encoding path was reintroduced)");
74
+ }
75
+ return violations;
76
+ }
77
+ /** Every conformance violation the projection would advertise for one spec document. Empty means
78
+ * every projected tool is self-contained (P0) and every object/array body round-trips faithfully
79
+ * through the real transport (P1). `normalizeBody` is injectable for negative tests; production uses
80
+ * the real {@link normalizeBodyArg}. */
81
+ export function findSpecConformanceViolations(label, doc, normalizeBody = normalizeBodyArg) {
82
+ let ops;
83
+ try {
84
+ // `collectOperations` deep-resolves each projected op's `$ref`s and throws loudly on a cyclic or
85
+ // unresolvable ref — a spec that cannot project a self-contained schema fails HERE.
86
+ ops = collectOperations(doc);
87
+ }
88
+ catch (e) {
89
+ return [`${label}: projection failed (unresolvable or cyclic $ref?): ${describe(e)}`];
90
+ }
91
+ const violations = [];
92
+ for (const op of ops) {
93
+ if (op.mcpExcluded)
94
+ continue;
95
+ // P0: the projected input schema must be self-contained (no `$ref`, `body` explicitly typed).
96
+ let inputSchema;
97
+ try {
98
+ inputSchema = toolInputSchema(op);
99
+ }
100
+ catch (e) {
101
+ violations.push(`${label}: ${op.operationId}: input schema could not be resolved: ${describe(e)}`);
102
+ continue;
103
+ }
104
+ for (const v of findToolSchemaViolations(op.operationId, inputSchema)) {
105
+ violations.push(`${label}: ${v}`);
106
+ }
107
+ // P1: an object/array body must round-trip through the real transport.
108
+ const kind = structuredBodyKind(effectiveRequestBodySchema(op, doc));
109
+ if (kind) {
110
+ violations.push(...checkBodyRoundTrip(label, op, kind, doc, normalizeBody));
111
+ }
112
+ }
113
+ return violations;
114
+ }
115
+ /** Parse a vendored consumer spec from disk into an {@link OpenApiDoc}. */
116
+ export function loadConsumerSpec(spec) {
117
+ return parseSpec(readFileSync(spec.file, "utf8"));
118
+ }
119
+ /** Run the conformance guard over the whole {@link CONSUMER_SPECS} corpus (or an injected set). Each
120
+ * spec is loaded and projected independently so one broken spec does not mask the others. */
121
+ export function runConsumerConformance(specs = CONSUMER_SPECS) {
122
+ return specs.map((spec) => {
123
+ let doc;
124
+ try {
125
+ doc = loadConsumerSpec(spec);
126
+ }
127
+ catch (e) {
128
+ return { label: spec.label, violations: [`${spec.label}: cannot load ${spec.file}: ${describe(e)}`] };
129
+ }
130
+ return { label: spec.label, violations: findSpecConformanceViolations(spec.label, doc) };
131
+ });
132
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.87.0",
3
+ "version": "0.88.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -95,6 +95,7 @@
95
95
  "check:runtime": "node scripts/gen-runtime.mjs --check",
96
96
  "gen:mcp-tools": "node --experimental-strip-types scripts/gen-mcp-tools.ts",
97
97
  "check:mcp": "node --experimental-strip-types scripts/gen-mcp-tools.ts --check",
98
+ "check:mcp-conformance": "node --experimental-strip-types scripts/check-mcp-conformance.ts",
98
99
  "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
99
100
  "test:conformance": "node --test --experimental-strip-types \"src/context/conformance/**/*.conformance.ts\"",
100
101
  "check:pii": "node --experimental-strip-types scripts/check-pii.ts",
@@ -0,0 +1,51 @@
1
+ # Vendored consumer OpenAPI specs (MCP projection conformance corpus)
2
+
3
+ These are **real** hosted-app `openapi.yaml` documents, vendored and **pinned to a
4
+ specific commit**, that the MCP projection is held to by
5
+ `npm run check:mcp-conformance -w packages/urban` (wired into CI). Unlike the minimal
6
+ synthetic `mcp-parity.fixture.yaml`, these are the actual specs consumer apps ship —
7
+ so a projection change (or a spec change) that would advertise a broken tool to an
8
+ MCP client fails the build here, on the surface where the `$ref`-body defect
9
+ (nanobpm/nano-ide#501) originally surfaced.
10
+
11
+ For every projected (non-`x-mcp`-excluded) tool the guard asserts the properties
12
+ P0 (nanobpm/nano-ide#502) and P1 (nanobpm/nano-ide#503) established:
13
+
14
+ - **Self-contained input schema** — no unresolved `$ref` and an explicit `type` on
15
+ `body` (a client cannot resolve `#/components/...` outside the source document).
16
+ - **Faithful object-body transport** — an object/array body round-trips: an object
17
+ argument reaches the door as an object, and a pre-encoded JSON-string body is
18
+ parsed once (never double-encoded).
19
+
20
+ The guard needs **no running instance** — it projects the vendored file directly.
21
+
22
+ ## Corpus
23
+
24
+ | Spec | Source repo | Pinned ref |
25
+ | --- | --- | --- |
26
+ | `nano-workforce.openapi.yaml` | [`nanobpm/nano-workforce`](https://github.com/nanobpm/nano-workforce) | `2018020a290c2f416e703e3584b27f92ccf27753` |
27
+
28
+ The pin is the single source of truth in code: `CONSUMER_SPECS` in
29
+ `../../runtime/mcp-conformance.ts` (`ref` field) records the same commit. Keep the
30
+ table above in sync with that `ref` when refreshing a pin. `mcp-conformance.test.ts`
31
+ asserts each `ref` is a full 40-hex commit SHA (so a mutable branch/tag pin cannot
32
+ make the guard non-deterministic); it does not verify the table ↔ code ↔ vendored
33
+ bytes provenance, so update both together by hand.
34
+
35
+ ## Refreshing a pinned spec
36
+
37
+ Vendoring is deliberate — the check must be deterministic and offline. To bump a pin,
38
+ re-fetch the file at a new commit and update the `ref` in `CONSUMER_SPECS` (and the
39
+ table above) in the **same** commit, then re-run the guard:
40
+
41
+ ```sh
42
+ REF=<new-commit-sha>
43
+ gh api "repos/nanobpm/nano-workforce/contents/openapi.yaml?ref=$REF" \
44
+ --jq '.content' | base64 -d \
45
+ > packages/urban/src/openapi/consumer-specs/nano-workforce.openapi.yaml
46
+ # update CONSUMER_SPECS[].ref to $REF, then:
47
+ npm run check:mcp-conformance -w packages/urban
48
+ ```
49
+
50
+ If a refreshed spec would project a broken tool the guard fails — then either the
51
+ projection (`openapi/spec.ts`) or the offending consumer spec is fixed deliberately.