@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
  //
@@ -44,15 +48,22 @@ import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
44
48
  import { SYSTEM_BRIEF_JSON } from "../../../toolkit/derivers/system-brief.ts";
45
49
  import {
46
50
  collectOperations,
51
+ evaluateSecurity,
52
+ isMutatingMethod,
53
+ type OpenApiDoc,
47
54
  type OperationInfo,
48
55
  parseSpec,
56
+ type SecurityDecision,
57
+ sharedSecretSchemeName,
49
58
  } from "../../../openapi/spec.ts";
50
59
  import type { AppApi, RuntimeContext } from "../context.ts";
51
60
  import { errorMessage } from "../guards.ts";
52
61
  import type {
53
62
  ElementInstanceWaitStateFilter,
63
+ EngineIncidentState,
54
64
  HttpRequest,
55
65
  HttpResponse,
66
+ IncidentFilter,
56
67
  ProcessInstanceState,
57
68
  SqliteDb,
58
69
  UserTaskFilter,
@@ -151,6 +162,14 @@ interface McpConfig {
151
162
  readonly enabled: boolean;
152
163
  /** Whether non-loopback callers are allowed. Defaults OFF (loopback-only). */
153
164
  readonly allowRemote: boolean;
165
+ /** Explicit runtime opt-in that authorizes the framework MUTATING debug tools WITHOUT a
166
+ * shared-secret credential (ADR 0067 Slice 3). Defaults OFF: mutations then require the app's
167
+ * shared secret. An operator sets this to open mutations on a trusted, credential-less setup
168
+ * (e.g. a purely local debugging box). It is honoured LOOPBACK-ONLY: when `allowRemote` is also
169
+ * enabled the credential-free bypass is ignored and remote mutations still require the shared
170
+ * secret, so opening the surface to non-loopback callers can never silently expose unguarded
171
+ * mutating tools. */
172
+ readonly allowMutations: boolean;
154
173
  }
155
174
 
156
175
  function isRecord(v: unknown): v is Record<string, unknown> {
@@ -158,11 +177,13 @@ function isRecord(v: unknown): v is Record<string, unknown> {
158
177
  }
159
178
 
160
179
  /**
161
- * Resolve the MCP access policy from the manifest (`mcp: { enabled?, allowRemote? }`, read
162
- * reflectively until the app schema folds the field in — mirroring how `readApiBinding` reads
163
- * `api`) and the environment (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`). The env flags win so
164
- * an operator can force the surface off (or open it to a non-loopback bind) without editing the
165
- * manifest. Enabled defaults ON and access defaults to loopback-only ("defaults ON for loopback").
180
+ * Resolve the MCP access policy from the manifest
181
+ * (`mcp: { enabled?, allowRemote?, allowMutations? }`, read reflectively until the app schema folds
182
+ * the field in — mirroring how `readApiBinding` reads `api`) and the environment
183
+ * (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`, `URBAN_MCP_ALLOW_MUTATIONS`). The env flags win so
184
+ * an operator can force the surface off (or open it to a non-loopback bind, or enable mutating
185
+ * tools) without editing the manifest. Enabled defaults ON and access defaults to loopback-only
186
+ * ("defaults ON for loopback"); `allowMutations` defaults OFF (mutating tools stay closed).
166
187
  */
167
188
  export function readMcpConfig(manifest: unknown, env: (name: string) => string | undefined): McpConfig {
168
189
  const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "mcp") : undefined;
@@ -171,14 +192,19 @@ export function readMcpConfig(manifest: unknown, env: (name: string) => string |
171
192
  // surprisingly (e.g. the string `"false"` is not a boolean `false`).
172
193
  const rawEnabled = isRecord(raw) ? Reflect.get(raw, "enabled") : undefined;
173
194
  const rawAllowRemote = isRecord(raw) ? Reflect.get(raw, "allowRemote") : undefined;
195
+ const rawAllowMutations = isRecord(raw) ? Reflect.get(raw, "allowMutations") : undefined;
174
196
  const manifestEnabled = typeof rawEnabled === "boolean" ? rawEnabled : undefined;
175
197
  const manifestAllowRemote = typeof rawAllowRemote === "boolean" ? rawAllowRemote : undefined;
198
+ const manifestAllowMutations = typeof rawAllowMutations === "boolean" ? rawAllowMutations : undefined;
176
199
  const envEnabled = env("URBAN_MCP_ENABLED");
177
200
  const envAllowRemote = env("URBAN_MCP_ALLOW_REMOTE");
201
+ const envAllowMutations = env("URBAN_MCP_ALLOW_MUTATIONS");
178
202
  const enabled = envEnabled === "false" ? false : envEnabled === "true" ? true : manifestEnabled !== false;
179
203
  const allowRemote =
180
204
  envAllowRemote === "true" ? true : envAllowRemote === "false" ? false : manifestAllowRemote === true;
181
- return { enabled, allowRemote };
205
+ const allowMutations =
206
+ envAllowMutations === "true" ? true : envAllowMutations === "false" ? false : manifestAllowMutations === true;
207
+ return { enabled, allowRemote, allowMutations };
182
208
  }
183
209
 
184
210
  /** The host portion of an HTTP `Host` header, lower-cased and stripped of any `:port` (and of the
@@ -203,10 +229,25 @@ export function isLoopbackRequest(req: HttpRequest): boolean {
203
229
  return name === undefined || name === "localhost" || name === "127.0.0.1" || name === "::1";
204
230
  }
205
231
 
206
- /** True for a read-only operation — the only operations this slice projects as tools. Mutating
207
- * operations (POST/PUT/PATCH/DELETE) are withheld until Slice 3 adds them behind a guard. */
208
- function isReadOnlyOperation(op: OperationInfo): boolean {
209
- return op.method === "get" || op.method === "head";
232
+ /** Read a header value from the SDK's per-request `requestInfo.headers` map (lower-cased keys,
233
+ * values `string | string[] | undefined`). A repeated header (`string[]`, as Node's raw adapter
234
+ * surfaces it) is comma-joined to MATCH `Headers.get()` semantics — the same read `mountApi` and
235
+ * the reconstructed `toHttpRequest` use for OpenAPI route enforcement — so the shared-secret guard
236
+ * cannot diverge from route enforcement on a repeated credential header. Returns `undefined` when
237
+ * absent. Used to read the shared-secret credential the client presents on the MCP connection for a
238
+ * mutating tool call. */
239
+ export function readHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
240
+ const value = headers[name.toLowerCase()];
241
+ if (Array.isArray(value)) return value.join(", ");
242
+ return value;
243
+ }
244
+
245
+ /** A minimal read of the `x-mcp` extension for a projected operation is done at parse time
246
+ * (`OperationInfo.mcpExcluded`); here we only need the mutation classification, shared with the CI
247
+ * parity guard via {@link isMutatingMethod}. Kept as a named predicate for readability at the call
248
+ * sites (tool annotations + guard). */
249
+ function isMutatingOperation(op: OperationInfo): boolean {
250
+ return isMutatingMethod(op.method);
210
251
  }
211
252
 
212
253
  /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
@@ -301,16 +342,29 @@ export function missingRequiredArgs(inputSchema: Record<string, unknown>, args:
301
342
 
302
343
  /** Reconstruct the HTTP request an operation tool call is equivalent to, ready to hand to the
303
344
  * reused `mountApi` router. The JSON body is the tool's `body` argument (when the op declares one).
304
- */
305
- function toHttpRequest(op: OperationInfo, args: Record<string, unknown>): HttpRequest {
345
+ * The MCP connection's request headers are FORWARDED (with `content-type` pinned to JSON) so the
346
+ * reused router's `evaluateSecurity` sees the client's credential: a mutating (or a secured
347
+ * read-only) app operation is then guarded exactly as its HTTP route is — the app operation tools
348
+ * inherit the OpenAPI `security` contract rather than a bespoke MCP gate (ADR 0067 §4). */
349
+ function toHttpRequest(
350
+ op: OperationInfo,
351
+ args: Record<string, unknown>,
352
+ forwardHeaders: Record<string, string | string[] | undefined>,
353
+ ): HttpRequest {
306
354
  const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
307
355
  ? JSON.stringify(args.body)
308
356
  : "";
357
+ const headers = new Headers();
358
+ for (const [key, value] of Object.entries(forwardHeaders)) {
359
+ if (typeof value === "string") headers.set(key, value);
360
+ else if (Array.isArray(value)) for (const v of value) headers.append(key, v);
361
+ }
362
+ headers.set("content-type", "application/json");
309
363
  return {
310
364
  method: op.method.toUpperCase(),
311
365
  path: fillPath(op, args),
312
366
  query: toolQuery(op, args),
313
- headers: new Headers({ "content-type": "application/json" }),
367
+ headers,
314
368
  text: () => Promise.resolve(bodyText),
315
369
  };
316
370
  }
@@ -330,6 +384,31 @@ function readString(args: Record<string, unknown>, key: string): string | undefi
330
384
  return typeof v === "string" ? v : undefined;
331
385
  }
332
386
 
387
+ /** Read an OPTIONAL string argument in its TRIMMED present form, or `undefined` when it is missing,
388
+ * non-string, or blank/whitespace-only. The canonical read for identifying keys and filter values
389
+ * (processInstanceKey/elementId/assignee/candidateGroup): it applies the SAME
390
+ * {@link presentFormIdentifier} trimmed-presence rule that `missingRequiredArgs`/{@link isBlankArg}
391
+ * use for presence, so a padded value like `" pi-1 "` normalizes to `"pi-1"` instead of reaching
392
+ * the engine untrimmed — where a filter would silently match nothing and a keyed lookup would target
393
+ * a whitespace-padded entity that does not exist. The single source of truth {@link requireString}
394
+ * also derives from, so required and optional reads cannot drift on how they normalize. */
395
+ function readPresentString(args: Record<string, unknown>, key: string): string | undefined {
396
+ return presentFormIdentifier(readString(args, key));
397
+ }
398
+
399
+ /** Read a REQUIRED non-empty string argument, TRIMMED, or throw `InvalidParams`. The canonical guard
400
+ * the mutating debug tools use for their identifying keys (processInstanceKey/incidentKey/jobKey/
401
+ * scopeKey): a missing, non-string, or blank value fails fast before any engine state is touched,
402
+ * rather than silently degrading to `""` and performing a broken/misleading mutation. Derived from
403
+ * {@link readPresentString} — the same trimmed-presence rule {@link isBlankArg} (hence
404
+ * `missingRequiredArgs`) uses — so a padded key like `" pi-1 "` cannot pass the required-arg check
405
+ * yet reach the engine untrimmed and mis-target the entity. */
406
+ function requireString(args: Record<string, unknown>, key: string): string {
407
+ const present = readPresentString(args, key);
408
+ if (present === undefined) throw new McpError(ErrorCode.InvalidParams, `${key} must be a non-empty string`);
409
+ return present;
410
+ }
411
+
333
412
  function readStringArray(args: Record<string, unknown>, key: string): string[] | undefined {
334
413
  const v = args[key];
335
414
  if (!Array.isArray(v)) return undefined;
@@ -355,20 +434,45 @@ function readWaitStateType(args: Record<string, unknown>, key: string): WaitStat
355
434
  : undefined;
356
435
  }
357
436
 
358
- /** One framework-owned read-only debug tool: its MCP descriptor plus a handler that reads engine
359
- * truth or a projection store and returns JSON. */
437
+ /** Narrow an argument to a real engine incident state — the `"UNKNOWN"` client-side sentinel is
438
+ * deliberately NOT a selector (see `IncidentState`/`IncidentFilter`), so it is rejected here. */
439
+ function readIncidentState(args: Record<string, unknown>, key: string): EngineIncidentState | undefined {
440
+ const v = args[key];
441
+ return v === "ACTIVE" || v === "MIGRATED" || v === "PENDING" || v === "RESOLVED" ? v : undefined;
442
+ }
443
+
444
+ /** Read a plain JSON object argument (an MCP `object` input), else `undefined`. Used for the
445
+ * `set_variables` payload — an arbitrary variable map handed to the engine verbatim. */
446
+ function readObject(args: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
447
+ const v = args[key];
448
+ return isRecord(v) ? v : undefined;
449
+ }
450
+
451
+ /** Read a finite integer argument (for a job's retry count), else `undefined`. */
452
+ function readInteger(args: Record<string, unknown>, key: string): number | undefined {
453
+ const v = args[key];
454
+ return typeof v === "number" && Number.isInteger(v) ? v : undefined;
455
+ }
456
+
457
+ /** One framework-owned debug tool: its MCP descriptor plus a handler that reads engine truth / a
458
+ * projection store, or (for a `mutating` tool) performs an unstick operation. A `mutating` tool is
459
+ * gated by the shared-secret guard before its handler runs (ADR 0067 §4); a read tool is not. */
360
460
  interface DebugTool {
361
461
  readonly name: string;
362
462
  readonly description: string;
363
463
  readonly inputSchema: Record<string, unknown>;
464
+ /** Whether the tool mutates engine state — gates it behind the shared-secret guard and flips the
465
+ * `readOnlyHint` annotation off in `tools/list`. Read tools omit it (default read-only). */
466
+ readonly mutating?: boolean;
364
467
  run(args: Record<string, unknown>): Promise<unknown>;
365
468
  }
366
469
 
367
470
  const OPTIONAL_STRING = { type: "string" } as const;
368
471
 
369
472
  /** Build the framework debug tools, closing over the app's engine (truth plane) and default data
370
- * source (the ADR 0065 projection stores). The engine tools consume only EXISTING `EngineClient`
371
- * methods — no seam extension in this slice. */
473
+ * source (the ADR 0065 projection stores). READ tools consume the engine's read accessors and the
474
+ * projection stores; MUTATING tools (ADR 0067 Slice 3, gated by the shared-secret guard) drive the
475
+ * Slice 2 seam extension — cancel an instance, resolve/retry an incident, set variables. */
372
476
  function buildDebugTools(app: AppApi): DebugTool[] {
373
477
  const projectionDb = (): SqliteDb | undefined =>
374
478
  app.data.hasDefaultSource() ? app.data.source().db : undefined;
@@ -409,9 +513,9 @@ function buildDebugTools(app: AppApi): DebugTool[] {
409
513
  },
410
514
  run: (args) => {
411
515
  const filter: ElementInstanceWaitStateFilter = {};
412
- const pik = readString(args, "processInstanceKey");
516
+ const pik = readPresentString(args, "processInstanceKey");
413
517
  if (pik) filter.processInstanceKey = pik;
414
- const elementId = readString(args, "elementId");
518
+ const elementId = readPresentString(args, "elementId");
415
519
  if (elementId) filter.elementId = elementId;
416
520
  const waitStateType = readWaitStateType(args, "waitStateType");
417
521
  if (waitStateType) filter.waitStateType = waitStateType;
@@ -426,7 +530,7 @@ function buildDebugTools(app: AppApi): DebugTool[] {
426
530
  properties: { elementInstanceKey: OPTIONAL_STRING },
427
531
  required: ["elementInstanceKey"],
428
532
  },
429
- run: (args) => app.engine.getElementInstance(readString(args, "elementInstanceKey") ?? ""),
533
+ run: (args) => app.engine.getElementInstance(requireString(args, "elementInstanceKey")),
430
534
  },
431
535
  {
432
536
  name: `${DEBUG_PREFIX}search_user_tasks`,
@@ -442,17 +546,114 @@ function buildDebugTools(app: AppApi): DebugTool[] {
442
546
  },
443
547
  run: (args) => {
444
548
  const filter: UserTaskFilter & { state?: UserTaskState } = {};
445
- const pik = readString(args, "processInstanceKey");
549
+ const pik = readPresentString(args, "processInstanceKey");
446
550
  if (pik) filter.processInstanceKey = pik;
447
- const assignee = readString(args, "assignee");
551
+ const assignee = readPresentString(args, "assignee");
448
552
  if (assignee) filter.assignee = assignee;
449
- const candidateGroup = readString(args, "candidateGroup");
553
+ const candidateGroup = readPresentString(args, "candidateGroup");
450
554
  if (candidateGroup) filter.candidateGroup = candidateGroup;
451
555
  const state = readUserTaskState(args, "state");
452
556
  if (state) filter.state = state;
453
557
  return app.engine.searchUserTasks(filter);
454
558
  },
455
559
  },
560
+ {
561
+ name: `${DEBUG_PREFIX}search_incidents`,
562
+ description:
563
+ "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.",
564
+ inputSchema: {
565
+ type: "object",
566
+ properties: {
567
+ processInstanceKey: OPTIONAL_STRING,
568
+ state: { type: "string", enum: ["ACTIVE", "MIGRATED", "PENDING", "RESOLVED"] },
569
+ },
570
+ },
571
+ run: (args) => {
572
+ const filter: IncidentFilter = {};
573
+ const pik = readPresentString(args, "processInstanceKey");
574
+ if (pik) filter.processInstanceKey = pik;
575
+ const state = readIncidentState(args, "state");
576
+ if (state) filter.state = state;
577
+ return app.engine.searchIncidents(filter);
578
+ },
579
+ },
580
+ {
581
+ name: `${DEBUG_PREFIX}cancel_instance`,
582
+ description: "MUTATING: cancel a running process instance. Requires the shared-secret guard.",
583
+ mutating: true,
584
+ inputSchema: {
585
+ type: "object",
586
+ properties: { processInstanceKey: OPTIONAL_STRING },
587
+ required: ["processInstanceKey"],
588
+ },
589
+ run: async (args) => {
590
+ const processInstanceKey = requireString(args, "processInstanceKey");
591
+ await app.engine.cancelInstance({ processInstanceKey });
592
+ return { cancelled: processInstanceKey };
593
+ },
594
+ },
595
+ {
596
+ name: `${DEBUG_PREFIX}resolve_incident`,
597
+ description:
598
+ "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.",
599
+ mutating: true,
600
+ inputSchema: {
601
+ type: "object",
602
+ properties: { incidentKey: OPTIONAL_STRING },
603
+ required: ["incidentKey"],
604
+ },
605
+ run: async (args) => {
606
+ const incidentKey = requireString(args, "incidentKey");
607
+ await app.engine.resolveIncident({ incidentKey });
608
+ return { resolved: incidentKey };
609
+ },
610
+ },
611
+ {
612
+ name: `${DEBUG_PREFIX}retry_job`,
613
+ description:
614
+ "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.",
615
+ mutating: true,
616
+ inputSchema: {
617
+ type: "object",
618
+ properties: { jobKey: OPTIONAL_STRING, retries: { type: "integer", minimum: 0 } },
619
+ required: ["jobKey", "retries"],
620
+ },
621
+ run: async (args) => {
622
+ const jobKey = requireString(args, "jobKey");
623
+ const retries = readInteger(args, "retries");
624
+ if (retries === undefined || retries < 0)
625
+ throw new McpError(ErrorCode.InvalidParams, "retries must be a non-negative integer");
626
+ await app.engine.updateJobRetries({ jobKey, retries });
627
+ return { jobKey, retries };
628
+ },
629
+ },
630
+ {
631
+ name: `${DEBUG_PREFIX}set_variables`,
632
+ description:
633
+ "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.",
634
+ mutating: true,
635
+ inputSchema: {
636
+ type: "object",
637
+ properties: {
638
+ scopeKey: OPTIONAL_STRING,
639
+ variables: { type: "object" },
640
+ local: { type: "boolean" },
641
+ },
642
+ required: ["scopeKey", "variables"],
643
+ },
644
+ run: async (args) => {
645
+ const scopeKey = requireString(args, "scopeKey");
646
+ const variables = readObject(args, "variables");
647
+ if (variables === undefined) throw new McpError(ErrorCode.InvalidParams, "variables must be an object");
648
+ const input: { scopeKey: string; variables: Record<string, unknown>; local?: boolean } = {
649
+ scopeKey,
650
+ variables,
651
+ };
652
+ if (args.local === true) input.local = true;
653
+ await app.engine.setVariables(input);
654
+ return { scopeKey, set: Object.keys(variables) };
655
+ },
656
+ },
456
657
  {
457
658
  name: `${DEBUG_PREFIX}instance_state`,
458
659
  description: "Projection (urban_instance_state, ADR 0065): the canonical engine-lifecycle state for an instance.",
@@ -462,9 +663,9 @@ function buildDebugTools(app: AppApi): DebugTool[] {
462
663
  required: ["processInstanceKey"],
463
664
  },
464
665
  run: (args) => {
666
+ const key = requireString(args, "processInstanceKey");
465
667
  const db = projectionDb();
466
668
  if (!db) return Promise.resolve(null);
467
- const key = readString(args, "processInstanceKey") ?? "";
468
669
  return Promise.resolve(new InstanceStateStore(db).getState(key) ?? null);
469
670
  },
470
671
  },
@@ -477,9 +678,9 @@ function buildDebugTools(app: AppApi): DebugTool[] {
477
678
  required: ["processInstanceKey"],
478
679
  },
479
680
  run: (args) => {
681
+ const key = requireString(args, "processInstanceKey");
480
682
  const db = projectionDb();
481
683
  if (!db) return Promise.resolve([]);
482
- const key = readString(args, "processInstanceKey") ?? "";
483
684
  return Promise.resolve(new OpenUserTasksStore(db).openTasks(key));
484
685
  },
485
686
  },
@@ -506,24 +707,54 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
506
707
  // call is reconstructed into the operation's HTTP request and routed through this same router.
507
708
  const apiRouter = makeRouter(apiRoutes);
508
709
 
509
- // The read-only operation table, projected lazily from the same spec `mountApi` reads and cached
510
- // (mirroring `mountApi`'s lazy `loadDoc`). A missing/broken spec degrades to "no app tools"
511
- // rather than breaking the whole MCP endpoint (the debug tools + resource + prompt still serve).
512
- let opsPromise: Promise<OperationInfo[]> | undefined;
513
- const loadReadOnlyOps = (): Promise<OperationInfo[]> => {
514
- if (!opsPromise) {
515
- opsPromise = (async () => {
710
+ // The parsed OpenAPI document, loaded lazily from the same spec `mountApi` reads and cached
711
+ // (mirroring `mountApi`'s lazy `loadDoc`). Shared by the tool projection AND the mutation guard's
712
+ // shared-secret-scheme resolution, so both read one parse of one spec. The absent-`doc` outcome is
713
+ // discriminated: `failed: false` is a legitimately-absent binding (no app tools, no shared-secret
714
+ // scheme); `failed: true` is a broken/unparseable spec (a server misconfiguration, already logged)
715
+ // — the mutation guard needs the distinction to answer a `401` (nothing to authorize against) vs a
716
+ // `500` (spec broken, can't even tell) rather than conflating both as unauthorized. A failure is
717
+ // not cached, so a later request retries.
718
+ type SpecLoad = { doc: OpenApiDoc } | { doc: undefined; failed: boolean };
719
+ let docPromise: Promise<SpecLoad> | undefined;
720
+ const loadSpecDoc = (): Promise<SpecLoad> => {
721
+ if (!docPromise) {
722
+ docPromise = (async (): Promise<SpecLoad> => {
516
723
  const binding = readApiBinding(ctx.manifest);
517
- if (!binding) return [];
724
+ if (!binding) return { doc: undefined, failed: false };
518
725
  const text = await ctx.host.readTextFile(resolveAppPath(ctx.root, binding.spec));
519
- const readOnly = collectOperations(parseSpec(text)).filter(isReadOnlyOperation);
726
+ return { doc: parseSpec(text) };
727
+ })().catch((e) => {
728
+ docPromise = undefined; // don't cache the failure — let a later request retry
729
+ ctx.host.log("warn", "mcp: failed to load the app OpenAPI spec", { error: errorMessage(e) });
730
+ return { doc: undefined, failed: true };
731
+ });
732
+ }
733
+ return docPromise;
734
+ };
735
+
736
+ // The projected app operation table (read AND mutating), derived from the same enumeration
737
+ // `mountApi` routes. `x-mcp`-excluded operations are dropped (the security-relevant opt-out) as
738
+ // are reserved-namespace ids; the read/mutate split is applied at annotation + guard time via
739
+ // `isMutatingOperation`, never a forked walker. Memoized PER parsed spec document (keyed on the
740
+ // cached doc promise `loadSpecDoc` returns): `tools/list` and `tools/call` both hit this on every
741
+ // request, so recomputing `collectOperations` + the reserved-namespace filter (and re-emitting its
742
+ // warn) each time is avoidable overhead and log spam. When the spec fails to load `loadSpecDoc`
743
+ // resets its promise, so the key changes and the projection is recomputed on the retry.
744
+ let projectedOps: { docKey: Promise<SpecLoad>; ops: Promise<OperationInfo[]> } | undefined;
745
+ const loadProjectedOps = (): Promise<OperationInfo[]> => {
746
+ const docKey = loadSpecDoc();
747
+ if (projectedOps?.docKey !== docKey) {
748
+ const ops = docKey.then(({ doc }) => {
749
+ if (!doc) return [];
750
+ const projected = collectOperations(doc).filter((op) => !op.mcpExcluded);
520
751
  // The `urban_debug_` namespace is framework-reserved (see DEBUG_PREFIX). A `urban_debug_*`
521
752
  // operationId is a legal safe path segment, so an app could declare one — but `CallTool`
522
753
  // resolves `debugByName` before app ops, so such an op would be shadowed by the framework
523
754
  // tool AND surface a duplicate name in `tools/list`. Drop reserved-namespace app ops here
524
755
  // (the single projection source both `tools/list` and `CallTool` read), warning per drop,
525
756
  // so the collision is impossible by construction rather than order-dependent.
526
- return readOnly.filter((op) => {
757
+ return projected.filter((op) => {
527
758
  if (op.operationId.startsWith(DEBUG_PREFIX)) {
528
759
  ctx.host.log("warn", "mcp: dropped app operation using reserved framework tool namespace", {
529
760
  operationId: op.operationId,
@@ -533,21 +764,85 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
533
764
  }
534
765
  return true;
535
766
  });
536
- })().catch((e) => {
537
- opsPromise = undefined; // don't cache the failure — let a later request retry
538
- ctx.host.log("warn", "mcp: failed to project app operations as tools", { error: errorMessage(e) });
539
- return [];
540
767
  });
768
+ projectedOps = { docKey, ops };
541
769
  }
542
- return opsPromise;
770
+ return projectedOps.ops;
543
771
  };
544
772
 
545
773
  const debugTools = buildDebugTools(app);
546
774
  const debugByName = new Map(debugTools.map((t) => [t.name, t]));
547
775
 
776
+ // A minimal `OperationInfo` that requires ONLY the app's shared-secret scheme, so the framework
777
+ // mutating tools reuse `evaluateSecurity` VERBATIM (constant-time compare, misconfig→500, absent
778
+ // credential→401) rather than a bespoke comparison — the same enforcement path an app operation's
779
+ // own `security` takes. It is never routed; only its `security` field is read.
780
+ const sharedSecretOp = (schemeName: string): OperationInfo => ({
781
+ operationId: "__mcp_mutation_guard__",
782
+ method: "post",
783
+ path: "/__mcp_mutation_guard__",
784
+ parameters: [],
785
+ requestBodyRequired: false,
786
+ responseSchemas: [],
787
+ security: [{ [schemeName]: [] }],
788
+ eject: false,
789
+ mcpExcluded: false,
790
+ });
791
+
792
+ // Decide whether a mutating tool call is authorized. Either an explicit runtime opt-in
793
+ // (`mcp.allowMutations` / `URBAN_MCP_ALLOW_MUTATIONS`) opens mutations credential-free — but only
794
+ // LOOPBACK-ONLY (`!allowRemote`), so exposing the surface to non-loopback callers can never
795
+ // silently drop the guard — OR the client presented the app's shared secret (the apiKey header
796
+ // scheme, via `x-nano-secret-env`). With no shared-secret scheme configured and no (in-scope)
797
+ // opt-in, mutations are refused (fail closed).
798
+ const NO_SHARED_SECRET_SCHEME_ERROR =
799
+ "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)";
800
+ const authorizeMutation = async (
801
+ headers: Record<string, string | string[] | undefined>,
802
+ ): Promise<SecurityDecision> => {
803
+ // The credential-free opt-in is loopback-only: with `allowRemote` on, a non-loopback caller
804
+ // could otherwise reach mutating `urban_debug_*` tools with no shared-secret guard at all, which
805
+ // contradicts the "trusted, purely local box" intent. When remote exposure is enabled we ignore
806
+ // the bypass and fall through to require the shared secret.
807
+ if (config.allowMutations && !config.allowRemote) return { ok: true };
808
+ const loaded = await loadSpecDoc();
809
+ if (!loaded.doc && loaded.failed) {
810
+ // A broken/unparseable spec is a server misconfiguration (500) — already logged by
811
+ // `loadSpecDoc` — not a missing credential (401): with no parsed spec we cannot even tell
812
+ // whether a shared-secret scheme exists to authorize against, so mirror `mountApi` and surface
813
+ // a 500 (the CallTool handler logs it) rather than a misleading 401.
814
+ return {
815
+ ok: false,
816
+ status: 500,
817
+ error:
818
+ "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)",
819
+ };
820
+ }
821
+ // A misconfigured spec — more than one candidate shared-secret scheme, so which one guards
822
+ // mutations would depend on authoring order — is a 500 (see `sharedSecretSchemeName`), not a
823
+ // missing/bad credential. Surface it like the other misconfiguration branches above.
824
+ let schemeName: string | undefined;
825
+ try {
826
+ schemeName = loaded.doc ? sharedSecretSchemeName(loaded.doc) : undefined;
827
+ } catch (e) {
828
+ return { ok: false, status: 500, error: errorMessage(e) };
829
+ }
830
+ if (!loaded.doc || !schemeName) {
831
+ return { ok: false, status: 401, error: NO_SHARED_SECRET_SCHEME_ERROR };
832
+ }
833
+ return evaluateSecurity(
834
+ loaded.doc,
835
+ sharedSecretOp(schemeName),
836
+ (name) => readHeader(headers, name),
837
+ () => undefined,
838
+ (envVar) => ctx.host.env(envVar),
839
+ );
840
+ };
841
+
548
842
  const dispatchOperationTool = async (
549
843
  op: OperationInfo,
550
844
  args: Record<string, unknown>,
845
+ headers: Record<string, string | string[] | undefined>,
551
846
  ): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean }> => {
552
847
  const missing = missingPathParams(op, args);
553
848
  if (missing.length > 0) {
@@ -558,7 +853,10 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
558
853
  true,
559
854
  );
560
855
  }
561
- const res = await apiRouter(toHttpRequest(op, args));
856
+ // Forward the MCP connection's headers so the reused router enforces the operation's own
857
+ // OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
858
+ // route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
859
+ const res = await apiRouter(toHttpRequest(op, args, headers));
562
860
  const status = res.status ?? 200;
563
861
  return textResult(res.body ?? "", status >= 400);
564
862
  };
@@ -578,9 +876,9 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
578
876
  "This endpoint is the app's runtime-served MCP surface (ADR 0067).",
579
877
  "",
580
878
  `- Read the \`${SYSTEM_BRIEF_URI}\` resource first for the app's system model (processes, service-task call graph, decisions, ownership).`,
581
- "- 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.",
582
- `- \`${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).`,
583
- "- This slice is read-only; mutating tools are added in a later slice behind an access guard.",
879
+ "- 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).",
880
+ `- \`${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.`,
881
+ `- \`${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.)`,
584
882
  ].join("\n");
585
883
 
586
884
  /** Build a fresh low-level MCP `Server` with this app's tools/resources/prompts wired. One is
@@ -592,27 +890,52 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
592
890
  );
593
891
 
594
892
  server.setRequestHandler(ListToolsRequestSchema, async () => {
595
- const ops = await loadReadOnlyOps();
596
- const appTools = ops.map((op) => ({
597
- name: op.operationId,
598
- description: op.summary ?? `${op.method.toUpperCase()} ${op.path}`,
599
- inputSchema: toolInputSchema(op),
600
- annotations: { readOnlyHint: true },
601
- }));
893
+ const ops = await loadProjectedOps();
894
+ const appTools = ops.map((op) => {
895
+ const mutating = isMutatingOperation(op);
896
+ return {
897
+ name: op.operationId,
898
+ description: op.summary ?? `${op.method.toUpperCase()} ${op.path}`,
899
+ inputSchema: toolInputSchema(op),
900
+ annotations: { readOnlyHint: !mutating, destructiveHint: mutating },
901
+ };
902
+ });
602
903
  const frameworkTools = debugTools.map((t) => ({
603
904
  name: t.name,
604
905
  description: t.description,
605
906
  inputSchema: t.inputSchema,
606
- annotations: { readOnlyHint: true },
907
+ annotations: { readOnlyHint: !t.mutating, destructiveHint: t.mutating === true },
607
908
  }));
608
909
  return { tools: [...appTools, ...frameworkTools] };
609
910
  });
610
911
 
611
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
912
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
612
913
  const { name } = request.params;
613
914
  const args = isRecord(request.params.arguments) ? request.params.arguments : {};
915
+ // The client presents its shared-secret credential as a header on the MCP connection; the SDK
916
+ // surfaces the request's headers here via `requestInfo`. Empty when a host cannot supply them
917
+ // (then a mutating call falls back to refuse unless the runtime opt-in is set).
918
+ const headers = extra.requestInfo?.headers ?? {};
614
919
  const debug = debugByName.get(name);
615
920
  if (debug) {
921
+ if (debug.mutating) {
922
+ const authz = await authorizeMutation(headers);
923
+ if (!authz.ok) {
924
+ const status = authz.status ?? 401;
925
+ // Mirror `mountApi`'s security handling: a `500` refusal means the shared-secret scheme
926
+ // is misconfigured (unknown/unsupported scheme, or an unset secret env var) rather than a
927
+ // bad/absent credential. Log it — as `mountApi` does for its OpenAPI security — so an
928
+ // operator can diagnose the misconfiguration, and carry the status in the error payload
929
+ // so a client can distinguish a `500` misconfig from a `401` unauthorized.
930
+ if (status === 500) {
931
+ ctx.host.log("error", "mcp mutating tool security is misconfigured (ADR 0059)", {
932
+ tool: name,
933
+ reason: authz.error,
934
+ });
935
+ }
936
+ return textResult({ error: authz.error ?? "unauthorized", status }, true);
937
+ }
938
+ }
616
939
  const missing = missingRequiredArgs(debug.inputSchema, args);
617
940
  if (missing.length > 0) {
618
941
  // Fail fast like an operation-tool validation error, instead of substituting an empty
@@ -628,12 +951,14 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
628
951
  return textResult({ error: errorMessage(e) }, true);
629
952
  }
630
953
  }
631
- const ops = await loadReadOnlyOps();
954
+ const ops = await loadProjectedOps();
632
955
  const op = ops.find((o) => o.operationId === name);
633
956
  if (!op) {
634
957
  throw new McpError(ErrorCode.MethodNotFound, `no such tool: ${name}`);
635
958
  }
636
- return dispatchOperationTool(op, args);
959
+ // App operation tools inherit their OpenAPI `security` — the forwarded headers let the reused
960
+ // router enforce it, so a mutating (or secured read) app op is guarded exactly as its HTTP route.
961
+ return dispatchOperationTool(op, args, headers);
637
962
  });
638
963
 
639
964
  server.setRequestHandler(ListResourcesRequestSchema, async () => ({
@@ -808,7 +1133,12 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
808
1133
  routes,
809
1134
  enabled: config.enabled,
810
1135
  describe: () => ({
811
- mcp: { path: MCP_PATH, enabled: config.enabled, loopbackOnly: !config.allowRemote },
1136
+ mcp: {
1137
+ path: MCP_PATH,
1138
+ enabled: config.enabled,
1139
+ loopbackOnly: !config.allowRemote,
1140
+ allowMutations: config.allowMutations,
1141
+ },
812
1142
  }),
813
1143
  };
814
1144
  }