@nanobpm/urban 0.85.0 → 0.86.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/openapi/spec.d.ts +72 -0
- package/dist/openapi/spec.js +130 -0
- package/dist/runtime/core/host.d.ts +67 -9
- package/dist/runtime/core/host.js +48 -0
- package/dist/runtime/core/modules/mcp.d.ts +23 -5
- package/dist/runtime/core/modules/mcp.js +346 -61
- package/dist/runtime/engine/nanosdk.js +9 -5
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +4 -0
- package/package.json +3 -1
- package/src/openapi/mcp-parity.fixture.yaml +49 -0
- package/src/openapi/mcp-tools.snapshot.json +20 -0
- package/src/openapi/spec.test.ts +92 -0
- package/src/openapi/spec.ts +161 -0
- package/src/runtime/core/host.ts +83 -9
- package/src/runtime/core/modules/mcp.test.ts +526 -14
- package/src/runtime/core/modules/mcp.ts +391 -61
- package/src/runtime/engine/nanosdk.test.ts +69 -11
- package/src/runtime/engine/nanosdk.ts +9 -5
- package/src/runtime/index.ts +8 -0
|
@@ -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
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
207
|
-
*
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
359
|
-
*
|
|
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).
|
|
371
|
-
*
|
|
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 =
|
|
516
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
413
517
|
if (pik) filter.processInstanceKey = pik;
|
|
414
|
-
const 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(
|
|
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 =
|
|
549
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
446
550
|
if (pik) filter.processInstanceKey = pik;
|
|
447
|
-
const assignee =
|
|
551
|
+
const assignee = readPresentString(args, "assignee");
|
|
448
552
|
if (assignee) filter.assignee = assignee;
|
|
449
|
-
const 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
|
|
510
|
-
// (mirroring `mountApi`'s lazy `loadDoc`).
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
582
|
-
`- \`${DEBUG_PREFIX}*\` tools are framework-owned
|
|
583
|
-
|
|
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
|
|
596
|
-
const appTools = ops.map((op) =>
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
}
|