@nanobpm/urban 0.85.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.
@@ -8,16 +8,20 @@
8
8
  //
9
9
  // Derive-don't-declare (ADR 0053): the app tool list is PROJECTED from the SAME OpenAPI
10
10
  // enumeration `mountApi` routes from — `parseSpec` + `collectOperations` (openapi/spec.ts), read
11
- // through `readApiBinding` — never a second spec walker. Each read-only operation becomes one tool
11
+ // through `readApiBinding` — never a second spec walker. Each projected operation becomes one tool
12
12
  // whose `operationId` is the tool name and whose request-body/params schema is the tool input
13
13
  // schema; a tool call is reconstructed into the operation's HTTP request and dispatched through the
14
14
  // SAME `mountApi` router, so it flows through the identical delegate registry + validation the HTTP
15
15
  // route uses (a tool call is equivalent to the corresponding HTTP call).
16
16
  //
17
- // This slice (ADR 0067 Slice 1) exposes READ-ONLY app operations only (GET/HEAD) — mutating
18
- // operations and their guards are Slice 3. It also exposes framework-owned read-only
19
- // process-debugging tools over BOTH truth planes: engine truth via existing `EngineClient` methods,
20
- // and the ADR 0065 canonical projection stores (`urban_instance_state` / `urban_open_user_tasks`).
17
+ // This module (ADR 0067) exposes app operations as MCP tools — each authorized exactly as its own
18
+ // HTTP route by the operation's OpenAPI `security` (a read or mutating op with no `security` is
19
+ // open; there is NO extra MCP-level shared-secret guard on app operations). An operation opts out
20
+ // of projection with the `x-mcp` extension. It also exposes framework-owned process-debugging tools
21
+ // over BOTH truth planes: engine truth via existing `EngineClient` methods (reads, plus mutating
22
+ // unstick tools — cancel/resolve/retry/set-variables, which ARE guarded by the app's shared secret
23
+ // or the `mcp.allowMutations` opt-in), and the ADR 0065 canonical projection stores
24
+ // (`urban_instance_state` / `urban_open_user_tasks`).
21
25
  // The app's system brief (`/app/agent.json`) is served as an MCP resource, plus one orientation
22
26
  // entry under MCP prompts.
23
27
  //
@@ -30,7 +34,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
30
34
  import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, isInitializeRequest, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
31
35
  import { GENERATED_DIR } from "../../../toolkit/artifact.js";
32
36
  import { SYSTEM_BRIEF_JSON } from "../../../toolkit/derivers/system-brief.js";
33
- import { collectOperations, parseSpec, } from "../../../openapi/spec.js";
37
+ import { collectOperations, evaluateSecurity, isMutatingMethod, parseSpec, sharedSecretSchemeName, } from "../../../openapi/spec.js";
34
38
  import { errorMessage } from "../guards.js";
35
39
  import { presentFormIdentifier } from "../host.js";
36
40
  import { resolveBindMode } from "../manifest.js";
@@ -101,11 +105,13 @@ function isRecord(v) {
101
105
  return typeof v === "object" && v !== null && !Array.isArray(v);
102
106
  }
103
107
  /**
104
- * Resolve the MCP access policy from the manifest (`mcp: { enabled?, allowRemote? }`, read
105
- * reflectively until the app schema folds the field in — mirroring how `readApiBinding` reads
106
- * `api`) and the environment (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`). The env flags win so
107
- * an operator can force the surface off (or open it to a non-loopback bind) without editing the
108
- * manifest. Enabled defaults ON and access defaults to loopback-only ("defaults ON for loopback").
108
+ * Resolve the MCP access policy from the manifest
109
+ * (`mcp: { enabled?, allowRemote?, allowMutations? }`, read reflectively until the app schema folds
110
+ * the field in — mirroring how `readApiBinding` reads `api`) and the environment
111
+ * (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`, `URBAN_MCP_ALLOW_MUTATIONS`). The env flags win so
112
+ * an operator can force the surface off (or open it to a non-loopback bind, or enable mutating
113
+ * tools) without editing the manifest. Enabled defaults ON and access defaults to loopback-only
114
+ * ("defaults ON for loopback"); `allowMutations` defaults OFF (mutating tools stay closed).
109
115
  */
110
116
  export function readMcpConfig(manifest, env) {
111
117
  const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "mcp") : undefined;
