@nanobpm/urban 0.84.0 → 0.86.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.
@@ -12,6 +12,7 @@ import { completeUserTaskResponse, resolveFormResponse } from "./forms.js";
12
12
  import { mountActions } from "./actions.js";
13
13
  import { mountAgent } from "./agent.js";
14
14
  import { mountApi } from "./api.js";
15
+ import { mountMcp } from "./mcp.js";
15
16
  import { mountPages } from "./pages.js";
16
17
  /** Escape HTML-significant characters before embedding a value in markup. */
17
18
  function escapeHtml(s) {
@@ -190,6 +191,16 @@ export function mountSurfaces(ctx, app) {
190
191
  const agent = mountAgent(ctx);
191
192
  routes.push(...agent.routes);
192
193
  enabled.push("agent@/app/agent");
194
+ // The runtime-served MCP surface (ADR 0067): `/app/mcp`, a Streamable-HTTP MCP endpoint mounted
195
+ // unconditionally (like `/app/agent`) so an MCP client always has a stable address. It projects
196
+ // the app's read-only OpenAPI operations as tools from the SAME enumeration `mountApi` routes
197
+ // from, plus framework-owned read-only process-debugging tools — zero app-side MCP code.
198
+ const mcp = mountMcp(ctx, app, api.routes);
199
+ routes.push(...mcp.routes);
200
+ // The route is always mounted (stable address), but only claim MCP as an active surface when it
201
+ // actually answers — a config-disabled surface 404s, so listing it here would be inaccurate.
202
+ if (mcp.enabled)
203
+ enabled.push("mcp@/app/mcp");
193
204
  // The pages surface (the schema-driven page runtime) mounts its own routes.
194
205
  const pages = mountPages(ctx, app);
195
206
  if (pages.routes.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.84.0",
3
+ "version": "0.86.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",
@@ -93,12 +93,15 @@
93
93
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
94
94
  "gen:runtime": "node scripts/gen-runtime.mjs",
95
95
  "check:runtime": "node scripts/gen-runtime.mjs --check",
96
+ "gen:mcp-tools": "node --experimental-strip-types scripts/gen-mcp-tools.ts",
97
+ "check:mcp": "node --experimental-strip-types scripts/gen-mcp-tools.ts --check",
96
98
  "test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
97
99
  "test:conformance": "node --test --experimental-strip-types \"src/context/conformance/**/*.conformance.ts\"",
98
100
  "check:pii": "node --experimental-strip-types scripts/check-pii.ts",
99
101
  "test:deno": "deno test -A --no-check src/"
100
102
  },
101
103
  "dependencies": {
104
+ "@modelcontextprotocol/sdk": "^1.30.0",
102
105
  "@nanobpm/nano-app-schema": "^0.9.0",
103
106
  "@nanobpm/nano-sdk": "^1.2.5",
104
107
  "@nanobpm/workflow": "^0.14.0",
@@ -0,0 +1,49 @@
1
+ # Parity-guard fixture (ADR 0067 Slice 3) — NOT an app spec.
2
+ #
3
+ # This is the input to the spec↔tool parity guard (`scripts/gen-mcp-tools.ts`).
4
+ # It exercises every branch of the MCP tool projection with a stable, minimal
5
+ # document so `check:mcp` fails loudly the moment the walker's projection changes
6
+ # without a matching, reviewed regeneration of `mcp-tools.snapshot.json`:
7
+ #
8
+ # - a READ operation (GET) → projected, non-mutating
9
+ # - a MUTATING operation (POST) → projected, mutating (guarded at runtime)
10
+ # - an `x-mcp`-EXCLUDED operation → present in the doc but withheld from the
11
+ # agent-facing tool surface (the security-
12
+ # relevant opt-out, `x-mcp: false`)
13
+ #
14
+ # Edit this fixture ONLY together with a reviewed `npm run gen:mcp-tools`.
15
+ openapi: 3.1.0
16
+ info:
17
+ title: mcp-parity-fixture
18
+ version: 0.0.0
19
+ components:
20
+ securitySchemes:
21
+ appSecret:
22
+ type: apiKey
23
+ in: header
24
+ name: X-Nano-Key
25
+ x-nano-secret-env: NANO_WEBHOOK_KEY
26
+ paths:
27
+ /widgets:
28
+ get:
29
+ operationId: listWidgets
30
+ summary: List widgets (read — projected, unguarded on loopback).
31
+ responses:
32
+ "200":
33
+ description: ok
34
+ post:
35
+ operationId: createWidget
36
+ summary: Create a widget (mutating — projected, guarded).
37
+ security:
38
+ - appSecret: []
39
+ responses:
40
+ "201":
41
+ description: created
42
+ /internal/reindex:
43
+ post:
44
+ operationId: reindexWidgets
45
+ summary: Operator-only reindex (withheld from the MCP tool surface).
46
+ x-mcp: false
47
+ responses:
48
+ "202":
49
+ description: accepted
@@ -0,0 +1,20 @@
1
+ [
2
+ {
3
+ "operationId": "reindexWidgets",
4
+ "method": "post",
5
+ "mutating": true,
6
+ "excluded": true
7
+ },
8
+ {
9
+ "operationId": "listWidgets",
10
+ "method": "get",
11
+ "mutating": false,
12
+ "excluded": false
13
+ },
14
+ {
15
+ "operationId": "createWidget",
16
+ "method": "post",
17
+ "mutating": true,
18
+ "excluded": false
19
+ }
20
+ ]
@@ -5,6 +5,7 @@ import {
5
5
  evaluateSecurity,
6
6
  isObjectSchema,
7
7
  isSafeOperationId,
8
+ isMutatingMethod,
8
9
  type OpenApiDoc,
9
10
  type OpenApiSchema,
10
11
  operationsWithoutId,
@@ -13,6 +14,8 @@ import {
13
14
  resolveSchema,
14
15
  responseSchemaForStatus,
15
16
  sharedRequestBodySchemas,
17
+ sharedSecretSchemeName,
18
+ sharedSecretSchemeNames,
16
19
  toRouteMatcher,
17
20
  undeclaredPathParams,
18
21
  undeclaredSecuritySchemes,
@@ -436,6 +439,18 @@ test("isSafeOperationId rejects path separators and traversal; collectOperations
436
439
  assert.deepEqual(operationsWithUnsafeId(evil), ["GET /x (../../etc/passwd)"]);
437
440
  });
438
441
 
442
+ test("isMutatingMethod: the HTTP safe verbs (get/head/options) are read-only, everything else mutates", () => {
443
+ // OPTIONS is a CORS/preflight metadata probe — a safe, non-mutating verb (RFC 9110 §9.2.1). It must
444
+ // NOT be flagged mutating, or an `options` operation is wrongly marked destructive in tools/list and
445
+ // captured as a "mutating" fact by the spec↔tool parity snapshot.
446
+ for (const safe of ["get", "head", "options"] as const) {
447
+ assert.equal(isMutatingMethod(safe), false, `${safe} must be treated as read-only`);
448
+ }
449
+ for (const mutating of ["put", "post", "delete", "patch"] as const) {
450
+ assert.equal(isMutatingMethod(mutating), true, `${mutating} must be treated as mutating`);
451
+ }
452
+ });
453
+
439
454
  test("object validation uses own-property checks (prototype keys don't satisfy required or bypass additionalProperties)", () => {
440
455
  // A prototype key must NOT satisfy `required` (own-property semantics).
441
456
  const req = { type: "object", properties: { toString: { type: "string" } }, required: ["toString"] };
@@ -633,6 +648,83 @@ test("evaluateSecurity: alternative requirements are OR (any one satisfied autho
633
648
  assert.equal(evaluateSecurity(doc, op, noHeader, noQuery, env).status, 401);
634
649
  });
635
650
 
651
+ test("sharedSecretSchemeName: exactly one apiKey header scheme with x-nano-secret-env is the guard", () => {
652
+ assert.equal(sharedSecretSchemeName(secured), "webhookKey");
653
+ assert.deepEqual(sharedSecretSchemeNames(secured), ["webhookKey"]);
654
+ });
655
+
656
+ test("sharedSecretSchemeName: no candidate scheme → undefined (mutations stay closed)", () => {
657
+ const doc: OpenApiDoc = {
658
+ openapi: "3.0.0",
659
+ components: { securitySchemes: { oauth: { type: "oauth2" } } },
660
+ paths: {},
661
+ };
662
+ assert.equal(sharedSecretSchemeName(doc), undefined);
663
+ assert.deepEqual(sharedSecretSchemeNames(doc), []);
664
+ });
665
+
666
+ test("sharedSecretSchemeName: MULTIPLE candidate schemes are an explicit misconfiguration, not a silent first-wins", () => {
667
+ // Two apiKey header schemes both carry x-nano-secret-env: which one guards mutations would
668
+ // otherwise depend on object iteration (authoring) order — surface it loudly instead.
669
+ const doc: OpenApiDoc = {
670
+ openapi: "3.0.0",
671
+ components: {
672
+ securitySchemes: {
673
+ primary: { type: "apiKey", in: "header", name: "X-A", "x-nano-secret-env": "A_KEY" },
674
+ secondary: { type: "apiKey", in: "header", name: "X-B", "x-nano-secret-env": "B_KEY" },
675
+ },
676
+ },
677
+ paths: {},
678
+ };
679
+ // The full candidate list is exposed (source of truth) …
680
+ assert.deepEqual(sharedSecretSchemeNames(doc), ["primary", "secondary"]);
681
+ // … and the selector refuses to pick one silently.
682
+ assert.throws(
683
+ () => sharedSecretSchemeName(doc),
684
+ /multiple shared-secret schemes.*primary, secondary/s,
685
+ );
686
+ });
687
+
688
+ test("sharedSecretSchemeNames: a declared-but-EMPTY x-nano-secret-env is still a candidate (→ 500 downstream, not a silent 401)", () => {
689
+ // An empty `x-nano-secret-env` is a misconfiguration, not "no scheme": a truthiness test would
690
+ // drop it so the guard reports a misleading "no shared-secret scheme" (401). A structural presence
691
+ // check keeps it selected so `evaluateSecurity` surfaces it as a 500, mirroring how it treats an
692
+ // empty env-var pointer.
693
+ const doc: OpenApiDoc = {
694
+ openapi: "3.0.0",
695
+ components: {
696
+ securitySchemes: {
697
+ webhookKey: { type: "apiKey", in: "header", name: "X-Key", "x-nano-secret-env": "" },
698
+ },
699
+ },
700
+ paths: {},
701
+ };
702
+ assert.deepEqual(sharedSecretSchemeNames(doc), ["webhookKey"]);
703
+ assert.equal(sharedSecretSchemeName(doc), "webhookKey");
704
+ });
705
+
706
+ test("sharedSecretSchemeNames: a malformed (null) scheme entry is skipped, not a bare TypeError", () => {
707
+ // A mistakenly empty scheme (`webhookKey:` with no body) parses as `null` in YAML. Dereferencing
708
+ // it (`scheme.type`) would throw a bare TypeError; a non-object scheme is not a valid apiKey
709
+ // shared-secret candidate, so it is skipped and the real candidate is still selected. Built via
710
+ // JSON.parse so the runtime-invalid `null` scheme reaches the function (types forbid it).
711
+ const doc: OpenApiDoc = parseSpec(
712
+ JSON.stringify({
713
+ openapi: "3.0.0",
714
+ components: {
715
+ securitySchemes: {
716
+ brokenEmpty: null,
717
+ webhookKey: { type: "apiKey", in: "header", name: "X-Key", "x-nano-secret-env": "KEY" },
718
+ },
719
+ },
720
+ paths: {},
721
+ }),
722
+ );
723
+ assert.deepEqual(sharedSecretSchemeNames(doc), ["webhookKey"]);
724
+ assert.equal(sharedSecretSchemeName(doc), "webhookKey");
725
+ });
726
+
727
+
636
728
  test("responseSchemaForStatus: a documented-but-bodyless status suppresses the default fallback", () => {
637
729
  const ops = collectOperations({
638
730
  openapi: "3.1.0",
@@ -187,6 +187,11 @@ export interface OperationInfo {
187
187
  * else the document-level default, else `[]`). An empty list means the operation is open. */
188
188
  security: OpenApiSecurityRequirement[];
189
189
  eject: boolean;
190
+ /** Whether the operation opts OUT of MCP tool projection via the `x-mcp` extension (ADR 0067).
191
+ * Defaults to `false` — an operation IS exposed as a tool unless it explicitly excludes itself.
192
+ * Security-relevant: this is the single authoring switch that keeps an operator-only door (e.g.
193
+ * a human-gated dispatch) out of the agent-facing tool surface. See {@link isMcpExcluded}. */
194
+ mcpExcluded: boolean;
190
195
  summary?: string;
191
196
  }
192
197
 
@@ -307,6 +312,7 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
307
312
  responseSchemas: collectResponseSchemas(responses),
308
313
  security,
309
314
  eject: opRaw["x-urban-eject"] === true,
315
+ mcpExcluded: isMcpExcluded(opRaw["x-mcp"]),
310
316
  summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
311
317
  });
312
318
  }
@@ -314,6 +320,161 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
314
320
  return out;
315
321
  }
316
322
 
323
+ /**
324
+ * Read the `x-mcp` operation extension (ADR 0067 — MCP tool projection). An operation is EXPOSED as
325
+ * an MCP tool by default; it is excluded only when it says so explicitly, so a spec author opts a
326
+ * door OUT rather than every operation having to opt in. Recognised exclusion forms:
327
+ * - `x-mcp: false` — a terse boolean opt-out;
328
+ * - `x-mcp: { exclude: true }` — the object form (room for future keys, e.g. per-tool metadata).
329
+ * Any other value (including `x-mcp: true` / `{ exclude: false }` / absent) leaves the operation
330
+ * exposed — fail-open to the documented default. This is a SECURITY-RELEVANT authoring rule: it is
331
+ * the one switch that keeps an operator-only operation out of the agent-facing tool surface, so it
332
+ * is enforced from the same single walker the runtime and the parity guard both read (no second
333
+ * source of truth to drift, ADR 0053).
334
+ */
335
+ export function isMcpExcluded(ext: unknown): boolean {
336
+ if (ext === false) return true;
337
+ return isRecord(ext) && ext.exclude === true;
338
+ }
339
+
340
+ /** The HTTP methods MCP treats as read-only (safe, non-mutating). These are exactly the HTTP "safe"
341
+ * verbs (RFC 9110 §9.2.1) that {@link HTTP_METHODS} can express: GET/HEAD, plus OPTIONS (a
342
+ * CORS/preflight metadata probe that never mutates). Every other method an operation can declare is
343
+ * a mutation whose tool is guarded (ADR 0067 Slice 3). */
344
+ const READ_ONLY_METHODS: readonly HttpMethodLower[] = ["get", "head", "options"];
345
+
346
+ /** Whether an operation's HTTP method mutates state — anything other than a safe read verb. Drives
347
+ * the MCP read/mutate split: read tools are unguarded on loopback, mutating tools are gated. */
348
+ export function isMutatingMethod(method: HttpMethodLower): boolean {
349
+ return !READ_ONLY_METHODS.includes(method);
350
+ }
351
+
352
+ /** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, and whether
353
+ * `x-mcp` excludes it from projection. This is the shared unit the runtime MCP surface AND the CI
354
+ * spec↔tool parity guard both read — a single derivation of "which operations become tools", never
355
+ * a second walker (ADR 0053). */
356
+ export interface McpToolProjection {
357
+ operationId: string;
358
+ method: HttpMethodLower;
359
+ mutating: boolean;
360
+ excluded: boolean;
361
+ }
362
+
363
+ /**
364
+ * Project every operationId-bearing operation to its MCP-tool facts, in the stable
365
+ * `collectOperations` (path, method) order. Excluded operations are RETAINED here (flagged
366
+ * `excluded: true`) rather than dropped, so the parity guard can positively assert that an
367
+ * `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
368
+ * tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
369
+ * `mountApi` routes.
370
+ */
371
+ export function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[] {
372
+ return collectOperations(doc).map((op) => ({
373
+ operationId: op.operationId,
374
+ method: op.method,
375
+ mutating: isMutatingMethod(op.method),
376
+ excluded: op.mcpExcluded,
377
+ }));
378
+ }
379
+
380
+ /**
381
+ * Compare a previously-captured MCP-tool projection (the committed snapshot) against the projection
382
+ * freshly computed from the spec, returning a human-readable line for every skew — an operation the
383
+ * spec now projects that the snapshot lacks, one the snapshot still lists that the spec no longer
384
+ * projects, or one whose method / mutating / excluded facts changed. An empty array means the spec
385
+ * and the projected tool list are in parity. This is the pure core of the `check:mcp` CI drift gate
386
+ * (ADR 0067) — the same "generated derived artifact + fail-on-drift" pattern the repo already uses
387
+ * for `check:runtime` — and is unit-tested by feeding it an injected skew.
388
+ */
389
+ export function diffMcpToolProjection(
390
+ snapshot: McpToolProjection[],
391
+ current: McpToolProjection[],
392
+ ): string[] {
393
+ const drift: string[] = [];
394
+ const byId = (list: McpToolProjection[]): Map<string, McpToolProjection> =>
395
+ new Map(list.map((p) => [p.operationId, p]));
396
+ const snapshotById = byId(snapshot);
397
+ const currentById = byId(current);
398
+ for (const [id, cur] of currentById) {
399
+ const snap = snapshotById.get(id);
400
+ if (!snap) {
401
+ drift.push(`+ ${id}: spec projects a tool the snapshot is missing (regenerate the snapshot)`);
402
+ continue;
403
+ }
404
+ if (snap.method !== cur.method || snap.mutating !== cur.mutating || snap.excluded !== cur.excluded) {
405
+ drift.push(
406
+ `~ ${id}: projection changed — snapshot {method:${snap.method}, mutating:${snap.mutating}, ` +
407
+ `excluded:${snap.excluded}} vs spec {method:${cur.method}, mutating:${cur.mutating}, ` +
408
+ `excluded:${cur.excluded}}`,
409
+ );
410
+ }
411
+ }
412
+ for (const id of snapshotById.keys()) {
413
+ if (!currentById.has(id)) {
414
+ drift.push(`- ${id}: snapshot lists a tool the spec no longer projects (regenerate the snapshot)`);
415
+ }
416
+ }
417
+ return drift;
418
+ }
419
+
420
+ /**
421
+ * Every app shared-secret security scheme (ADR 0067/0059): the `apiKey` schemes in
422
+ * `components.securitySchemes` presented in a request HEADER and pointing at an env var via
423
+ * `x-nano-secret-env` (declared, even if empty — an empty pointer is a misconfiguration surfaced as
424
+ * 500 downstream, not "no scheme"), in document (authoring) order. Single source of truth for both the guard
425
+ * selector (`sharedSecretSchemeName`) and its ambiguity check — a mutating MCP tool REUSES the
426
+ * app's existing shared secret (the same one a `webhook` trigger or a guarded operation already
427
+ * uses) instead of minting an MCP-specific synonym.
428
+ */
429
+ export function sharedSecretSchemeNames(doc: OpenApiDoc): string[] {
430
+ const schemes = doc.components?.securitySchemes ?? {};
431
+ const names: string[] = [];
432
+ for (const [name, scheme] of Object.entries(schemes)) {
433
+ // A malformed scheme value (e.g. `webhookKey:` with no body parses as `null` in YAML, or a
434
+ // primitive) is not a valid apiKey shared-secret candidate — skip it rather than dereference it
435
+ // and throw a bare TypeError. This mirrors `evaluateSecurity`, which never treats a non-object
436
+ // scheme as a valid credential (a referenced null/primitive scheme surfaces there as a 500), so
437
+ // there is no silent security hole: mutations fail closed and a genuine misconfig is caught at
438
+ // request time by the enforcement path.
439
+ if (!isRecord(scheme)) continue;
440
+ // Structural presence check (`typeof … === "string"`), NOT truthiness: a declared-but-EMPTY
441
+ // `x-nano-secret-env` is a misconfiguration, not "no scheme". Selecting it here lets
442
+ // `evaluateSecurity` surface it as a 500 (missing env-var pointer) — the same way it treats an
443
+ // empty pointer — instead of silently dropping the scheme so the guard reports a misleading 401.
444
+ if (
445
+ scheme.type === "apiKey" &&
446
+ (scheme.in ?? "header") === "header" &&
447
+ typeof scheme["x-nano-secret-env"] === "string"
448
+ ) {
449
+ names.push(name);
450
+ }
451
+ }
452
+ return names;
453
+ }
454
+
455
+ /**
456
+ * The name of the app's single shared-secret security scheme (ADR 0067/0059) — the credential the
457
+ * framework MUTATING MCP tools require. `undefined` when the app declares no such scheme, in which
458
+ * case the mutating tools stay closed unless an explicit runtime opt-in is set.
459
+ *
460
+ * THROWS when the spec declares MORE THAN ONE candidate: which scheme becomes "the" MCP mutation
461
+ * guard would otherwise depend on `securitySchemes` object iteration (authoring) order, silently
462
+ * guarding with an unintended secret (or rejecting a different, valid shared-secret header). That is
463
+ * an incoherent spec, surfaced loudly — the caller maps it to a 500 misconfiguration — rather than
464
+ * resolved by an invisible "first wins" rule. Derived from `sharedSecretSchemeNames` (no drift).
465
+ */
466
+ export function sharedSecretSchemeName(doc: OpenApiDoc): string | undefined {
467
+ const names = sharedSecretSchemeNames(doc);
468
+ if (names.length > 1) {
469
+ throw new Error(
470
+ `OpenAPI spec declares multiple shared-secret schemes (apiKey header with x-nano-secret-env): ` +
471
+ `${names.join(", ")}. Exactly one may serve as the MCP mutation guard — remove the extras so ` +
472
+ "the guard scheme is unambiguous.",
473
+ );
474
+ }
475
+ return names[0];
476
+ }
477
+
317
478
  /**
318
479
  * Throw if the document declares the same `operationId` on more than one operation. An operationId
319
480
  * must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
@@ -55,7 +55,7 @@ export interface ApiBinding {
55
55
  /** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
56
56
  * live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
57
57
  * stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
58
- const API_BASE = "/app/api";
58
+ export const API_BASE = "/app/api";
59
59
 
60
60
  /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
61
61
  * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
@@ -250,7 +250,7 @@ export function resolveOperationHandler(mod: Record<string, unknown>): Operation
250
250
  return undefined;
251
251
  }
252
252
 
253
- function readApiBinding(manifest: unknown): ApiBinding | undefined {
253
+ export function readApiBinding(manifest: unknown): ApiBinding | undefined {
254
254
  const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "api") : undefined;
255
255
  if (!raw || typeof raw !== "object") return undefined;
256
256
  const spec = Reflect.get(raw, "spec");