@nanobpm/urban 0.87.0 → 0.87.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.
@@ -34,7 +34,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
34
34
  import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, isInitializeRequest, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
35
35
  import { GENERATED_DIR } from "../../../toolkit/artifact.js";
36
36
  import { SYSTEM_BRIEF_JSON } from "../../../toolkit/derivers/system-brief.js";
37
- import { collectOperations, evaluateSecurity, isMutatingMethod, parseSpec, sharedSecretSchemeName, toolInputSchema, } from "../../../openapi/spec.js";
37
+ import { collectOperations, evaluateSecurity, isMutatingMethod, isObjectSchema, parseSpec, resolveSchema, schemaHasType, sharedSecretSchemeName, toolInputSchema, } from "../../../openapi/spec.js";
38
38
  import { errorMessage } from "../guards.js";
39
39
  import { presentFormIdentifier } from "../host.js";
40
40
  import { resolveBindMode } from "../manifest.js";
@@ -257,6 +257,10 @@ export function missingRequiredArgs(inputSchema, args) {
257
257
  * read-only) app operation is then guarded exactly as its HTTP route is — the app operation tools
258
258
  * inherit the OpenAPI `security` contract rather than a bespoke MCP gate (ADR 0067 §4). */
259
259
  function toHttpRequest(op, args, forwardHeaders) {
260
+ // `args.body` is expected to already be the structured value the operation declares — a
261
+ // pre-encoded JSON-string body is normalized upstream by `normalizeBodyArg` (called in
262
+ // `dispatchOperationTool`) so `JSON.stringify` here serializes an object/array ONCE rather than
263
+ // double-encoding a quoted string the door would reject (ADR 0067).
260
264
  const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
261
265
  ? JSON.stringify(args.body)
262
266
  : "";
@@ -277,6 +281,69 @@ function toHttpRequest(op, args, forwardHeaders) {
277
281
  text: () => Promise.resolve(bodyText),
278
282
  };
279
283
  }