@@ -114,13 +120,17 @@ export function readMcpConfig(manifest, env) {
114
120
  // surprisingly (e.g. the string `"false"` is not a boolean `false`).
115
121
  const rawEnabled = isRecord(raw) ? Reflect.get(raw, "enabled") : undefined;
116
122
  const rawAllowRemote = isRecord(raw) ? Reflect.get(raw, "allowRemote") : undefined;
123
+ const rawAllowMutations = isRecord(raw) ? Reflect.get(raw, "allowMutations") : undefined;
117
124
  const manifestEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
118
125
  const manifestAllowRemote = typeof rawAllowRemote === "boolean" ? rawAllowRemote : undefined;
126
+ const manifestAllowMutations = typeof rawAllowMutations === "boolean" ? rawAllowMutations : undefined;
119
127
  const envEnabled = env("URBAN_MCP_ENABLED");
120
128
  const envAllowRemote = env("URBAN_MCP_ALLOW_REMOTE");
129
+ const envAllowMutations = env("URBAN_MCP_ALLOW_MUTATIONS");
121
130
  const enabled = envEnabled === "false" ? false : envEnabled === "true" ? true : manifestEnabled !== false;
122
131
  const allowRemote = envAllowRemote === "true" ? true : envAllowRemote === "false" ? false : manifestAllowRemote === true;
123
- return { enabled, allowRemote };
132
+ const allowMutations = envAllowMutations === "true" ? true : envAllowMutations === "false" ? false : manifestAllowMutations === true;
133
+ return { enabled, allowRemote, allowMutations };
124
134
  }
125
135
  /** The host portion of an HTTP `Host` header, lower-cased and stripped of any `:port` (and of the
126
136
  * `[...]` brackets around an IPv6 literal), or `undefined` when there is no Host header. */
@@ -143,10 +153,25 @@ export function isLoopbackRequest(req) {
143
153
  const name = hostName(req);
144
154
  return name === undefined || name === "localhost" || name === "127.0.0.1" || name === "::1";
145
155
  }
