@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.
- package/dist/openapi/spec.d.ts +72 -0
- package/dist/openapi/spec.js +130 -0
- package/dist/runtime/core/modules/mcp.d.ts +23 -5
- package/dist/runtime/core/modules/mcp.js +346 -61
- 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/modules/mcp.test.ts +526 -14
- package/src/runtime/core/modules/mcp.ts +391 -61
|
@@ -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
|
//
|
|
@@ -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
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
147
|
-
*
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
299
|
-
*
|
|
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 =
|
|
419
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
341
420
|
if (pik)
|
|
342
421
|
filter.processInstanceKey = pik;
|
|
343
|
-
const 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(
|
|
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 =
|
|
455
|
+
const pik = readPresentString(args, "processInstanceKey");
|
|
377
456
|
if (pik)
|
|
378
457
|
filter.processInstanceKey = pik;
|
|
379
|
-
const assignee =
|
|
458
|
+
const assignee = readPresentString(args, "assignee");
|
|
380
459
|
if (assignee)
|
|
381
460
|
filter.assignee = assignee;
|
|
382
|
-
const 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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
506
|
-
`- \`${DEBUG_PREFIX}*\` tools are framework-owned
|
|
507
|
-
|
|
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
|
|
515
|
-
const appTools = ops.map((op) =>
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
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",
|