284
+ /** The operation's effective request-body schema for classifying a tool-call `body`: P0's resolved,
285
+ * `$ref`-free schema ({@link OperationInfo.requestBodySchemaResolved}, produced by
286
+ * `collectOperations`). As defence in depth, if that metadata is somehow still a bare `$ref` (it
287
+ * should never be after resolution), re-resolve it via {@link resolveSchema} against `doc` so the
288
+ * classification below never keys off an opaque `#/components/...` pointer. */
289
+ function effectiveRequestBodySchema(op, doc) {
290
+ const schema = op.requestBodySchemaResolved ?? op.requestBodySchema;
291
+ if (schema?.$ref)
292
+ return doc ? resolveSchema(doc, schema) : undefined;
293
+ return schema;
294
+ }
295
+ /** Whether the operation declares a STRUCTURED (object/array) request body — the shapes the app door
296
+ * expects as a JSON object/array rather than a bare scalar. Classified from the RESOLVED schema so a
297
+ * `$ref`-bodied op (e.g. `previewDeliveryGraph`, whose object `type` is only visible after P0's
298
+ * resolution) is recognized, not merely an inline-`type` object body. */
299
+ function structuredBodyKind(schema) {
300
+ if (!schema)
301
+ return undefined;
302
+ if (isObjectSchema(schema))
303
+ return "object";
304
+ if (schemaHasType(schema, "array"))
305
+ return "array";
306
+ return undefined;
307
+ }
308
+ /** Normalize a tool-call `body` argument for FAITHFUL transport (ADR 0067). A client that saw an
309
+ * opaque input schema — or that coerces non-primitive args — may send `body` as a pre-encoded JSON
310
+ * *string* instead of the object/array the operation declares. Left as-is, `toHttpRequest`'s
311
+ * `JSON.stringify` would DOUBLE-encode it into a quoted string and the door would reject it with
312
+ * "expected object, got string". So when the op declares an object/array body and `body` arrives as
313
+ * a string, parse it once so it round-trips as the structured value. A genuine object/array argument
314
+ * passes through untouched (still serialized by the transport). A string that does not parse to the
315
+ * declared shape is a CLEAR tool error naming the expected shape — never a silently double-encoded
316
+ * (and 4xx-rejected) request. */
317
+ function normalizeBodyArg(op, args, doc) {
318
+ const body = args.body;
319
+ const kind = structuredBodyKind(effectiveRequestBodySchema(op, doc));
320
+ if (body === undefined || kind === undefined || typeof body !== "string") {
321
+ return { ok: true, args };
322
+ }
323
+ let parsed;
324
+ try {
325
+ parsed = JSON.parse(body);
326
+ }
327
+ catch {
328
+ return {
329
+ ok: false,
330
+ error: `validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; the string is not valid JSON`,
331
+ };
332
+ }
333
+ const parsedKind = Array.isArray(parsed)
334
+ ? "array"
335
+ : parsed !== null && typeof parsed === "object"
336
+ ? "object"
337
+ : undefined;
338
+ if (parsedKind !== kind) {
339
+ const got = parsedKind ?? (parsed === null ? "null" : typeof parsed);
340
+ return {
341
+ ok: false,
342
+ error: `validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; it parsed to a ${got}`,
343
+ };
344
+ }
345
+ return { ok: true, args: { ...args, body: parsed } };
346
+ }
280
347
  /** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
281
348
  * non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
282
349
  function textResult(value, isError = false) {
@@ -716,10 +783,17 @@ export function mountMcp(ctx, app, apiRoutes) {
716
783
  // empty-string substitution — the calling agent sees exactly which required params it omitted.
717
784
  return textResult({ error: `validation failed: missing required path parameter(s): ${missing.join(", ")}` }, true);
718
785
  }
786
+ // Faithful body transport (ADR 0067): a client may send an object/array `body` pre-encoded as a
787
+ // JSON string. Normalize it to the structured value from the operation's RESOLVED body schema so
788
+ // it is not double-encoded by `toHttpRequest`; a string that cannot parse to the declared shape
789
+ // is a clear tool error rather than a silent 4xx from a double-encoded body.
790
+ const normalized = normalizeBodyArg(op, args, (await loadSpecDoc()).doc);
791
+ if (!normalized.ok)
792
+ return textResult({ error: normalized.error }, true);
719
793
  // Forward the MCP connection's headers so the reused router enforces the operation's own
720
794
  // OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
721
795
  // route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
722
- const res = await apiRouter(toHttpRequest(op, args, headers));
796
+ const res = await apiRouter(toHttpRequest(op, normalized.args, headers));
723
797
  const status = res.status ?? 200;
724
798
  return textResult(res.body ?? "", status >= 400);
725
799
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.87.0",
3
+ "version": "0.87.1",
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",
@@ -19,6 +19,11 @@
19
19
  # self-contained (no `$ref`, explicit
20
20
  # `type`) — an MCP client cannot resolve
21
21
  # `#/components/...` outside this document.
22
+ # - an INLINE object-body op (POST) → its request body declares `type: object`
23
+ # inline, exercising the faithful object-body
24
+ # transport path (object arg → door receives
25
+ # an object; a pre-encoded JSON string is
26
+ # parsed, not double-encoded).
22
27
  #
23
28
  # Edit this fixture ONLY together with a reviewed `npm run gen:mcp-tools`.
24
29
  openapi: 3.1.0
@@ -93,3 +98,25 @@ paths:
93
98
  responses:
94
99
  "200":
95
100
  description: ok
101
+ /widgets/bulk:
102
+ post:
103
+ operationId: bulkCreateWidgets
104
+ summary: "Bulk create widgets (mutating — inline `type: object` request body, object-arg round-trip case)."
105
+ requestBody:
106
+ required: true
107
+ content:
108
+ application/json:
109
+ schema:
110
+ type: object
111
+ required:
112
+ - items
113
+ properties:
114
+ items:
115
+ type: array
116
+ items:
117
+ type: string
118
+ dryRun:
119
+ type: boolean
120
+ responses:
121
+ "201":
122
+ description: created
@@ -64,5 +64,36 @@
64
64
  "type": "object",
65
65
  "properties": {}
66
66
  }
67
+ },
68
+ {
69
+ "operationId": "bulkCreateWidgets",
70
+ "method": "post",
71
+ "mutating": true,
72
+ "excluded": false,
73
+ "inputSchema": {
74
+ "type": "object",
75
+ "properties": {
76
+ "body": {
77
+ "type": "object",
78
+ "required": [
79
+ "items"
80
+ ],
81
+ "properties": {
82
+ "items": {
83
+ "type": "array",
84
+ "items": {
85
+ "type": "string"
86
+ }
87
+ },
88
+ "dryRun": {
89
+ "type": "boolean"
90
+ }
91
+ }
92
+ }
93
+ },
94
+ "required": [
95
+ "body"
96
+ ]
97
+ }
67
98
  }
68
99
  ]
@@ -356,6 +356,130 @@ test("a $ref-bodied tool projects a self-contained body schema (no $ref, explici
356
356
  assert.ok(!JSON.stringify(schema).includes("$ref"), "projected body schema must not leak a $ref");
357
357
  });
358
358
 
359
+ test("an object body reaches the door as an object; a pre-encoded JSON-string body is parsed, not double-encoded; a malformed string body is a clear error", async () => {
360
+ // `createInvoice`'s requestBody is `{ $ref: "#/components/schemas/Invoice" }` — an object body
361
+ // whose concrete `type` is only visible after the projection resolver inlines the component. The
362
+ // transport must classify it as an object body FROM THE RESOLVED SCHEMA (not the raw `$ref`) so the
363
+ // string-parse path triggers for exactly this motivating shape, not merely an inline-`type` body.
364
+ let seenBody: unknown = "unset";
365
+ const { router } = buildHarness({
366
+ modules: {
367
+ "/app/operations/createInvoice": {
368
+ default: (input: { body: unknown }) => {
369
+ seenBody = input.body;
370
+ return { body: { ok: true } };
371
+ },
372
+ },
373
+ },
374
+ });
375
+ const session = await connect(router);
376
+
377
+ // 1. A genuine object argument reaches the door (the reused delegate) AS AN OBJECT.
378
+ const objCall = await rpc(router, session, "tools/call", {
379
+ name: "createInvoice",
380
+ arguments: { body: { id: "7", amount: 3 } },
381
+ });
382
+ assert.notEqual(objCall.result?.isError, true, `object body call must succeed: ${toolContentText(objCall.result)}`);
383
+ assert.deepEqual(seenBody, { id: "7", amount: 3 }, "an object body argument must reach the door as an object");
384
+
385
+ // 2. A PRE-ENCODED JSON-string body is accepted and parsed — NOT double-encoded into a quoted
386
+ // string the door would reject with "expected object, got string".
387
+ seenBody = "unset";
388
+ const strCall = await rpc(router, session, "tools/call", {
389
+ name: "createInvoice",
390
+ arguments: { body: JSON.stringify({ id: "9", amount: 5 }) },
391
+ });
392
+ assert.notEqual(strCall.result?.isError, true, `pre-encoded string body must be accepted: ${toolContentText(strCall.result)}`);
393
+ assert.deepEqual(seenBody, { id: "9", amount: 5 }, "a pre-encoded JSON-string body must be parsed to an object at the door");
394
+
395
+ // 3. A malformed string body is a CLEAR tool error naming the expected shape — never a silent 4xx
396
+ // from a forwarded double-encoded body, and the delegate must not run.
397
+ seenBody = "unset";
398
+ const badCall = await rpc(router, session, "tools/call", {
399
+ name: "createInvoice",
400
+ arguments: { body: "{not valid json" },
401
+ });
402
+ assert.equal(badCall.result?.isError, true, "a malformed string body must return an isError result");
403
+ assert.match(toolContentText(badCall.result), /object/i, "the error must name the expected object shape");
404
+ assert.equal(seenBody, "unset", "the door delegate must not run for a malformed string body");
405
+
406
+ // 4. A string that parses to the WRONG shape (a scalar) is likewise a clear error, not a
407
+ // double-encoded forward — the object body path fails loudly on a non-object.
408
+ seenBody = "unset";
409
+ const wrongShape = await rpc(router, session, "tools/call", {
410
+ name: "createInvoice",
411
+ arguments: { body: "42" },
412
+ });
413
+ assert.equal(wrongShape.result?.isError, true, "a string parsing to a scalar must return an isError result");
414
+ assert.match(toolContentText(wrongShape.result), /object/i, "the error must name the expected object shape");
415
+ assert.equal(seenBody, "unset", "the door delegate must not run for a wrong-shape string body");
416
+ });
417
+
418
+ test("an array body reaches the door as an array; a pre-encoded JSON-string array body is parsed, not double-encoded; a wrong-shape string is a clear error", async () => {
419
+ // Mirror of the object-body transport test for the ARRAY branch: `structuredBodyKind` can return
420
+ // "array" and `normalizeBodyArg` has an `Array.isArray(parsed)` path, so a regression in array
421
+ // handling must be caught here. `createBatch`'s requestBody declares a `type: array` body — the
422
+ // transport must classify it as an array body and parse a pre-encoded JSON-string array once so it
423
+ // round-trips as an array rather than being double-encoded into a quoted string the door rejects.
424
+ const arraySpec = JSON.stringify({
425
+ openapi: "3.0.0",
426
+ paths: {
427
+ "/batch": {
428
+ post: {
429
+ operationId: "createBatch",
430
+ requestBody: {
431
+ required: true,
432
+ content: { "application/json": { schema: { type: "array", items: { type: "string" } } } },
433
+ },
434
+ responses: { "201": {} },
435
+ },
436
+ },
437
+ },
438
+ });
439
+ let seenBody: unknown = "unset";
440
+ const { router } = buildHarness({
441
+ spec: arraySpec,
442
+ modules: {
443
+ "/app/operations/createBatch": {
444
+ default: (input: { body: unknown }) => {
445
+ seenBody = input.body;
446
+ return { body: { ok: true } };
447
+ },
448
+ },
449
+ },
450
+ });
451
+ const session = await connect(router);
452
+
453
+ // 1. A genuine array argument reaches the door (the reused delegate) AS AN ARRAY.
454
+ const arrCall = await rpc(router, session, "tools/call", {
455
+ name: "createBatch",
456
+ arguments: { body: ["a", "b"] },
457
+ });
458
+ assert.notEqual(arrCall.result?.isError, true, `array body call must succeed: ${toolContentText(arrCall.result)}`);
459
+ assert.deepEqual(seenBody, ["a", "b"], "an array body argument must reach the door as an array");
460
+
461
+ // 2. A PRE-ENCODED JSON-string array body is accepted and parsed — NOT double-encoded into a quoted
462
+ // string the door would reject with "expected array, got string".
463
+ seenBody = "unset";
464
+ const strCall = await rpc(router, session, "tools/call", {
465
+ name: "createBatch",
466
+ arguments: { body: JSON.stringify(["a", "b"]) },
467
+ });
468
+ assert.notEqual(strCall.result?.isError, true, `pre-encoded string array body must be accepted: ${toolContentText(strCall.result)}`);
469
+ assert.deepEqual(seenBody, ["a", "b"], "a pre-encoded JSON-string array body must be parsed to an array at the door");
470
+
471
+ // 3. A string that parses to the WRONG shape (an object) is a clear error naming the expected
472
+ // array shape, not a double-encoded forward — and the delegate must not run.
473
+ seenBody = "unset";
474
+ const wrongShape = await rpc(router, session, "tools/call", {
475
+ name: "createBatch",
476
+ arguments: { body: JSON.stringify({ not: "an array" }) },
477
+ });
478
+ assert.equal(wrongShape.result?.isError, true, "a string parsing to an object must return an isError result");
479
+ assert.match(toolContentText(wrongShape.result), /array/i, "the error must name the expected array shape");
480
+ assert.equal(seenBody, "unset", "the door delegate must not run for a wrong-shape string array body");
481
+ });
482
+
359
483
  test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
360
484
  let seenParams: Record<string, unknown> | undefined;
361
485
  const { router, imported } = buildHarness({
@@ -50,9 +50,13 @@ import {
50
50
  collectOperations,
51
51
  evaluateSecurity,
52
52
  isMutatingMethod,
53
+ isObjectSchema,
53
54
  type OpenApiDoc,
55
+ type OpenApiSchema,
54
56
  type OperationInfo,
55
57
  parseSpec,
58
+ resolveSchema,
59
+ schemaHasType,
56
60
  type SecurityDecision,
57
61
  sharedSecretSchemeName,
58
62
  toolInputSchema,
@@ -336,6 +340,10 @@ function toHttpRequest(
336
340
  args: Record<string, unknown>,
337
341
  forwardHeaders: Record<string, string | string[] | undefined>,
338
342
  ): HttpRequest {
343
+ // `args.body` is expected to already be the structured value the operation declares — a
344
+ // pre-encoded JSON-string body is normalized upstream by `normalizeBodyArg` (called in
345
+ // `dispatchOperationTool`) so `JSON.stringify` here serializes an object/array ONCE rather than
346
+ // double-encoding a quoted string the door would reject (ADR 0067).
339
347
  const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
340
348
  ? JSON.stringify(args.body)
341
349
  : "";
@@ -354,6 +362,76 @@ function toHttpRequest(
354
362
  };
355
363
  }
356
364
 
365
+ /** The operation's effective request-body schema for classifying a tool-call `body`: P0's resolved,
366
+ * `$ref`-free schema ({@link OperationInfo.requestBodySchemaResolved}, produced by
367
+ * `collectOperations`). As defence in depth, if that metadata is somehow still a bare `$ref` (it
368
+ * should never be after resolution), re-resolve it via {@link resolveSchema} against `doc` so the
369
+ * classification below never keys off an opaque `#/components/...` pointer. */
370
+ function effectiveRequestBodySchema(
371
+ op: OperationInfo,
372
+ doc: OpenApiDoc | undefined,
373
+ ): OpenApiSchema | undefined {
374
+ const schema = op.requestBodySchemaResolved ?? op.requestBodySchema;
375
+ if (schema?.$ref) return doc ? resolveSchema(doc, schema) : undefined;
376
+ return schema;
377
+ }
378
+
379
+ /** Whether the operation declares a STRUCTURED (object/array) request body — the shapes the app door
380
+ * expects as a JSON object/array rather than a bare scalar. Classified from the RESOLVED schema so a
381
+ * `$ref`-bodied op (e.g. `previewDeliveryGraph`, whose object `type` is only visible after P0's
382
+ * resolution) is recognized, not merely an inline-`type` object body. */
383
+ function structuredBodyKind(schema: OpenApiSchema | undefined): "object" | "array" | undefined {
384
+ if (!schema) return undefined;
385
+ if (isObjectSchema(schema)) return "object";
386
+ if (schemaHasType(schema, "array")) return "array";
387
+ return undefined;
388
+ }
389
+
390
+ /** Normalize a tool-call `body` argument for FAITHFUL transport (ADR 0067). A client that saw an
391
+ * opaque input schema — or that coerces non-primitive args — may send `body` as a pre-encoded JSON
392
+ * *string* instead of the object/array the operation declares. Left as-is, `toHttpRequest`'s
393
+ * `JSON.stringify` would DOUBLE-encode it into a quoted string and the door would reject it with
394
+ * "expected object, got string". So when the op declares an object/array body and `body` arrives as
395
+ * a string, parse it once so it round-trips as the structured value. A genuine object/array argument
396
+ * passes through untouched (still serialized by the transport). A string that does not parse to the
397
+ * declared shape is a CLEAR tool error naming the expected shape — never a silently double-encoded
398
+ * (and 4xx-rejected) request. */
399
+ function normalizeBodyArg(
400
+ op: OperationInfo,
401
+ args: Record<string, unknown>,
402
+ doc: OpenApiDoc | undefined,
403
+ ): { ok: true; args: Record<string, unknown> } | { ok: false; error: string } {
404
+ const body = args.body;
405
+ const kind = structuredBodyKind(effectiveRequestBodySchema(op, doc));
406
+ if (body === undefined || kind === undefined || typeof body !== "string") {
407
+ return { ok: true, args };
408
+ }
409
+ let parsed: unknown;
410
+ try {
411
+ parsed = JSON.parse(body);
412
+ } catch {
413
+ return {
414
+ ok: false,
415
+ error:
416
+ `validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; the string is not valid JSON`,
417
+ };
418
+ }
419
+ const parsedKind = Array.isArray(parsed)
420
+ ? "array"
421
+ : parsed !== null && typeof parsed === "object"
422
+ ? "object"
423
+ : undefined;
424
+ if (parsedKind !== kind) {
425
+ const got = parsedKind ?? (parsed === null ? "null" : typeof parsed);
426
+ return {
427
+ ok: false,
428
+ error:
429
+ `validation failed: the "body" argument was sent as a string but this operation expects a JSON ${kind}; it parsed to a ${got}`,
430
+ };
431
+ }
432
+ return { ok: true, args: { ...args, body: parsed } };
433
+ }
434
+
357
435
  /** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
358
436
  * non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
359
437
  function textResult(value: unknown, isError = false): {
@@ -838,10 +916,16 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
838
916
  true,
839
917
  );
840
918
  }
919
+ // Faithful body transport (ADR 0067): a client may send an object/array `body` pre-encoded as a
920
+ // JSON string. Normalize it to the structured value from the operation's RESOLVED body schema so
921
+ // it is not double-encoded by `toHttpRequest`; a string that cannot parse to the declared shape
922
+ // is a clear tool error rather than a silent 4xx from a double-encoded body.
923
+ const normalized = normalizeBodyArg(op, args, (await loadSpecDoc()).doc);
924
+ if (!normalized.ok) return textResult({ error: normalized.error }, true);
841
925
  // Forward the MCP connection's headers so the reused router enforces the operation's own
842
926
  // OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
843
927
  // route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
844
- const res = await apiRouter(toHttpRequest(op, args, headers));
928
+ const res = await apiRouter(toHttpRequest(op, normalized.args, headers));
845
929
  const status = res.status ?? 200;
846
930
  return textResult(res.body ?? "", status >= 400);
847
931
  };