146
- /** True for a read-only operation — the only operations this slice projects as tools. Mutating
147
- * operations (POST/PUT/PATCH/DELETE) are withheld until Slice 3 adds them behind a guard. */
148
- function isReadOnlyOperation(op) {
149
- return op.method === "get" || op.method === "head";
156
+ /** Read a header value from the SDK's per-request `requestInfo.headers` map (lower-cased keys,
157
+ * values `string | string[] | undefined`). A repeated header (`string[]`, as Node's raw adapter
158
+ * surfaces it) is comma-joined to MATCH `Headers.get()` semantics — the same read `mountApi` and
159
+ * the reconstructed `toHttpRequest` use for OpenAPI route enforcement — so the shared-secret guard
160
+ * cannot diverge from route enforcement on a repeated credential header. Returns `undefined` when
161
+ * absent. Used to read the shared-secret credential the client presents on the MCP connection for a
162
+ * mutating tool call. */
163
+ export function readHeader(headers, name) {
164
+ const value = headers[name.toLowerCase()];
165
+ if (Array.isArray(value))
166
+ return value.join(", ");
167
+ return value;
168
+ }
169
+ /** A minimal read of the `x-mcp` extension for a projected operation is done at parse time
170
+ * (`OperationInfo.mcpExcluded`); here we only need the mutation classification, shared with the CI
171
+ * parity guard via {@link isMutatingMethod}. Kept as a named predicate for readability at the call
172
+ * sites (tool annotations + guard). */
173
+ function isMutatingOperation(op) {
174
+ return isMutatingMethod(op.method);
150
175
  }
151
176
  /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
152
177
  * path/query parameters and its request-body schema. Keeping the property schemas verbatim from the
@@ -246,16 +271,28 @@ export function missingRequiredArgs(inputSchema, args) {
246
271
  }
247
272
  /** Reconstruct the HTTP request an operation tool call is equivalent to, ready to hand to the
248
273
  * reused `mountApi` router. The JSON body is the tool's `body` argument (when the op declares one).
249
- */
250
- function toHttpRequest(op, args) {
274
+ * The MCP connection's request headers are FORWARDED (with `content-type` pinned to JSON) so the
275
+ * reused router's `evaluateSecurity` sees the client's credential: a mutating (or a secured
276
+ * read-only) app operation is then guarded exactly as its HTTP route is — the app operation tools
277
+ * inherit the OpenAPI `security` contract rather than a bespoke MCP gate (ADR 0067 §4). */
278
+ function toHttpRequest(op, args, forwardHeaders) {
251
279
  const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
252
280
  ? JSON.stringify(args.body)
253
281
  : "";
282
+ const headers = new Headers();
283
+ for (const [key, value] of Object.entries(forwardHeaders)) {
284
+ if (typeof value === "string")
285
+ headers.set(key, value);
286
+ else if (Array.isArray(value))
287
+ for (const v of value)
288
+ headers.append(key, v);
289
+ }
290
+ headers.set("content-type", "application/json");
254
291
  return {
255
292
  method: op.method.toUpperCase(),
256
293
  path: fillPath(op, args),
257
294
  query: toolQuery(op, args),
258
- headers: new Headers({ "content-type": "application/json" }),
295
+ headers,
259
296
  text: () => Promise.resolve(bodyText),
260
297
  };
261
298
  }
@@ -269,6 +306,30 @@ function readString(args, key) {
269
306
  const v = args[key];
270
307
  return typeof v === "string" ? v : undefined;
271
308
  }
309
+ /** Read an OPTIONAL string argument in its TRIMMED present form, or `undefined` when it is missing,
310
+ * non-string, or blank/whitespace-only. The canonical read for identifying keys and filter values
311
+ * (processInstanceKey/elementId/assignee/candidateGroup): it applies the SAME
312
+ * {@link presentFormIdentifier} trimmed-presence rule that `missingRequiredArgs`/{@link isBlankArg}
313
+ * use for presence, so a padded value like `" pi-1 "` normalizes to `"pi-1"` instead of reaching
314
+ * the engine untrimmed — where a filter would silently match nothing and a keyed lookup would target
315
+ * a whitespace-padded entity that does not exist. The single source of truth {@link requireString}
316
+ * also derives from, so required and optional reads cannot drift on how they normalize. */
317
+ function readPresentString(args, key) {
318
+ return presentFormIdentifier(readString(args, key));
319
+ }
320
+ /** Read a REQUIRED non-empty string argument, TRIMMED, or throw `InvalidParams`. The canonical guard
321
+ * the mutating debug tools use for their identifying keys (processInstanceKey/incidentKey/jobKey/
322
+ * scopeKey): a missing, non-string, or blank value fails fast before any engine state is touched,
323
+ * rather than silently degrading to `""` and performing a broken/misleading mutation. Derived from
324
+ * {@link readPresentString} — the same trimmed-presence rule {@link isBlankArg} (hence
325
+ * `missingRequiredArgs`) uses — so a padded key like `" pi-1 "` cannot pass the required-arg check
326
+ * yet reach the engine untrimmed and mis-target the entity. */
327
+ function requireString(args, key) {
328
+ const present = readPresentString(args, key);
329
+ if (present === undefined)
330
+ throw new McpError(ErrorCode.InvalidParams, `${key} must be a non-empty string`);
331
+ return present;
332
+ }
272
333
  function readStringArray(args, key) {
273
334
  const v = args[key];
274
335
  if (!Array.isArray(v))
@@ -293,10 +354,28 @@ function readWaitStateType(args, key) {
293
354
  ? v
294
355
  : undefined;
295
356
  }
357
+ /** Narrow an argument to a real engine incident state — the `"UNKNOWN"` client-side sentinel is
358
+ * deliberately NOT a selector (see `IncidentState`/`IncidentFilter`), so it is rejected here. */
359
+ function readIncidentState(args, key) {
360
+ const v = args[key];
361
+ return v === "ACTIVE" || v === "MIGRATED" || v === "PENDING" || v === "RESOLVED" ? v : undefined;
362
+ }
363
+ /** Read a plain JSON object argument (an MCP `object` input), else `undefined`. Used for the
364
+ * `set_variables` payload — an arbitrary variable map handed to the engine verbatim. */
365
+ function readObject(args, key) {
366
+ const v = args[key];
367
+ return isRecord(v) ? v : undefined;
368
+ }
369
+ /** Read a finite integer argument (for a job's retry count), else `undefined`. */
370
+ function readInteger(args, key) {
371
+ const v = args[key];
372
+ return typeof v === "number" && Number.isInteger(v) ? v : undefined;
373
+ }
296
374
  const OPTIONAL_STRING = { type: "string" };
297
375
  /** Build the framework debug tools, closing over the app's engine (truth plane) and default data
298
- * source (the ADR 0065 projection stores). The engine tools consume only EXISTING `EngineClient`
299
- * methods — no seam extension in this slice. */
376
+ * source (the ADR 0065 projection stores). READ tools consume the engine's read accessors and the
377
+ * projection stores; MUTATING tools (ADR 0067 Slice 3, gated by the shared-secret guard) drive the
378
+ * Slice 2 seam extension — cancel an instance, resolve/retry an incident, set variables. */
300
379
  function buildDebugTools(app) {
301
380
  const projectionDb = () => app.data.hasDefaultSource() ? app.data.source().db : undefined;
302
381
  return [
@@ -337,10 +416,10 @@ function buildDebugTools(app) {
337
416
  },
338
417
  run: (args) => {
339
418
  const filter = {};
340
- const pik = readString(args, "processInstanceKey");
419
+ const pik = readPresentString(args, "processInstanceKey");
341
420
  if (pik)
342
421
  filter.processInstanceKey = pik;
343
- const elementId = readString(args, "elementId");
422
+ const elementId = readPresentString(args, "elementId");
344
423
  if (elementId)
345
424
  filter.elementId = elementId;
346
425
  const waitStateType = readWaitStateType(args, "waitStateType");
@@ -357,7 +436,7 @@ function buildDebugTools(app) {
357
436
  properties: { elementInstanceKey: OPTIONAL_STRING },
358
437
  required: ["elementInstanceKey"],
359
438
  },
360
- run: (args) => app.engine.getElementInstance(readString(args, "elementInstanceKey") ?? ""),
439
+ run: (args) => app.engine.getElementInstance(requireString(args, "elementInstanceKey")),
361
440
  },
362
441
  {
363
442
  name: `${DEBUG_PREFIX}search_user_tasks`,
@@ -373,13 +452,13 @@ function buildDebugTools(app) {
373
452
  },
374
453
  run: (args) => {
375
454
  const filter = {};
376
- const pik = readString(args, "processInstanceKey");
455
+ const pik = readPresentString(args, "processInstanceKey");
377
456
  if (pik)
378
457
  filter.processInstanceKey = pik;
379
- const assignee = readString(args, "assignee");
458
+ const assignee = readPresentString(args, "assignee");
380
459
  if (assignee)
381
460
  filter.assignee = assignee;
382
- const candidateGroup = readString(args, "candidateGroup");
461
+ const candidateGroup = readPresentString(args, "candidateGroup");
383
462
  if (candidateGroup)
384
463
  filter.candidateGroup = candidateGroup;
385
464
  const state = readUserTaskState(args, "state");
@@ -388,6 +467,103 @@ function buildDebugTools(app) {
388
467
  return app.engine.searchUserTasks(filter);
389
468
  },
390
469
  },
470
+ {
471
+ name: `${DEBUG_PREFIX}search_incidents`,
472
+ description: "Engine truth: search incidents (stuck tokens — a job out of retries, an unhandled error, a failed gateway) by process instance and/or state. Read-only; each result carries the incidentKey/jobKey the mutating unstick tools take.",
473
+ inputSchema: {
474
+ type: "object",
475
+ properties: {
476
+ processInstanceKey: OPTIONAL_STRING,
477
+ state: { type: "string", enum: ["ACTIVE", "MIGRATED", "PENDING", "RESOLVED"] },
478
+ },
479
+ },
480
+ run: (args) => {
481
+ const filter = {};
482
+ const pik = readPresentString(args, "processInstanceKey");
483
+ if (pik)
484
+ filter.processInstanceKey = pik;
485
+ const state = readIncidentState(args, "state");
486
+ if (state)
487
+ filter.state = state;
488
+ return app.engine.searchIncidents(filter);
489
+ },
490
+ },
491
+ {
492
+ name: `${DEBUG_PREFIX}cancel_instance`,
493
+ description: "MUTATING: cancel a running process instance. Requires the shared-secret guard.",
494
+ mutating: true,
495
+ inputSchema: {
496
+ type: "object",
497
+ properties: { processInstanceKey: OPTIONAL_STRING },
498
+ required: ["processInstanceKey"],
499
+ },
500
+ run: async (args) => {
501
+ const processInstanceKey = requireString(args, "processInstanceKey");
502
+ await app.engine.cancelInstance({ processInstanceKey });
503
+ return { cancelled: processInstanceKey };
504
+ },
505
+ },
506
+ {
507
+ name: `${DEBUG_PREFIX}resolve_incident`,
508
+ description: "MUTATING: resolve an open incident by key, unblocking the parked token (bump the job's retries first for a jobNoRetries incident — see retry_job). Requires the shared-secret guard.",
509
+ mutating: true,
510
+ inputSchema: {
511
+ type: "object",
512
+ properties: { incidentKey: OPTIONAL_STRING },
513
+ required: ["incidentKey"],
514
+ },
515
+ run: async (args) => {
516
+ const incidentKey = requireString(args, "incidentKey");
517
+ await app.engine.resolveIncident({ incidentKey });
518
+ return { resolved: incidentKey };
519
+ },
520
+ },
521
+ {
522
+ name: `${DEBUG_PREFIX}retry_job`,
523
+ description: "MUTATING: set a failed job's remaining retries (making it activatable again — the 'retry' half of unsticking a jobNoRetries incident). Requires the shared-secret guard.",
524
+ mutating: true,
525
+ inputSchema: {
526
+ type: "object",
527
+ properties: { jobKey: OPTIONAL_STRING, retries: { type: "integer", minimum: 0 } },
528
+ required: ["jobKey", "retries"],
529
+ },
530
+ run: async (args) => {
531
+ const jobKey = requireString(args, "jobKey");
532
+ const retries = readInteger(args, "retries");
533
+ if (retries === undefined || retries < 0)
534
+ throw new McpError(ErrorCode.InvalidParams, "retries must be a non-negative integer");
535
+ await app.engine.updateJobRetries({ jobKey, retries });
536
+ return { jobKey, retries };
537
+ },
538
+ },
539
+ {
540
+ name: `${DEBUG_PREFIX}set_variables`,
541
+ description: "MUTATING: merge variables into a scope (a process-instance or element-instance key); set `local` true to keep them in that local scope. Repairs state before resolving an incident. Requires the shared-secret guard.",
542
+ mutating: true,
543
+ inputSchema: {
544
+ type: "object",
545
+ properties: {
546
+ scopeKey: OPTIONAL_STRING,
547
+ variables: { type: "object" },
548
+ local: { type: "boolean" },
549
+ },
550
+ required: ["scopeKey", "variables"],
551
+ },
552
+ run: async (args) => {
553
+ const scopeKey = requireString(args, "scopeKey");
554
+ const variables = readObject(args, "variables");
555
+ if (variables === undefined)
556
+ throw new McpError(ErrorCode.InvalidParams, "variables must be an object");
557
+ const input = {
558
+ scopeKey,
559
+ variables,
560
+ };
561
+ if (args.local === true)
562
+ input.local = true;
563
+ await app.engine.setVariables(input);
564
+ return { scopeKey, set: Object.keys(variables) };
565
+ },
566
+ },
391
567
  {
392
568
  name: `${DEBUG_PREFIX}instance_state`,
393
569
  description: "Projection (urban_instance_state, ADR 0065): the canonical engine-lifecycle state for an instance.",
@@ -397,10 +573,10 @@ function buildDebugTools(app) {
397
573
  required: ["processInstanceKey"],
398
574
  },
399
575
  run: (args) => {
576
+ const key = requireString(args, "processInstanceKey");
400
577
  const db = projectionDb();
401
578
  if (!db)
402
579
  return Promise.resolve(null);
403
- const key = readString(args, "processInstanceKey") ?? "";
404
580
  return Promise.resolve(new InstanceStateStore(db).getState(key) ?? null);
405
581
  },
406
582
  },
@@ -413,10 +589,10 @@ function buildDebugTools(app) {
413
589
  required: ["processInstanceKey"],
414
590
  },
415
591
  run: (args) => {
592
+ const key = requireString(args, "processInstanceKey");
416
593
  const db = projectionDb();
417
594
  if (!db)
418
595
  return Promise.resolve([]);
419
- const key = readString(args, "processInstanceKey") ?? "";
420
596
  return Promise.resolve(new OpenUserTasksStore(db).openTasks(key));
421
597
  },
422
598
  },
@@ -440,25 +616,46 @@ export function mountMcp(ctx, app, apiRoutes) {
440
616
  // calls share ONE dispatcher instance (no forked dispatcher, no second doc/delegate cache). A tool
441
617
  // call is reconstructed into the operation's HTTP request and routed through this same router.
442
618
  const apiRouter = makeRouter(apiRoutes);
443
- // The read-only operation table, projected lazily from the same spec `mountApi` reads and cached
444
- // (mirroring `mountApi`'s lazy `loadDoc`). A missing/broken spec degrades to "no app tools"
445
- // rather than breaking the whole MCP endpoint (the debug tools + resource + prompt still serve).
446
- let opsPromise;
447
- const loadReadOnlyOps = () => {
448
- if (!opsPromise) {
449
- opsPromise = (async () => {
619
+ let docPromise;
620
+ const loadSpecDoc = () => {
621
+ if (!docPromise) {
622
+ docPromise = (async () => {
450
623
  const binding = readApiBinding(ctx.manifest);
451
624
  if (!binding)
452
- return [];
625
+ return { doc: undefined, failed: false };
453
626
  const text = await ctx.host.readTextFile(resolveAppPath(ctx.root, binding.spec));
454
- const readOnly = collectOperations(parseSpec(text)).filter(isReadOnlyOperation);
627
+ return { doc: parseSpec(text) };
628
+ })().catch((e) => {
629
+ docPromise = undefined; // don't cache the failure — let a later request retry
630
+ ctx.host.log("warn", "mcp: failed to load the app OpenAPI spec", { error: errorMessage(e) });
631
+ return { doc: undefined, failed: true };
632
+ });
633
+ }
634
+ return docPromise;
635
+ };
636
+ // The projected app operation table (read AND mutating), derived from the same enumeration
637
+ // `mountApi` routes. `x-mcp`-excluded operations are dropped (the security-relevant opt-out) as
638
+ // are reserved-namespace ids; the read/mutate split is applied at annotation + guard time via
639
+ // `isMutatingOperation`, never a forked walker. Memoized PER parsed spec document (keyed on the
640
+ // cached doc promise `loadSpecDoc` returns): `tools/list` and `tools/call` both hit this on every
641
+ // request, so recomputing `collectOperations` + the reserved-namespace filter (and re-emitting its
642
+ // warn) each time is avoidable overhead and log spam. When the spec fails to load `loadSpecDoc`
643
+ // resets its promise, so the key changes and the projection is recomputed on the retry.
644
+ let projectedOps;
645
+ const loadProjectedOps = () => {
646
+ const docKey = loadSpecDoc();
647
+ if (projectedOps?.docKey !== docKey) {
648
+ const ops = docKey.then(({ doc }) => {
649
+ if (!doc)
650
+ return [];
651
+ const projected = collectOperations(doc).filter((op) => !op.mcpExcluded);
455
652
  // The `urban_debug_` namespace is framework-reserved (see DEBUG_PREFIX). A `urban_debug_*`
456
653
  // operationId is a legal safe path segment, so an app could declare one — but `CallTool`
457
654
  // resolves `debugByName` before app ops, so such an op would be shadowed by the framework
458
655
  // tool AND surface a duplicate name in `tools/list`. Drop reserved-namespace app ops here
459
656
  // (the single projection source both `tools/list` and `CallTool` read), warning per drop,
460
657
  // so the collision is impossible by construction rather than order-dependent.
461
- return readOnly.filter((op) => {
658
+ return projected.filter((op) => {
462
659
  if (op.operationId.startsWith(DEBUG_PREFIX)) {
463
660
  ctx.host.log("warn", "mcp: dropped app operation using reserved framework tool namespace", {
464
661
  operationId: op.operationId,
@@ -468,24 +665,80 @@ export function mountMcp(ctx, app, apiRoutes) {
468
665
  }
469
666
  return true;
470
667
  });
471
- })().catch((e) => {
472
- opsPromise = undefined; // don't cache the failure — let a later request retry
473
- ctx.host.log("warn", "mcp: failed to project app operations as tools", { error: errorMessage(e) });
474
- return [];
475
668
  });
669
+ projectedOps = { docKey, ops };
476
670
  }
477
- return opsPromise;
671
+ return projectedOps.ops;
478
672
  };
479
673
  const debugTools = buildDebugTools(app);
480
674
  const debugByName = new Map(debugTools.map((t) => [t.name, t]));
481
- const dispatchOperationTool = async (op, args) => {
675
+ // A minimal `OperationInfo` that requires ONLY the app's shared-secret scheme, so the framework
676
+ // mutating tools reuse `evaluateSecurity` VERBATIM (constant-time compare, misconfig→500, absent
677
+ // credential→401) rather than a bespoke comparison — the same enforcement path an app operation's
678
+ // own `security` takes. It is never routed; only its `security` field is read.
679
+ const sharedSecretOp = (schemeName) => ({
680
+ operationId: "__mcp_mutation_guard__",
681
+ method: "post",
682
+ path: "/__mcp_mutation_guard__",
683
+ parameters: [],
684
+ requestBodyRequired: false,
685
+ responseSchemas: [],
686
+ security: [{ [schemeName]: [] }],
687
+ eject: false,
688
+ mcpExcluded: false,
689
+ });
690
+ // Decide whether a mutating tool call is authorized. Either an explicit runtime opt-in
691
+ // (`mcp.allowMutations` / `URBAN_MCP_ALLOW_MUTATIONS`) opens mutations credential-free — but only
692
+ // LOOPBACK-ONLY (`!allowRemote`), so exposing the surface to non-loopback callers can never
693
+ // silently drop the guard — OR the client presented the app's shared secret (the apiKey header
694
+ // scheme, via `x-nano-secret-env`). With no shared-secret scheme configured and no (in-scope)
695
+ // opt-in, mutations are refused (fail closed).
696
+ const NO_SHARED_SECRET_SCHEME_ERROR = "mutating MCP tools require the app's shared secret (an apiKey header security scheme with x-nano-secret-env), or the explicit mcp.allowMutations opt-in (honoured loopback-only, i.e. when mcp.allowRemote is off)";
697
+ const authorizeMutation = async (headers) => {
698
+ // The credential-free opt-in is loopback-only: with `allowRemote` on, a non-loopback caller
699
+ // could otherwise reach mutating `urban_debug_*` tools with no shared-secret guard at all, which
700
+ // contradicts the "trusted, purely local box" intent. When remote exposure is enabled we ignore
701
+ // the bypass and fall through to require the shared secret.
702
+ if (config.allowMutations && !config.allowRemote)
703
+ return { ok: true };
704
+ const loaded = await loadSpecDoc();
705
+ if (!loaded.doc && loaded.failed) {
706
+ // A broken/unparseable spec is a server misconfiguration (500) — already logged by
707
+ // `loadSpecDoc` — not a missing credential (401): with no parsed spec we cannot even tell
708
+ // whether a shared-secret scheme exists to authorize against, so mirror `mountApi` and surface
709
+ // a 500 (the CallTool handler logs it) rather than a misleading 401.
710
+ return {
711
+ ok: false,
712
+ status: 500,
713
+ error: "mutating MCP tools cannot be authorized: the app OpenAPI spec failed to load (server misconfiguration — see the logged 'mcp: failed to load the app OpenAPI spec' error)",
714
+ };
715
+ }
716
+ // A misconfigured spec — more than one candidate shared-secret scheme, so which one guards
717
+ // mutations would depend on authoring order — is a 500 (see `sharedSecretSchemeName`), not a
718
+ // missing/bad credential. Surface it like the other misconfiguration branches above.
719
+ let schemeName;
720
+ try {
721
+ schemeName = loaded.doc ? sharedSecretSchemeName(loaded.doc) : undefined;
722
+ }
723
+ catch (e) {
724
+ return { ok: false, status: 500, error: errorMessage(e) };
725
+ }
726
+ if (!loaded.doc || !schemeName) {
727
+ return { ok: false, status: 401, error: NO_SHARED_SECRET_SCHEME_ERROR };
728
+ }
729
+ return evaluateSecurity(loaded.doc, sharedSecretOp(schemeName), (name) => readHeader(headers, name), () => undefined, (envVar) => ctx.host.env(envVar));
730
+ };
731
+ const dispatchOperationTool = async (op, args, headers) => {
482
732
  const missing = missingPathParams(op, args);
483
733
  if (missing.length > 0) {
484
734
  // Surface as an error tool result (like a reused-validator failure), not a silent 404 from an
485
735
  // empty-string substitution — the calling agent sees exactly which required params it omitted.
486
736
  return textResult({ error: `validation failed: missing required path parameter(s): ${missing.join(", ")}` }, true);
487
737
  }
488
- const res = await apiRouter(toHttpRequest(op, args));
738
+ // Forward the MCP connection's headers so the reused router enforces the operation's own
739
+ // OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
740
+ // route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
741
+ const res = await apiRouter(toHttpRequest(op, args, headers));
489
742
  const status = res.status ?? 200;
490
743
  return textResult(res.body ?? "", status >= 400);
491
744
  };
@@ -502,35 +755,60 @@ export function mountMcp(ctx, app, apiRoutes) {
502
755
  "This endpoint is the app's runtime-served MCP surface (ADR 0067).",
503
756
  "",
504
757
  `- Read the \`${SYSTEM_BRIEF_URI}\` resource first for the app's system model (processes, service-task call graph, decisions, ownership).`,
505
- "- App tools mirror the app's read-only HTTP operations one-to-one: a tool call is equivalent to the corresponding `/app/api` HTTP call, validated identically.",
506
- `- \`${DEBUG_PREFIX}*\` tools are framework-owned read-only process-debugging tools: engine truth (process instances, wait states, element instances, user tasks) and the ADR 0065 projection stores (instance state, open user tasks).`,
507
- "- This slice is read-only; mutating tools are added in a later slice behind an access guard.",
758
+ "- App tools mirror the app's HTTP operations one-to-one: a tool call is equivalent to the corresponding `/app/api` HTTP call, validated identically and authorized exactly as that HTTP route by the operation's own OpenAPI `security` (an operation with no `security` is open — there is no extra MCP-level guard). An operation is exposed unless it opts out with the `x-mcp` extension (a security-relevant authoring switch).",
759
+ `- \`${DEBUG_PREFIX}*\` READ tools are framework-owned process-debugging tools: engine truth (process instances, wait states, element instances, user tasks, incidents) and the ADR 0065 projection stores (instance state, open user tasks). They are unauthenticated on loopback.`,
760
+ `- \`${DEBUG_PREFIX}*\` MUTATING tools (cancel_instance, resolve_incident, retry_job, set_variables) are framework-GUARDED: present the app's shared secret as its apiKey header on the MCP connection, or the operator must set the mcp.allowMutations opt-in (honoured loopback-only — when mcp.allowRemote is enabled, mutations always require the shared secret). A mutating call without the credential is refused. (App operation tools are NOT covered by this guard — they are authorized by their own OpenAPI \`security\`, above.)`,
508
761
  ].join("\n");
509
762
  /** Build a fresh low-level MCP `Server` with this app's tools/resources/prompts wired. One is
510
763
  * created per session and connected to that session's transport. */
511
764
  const buildServer = () => {
512
765
  const server = new Server({ name: "urban", version: "1.0.0" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
513
766
  server.setRequestHandler(ListToolsRequestSchema, async () => {
514
- const ops = await loadReadOnlyOps();
515
- const appTools = ops.map((op) => ({
516
- name: op.operationId,
517
- description: op.summary ?? `${op.method.toUpperCase()} ${op.path}`,
518
- inputSchema: toolInputSchema(op),
519
- annotations: { readOnlyHint: true },
520
- }));
767
+ const ops = await loadProjectedOps();
768
+ const appTools = ops.map((op) => {
769
+ const mutating = isMutatingOperation(op);
770
+ return {
771
+ name: op.operationId,
772
+ description: op.summary ?? `${op.method.toUpperCase()} ${op.path}`,
773
+ inputSchema: toolInputSchema(op),
774
+ annotations: { readOnlyHint: !mutating, destructiveHint: mutating },
775
+ };
776
+ });
521
777
  const frameworkTools = debugTools.map((t) => ({
522
778
  name: t.name,
523
779
  description: t.description,
524
780
  inputSchema: t.inputSchema,
525
- annotations: { readOnlyHint: true },
781
+ annotations: { readOnlyHint: !t.mutating, destructiveHint: t.mutating === true },
526
782
  }));
527
783
  return { tools: [...appTools, ...frameworkTools] };
528
784
  });
529
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
785
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
530
786
  const { name } = request.params;
531
787
  const args = isRecord(request.params.arguments) ? request.params.arguments : {};
788
+ // The client presents its shared-secret credential as a header on the MCP connection; the SDK
789
+ // surfaces the request's headers here via `requestInfo`. Empty when a host cannot supply them
790
+ // (then a mutating call falls back to refuse unless the runtime opt-in is set).
791
+ const headers = extra.requestInfo?.headers ?? {};
532
792
  const debug = debugByName.get(name);
533
793
  if (debug) {
794
+ if (debug.mutating) {
795
+ const authz = await authorizeMutation(headers);
796
+ if (!authz.ok) {
797
+ const status = authz.status ?? 401;
798
+ // Mirror `mountApi`'s security handling: a `500` refusal means the shared-secret scheme
799
+ // is misconfigured (unknown/unsupported scheme, or an unset secret env var) rather than a
800
+ // bad/absent credential. Log it — as `mountApi` does for its OpenAPI security — so an
801
+ // operator can diagnose the misconfiguration, and carry the status in the error payload
802
+ // so a client can distinguish a `500` misconfig from a `401` unauthorized.
803
+ if (status === 500) {
804
+ ctx.host.log("error", "mcp mutating tool security is misconfigured (ADR 0059)", {
805
+ tool: name,
806
+ reason: authz.error,
807
+ });
808
+ }
809
+ return textResult({ error: authz.error ?? "unauthorized", status }, true);
810
+ }
811
+ }
534
812
  const missing = missingRequiredArgs(debug.inputSchema, args);
535
813
  if (missing.length > 0) {
536
814
  // Fail fast like an operation-tool validation error, instead of substituting an empty
@@ -544,12 +822,14 @@ export function mountMcp(ctx, app, apiRoutes) {
544
822
  return textResult({ error: errorMessage(e) }, true);
545
823
  }
546
824
  }
547
- const ops = await loadReadOnlyOps();
825
+ const ops = await loadProjectedOps();
548
826
  const op = ops.find((o) => o.operationId === name);
549
827
  if (!op) {
550
828
  throw new McpError(ErrorCode.MethodNotFound, `no such tool: ${name}`);
551
829
  }
552
- return dispatchOperationTool(op, args);
830
+ // App operation tools inherit their OpenAPI `security` — the forwarded headers let the reused
831
+ // router enforce it, so a mutating (or secured read) app op is guarded exactly as its HTTP route.
832
+ return dispatchOperationTool(op, args, headers);
553
833
  });
554
834
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
555
835
  resources: [
@@ -703,7 +983,12 @@ export function mountMcp(ctx, app, apiRoutes) {
703
983
  routes,
704
984
  enabled: config.enabled,
705
985
  describe: () => ({
706
- mcp: { path: MCP_PATH, enabled: config.enabled, loopbackOnly: !config.allowRemote },
986
+ mcp: {
987
+ path: MCP_PATH,
988
+ enabled: config.enabled,
989
+ loopbackOnly: !config.allowRemote,
990
+ allowMutations: config.allowMutations,
991
+ },
707
992
  }),
708
993
  };
709
994
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.85.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,6 +93,8 @@
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",