@nanobpm/urban 0.86.1 → 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.
- package/dist/openapi/spec.d.ts +63 -5
- package/dist/openapi/spec.js +172 -3
- package/dist/runtime/core/modules/mcp.js +80 -25
- package/package.json +1 -1
- package/src/openapi/mcp-parity.fixture.yaml +73 -0
- package/src/openapi/mcp-tools.snapshot.json +81 -2
- package/src/openapi/spec.test.ts +221 -0
- package/src/openapi/spec.ts +204 -7
- package/src/runtime/core/modules/mcp.test.ts +153 -0
- package/src/runtime/core/modules/mcp.ts +90 -21
|
@@ -327,6 +327,159 @@ test("a projected tool's input schema matches the spec's params", async () => {
|
|
|
327
327
|
});
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
+
test("a $ref-bodied tool projects a self-contained body schema (no $ref, explicit type)", async () => {
|
|
331
|
+
// createInvoice's requestBody is `{ $ref: "#/components/schemas/Invoice" }` — a bare $ref with no
|
|
332
|
+
// inline `type`. An MCP client cannot resolve `#/components/...` outside the document, so the
|
|
333
|
+
// projected tool schema MUST inline the concrete Invoice shape (ADR 0067). Regression guard for the
|
|
334
|
+
// leak that shipped an opaque `#/components/...` pointer into `inputSchema.properties.body`.
|
|
335
|
+
const { router } = buildHarness();
|
|
336
|
+
const session = await connect(router);
|
|
337
|
+
const list = await rpc(router, session, "tools/list", {});
|
|
338
|
+
const tools = list.result?.tools;
|
|
339
|
+
assert.ok(Array.isArray(tools));
|
|
340
|
+
const createInvoice = tools.find((t) => Reflect.get(t, "name") === "createInvoice");
|
|
341
|
+
assert.ok(createInvoice, "createInvoice must be present in tools/list before checking its schema");
|
|
342
|
+
const schema = Reflect.get(createInvoice, "inputSchema");
|
|
343
|
+
assert.deepEqual(schema, {
|
|
344
|
+
type: "object",
|
|
345
|
+
properties: {
|
|
346
|
+
body: {
|
|
347
|
+
type: "object",
|
|
348
|
+
properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
|
|
349
|
+
required: ["id", "amount"],
|
|
350
|
+
additionalProperties: false,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
required: ["body"],
|
|
354
|
+
});
|
|
355
|
+
// Self-containment holds structurally: the serialized schema carries no `$ref` token at all.
|
|
356
|
+
assert.ok(!JSON.stringify(schema).includes("$ref"), "projected body schema must not leak a $ref");
|
|
357
|
+
});
|
|
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
|
+
|
|
330
483
|
test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
|
|
331
484
|
let seenParams: Record<string, unknown> | undefined;
|
|
332
485
|
const { router, imported } = buildHarness({
|
|
@@ -50,11 +50,16 @@ 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,
|
|
62
|
+
toolInputSchema,
|
|
58
63
|
} from "../../../openapi/spec.ts";
|
|
59
64
|
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
60
65
|
import { errorMessage } from "../guards.ts";
|
|
@@ -250,26 +255,10 @@ function isMutatingOperation(op: OperationInfo): boolean {
|
|
|
250
255
|
return isMutatingMethod(op.method);
|
|
251
256
|
}
|
|
252
257
|
|
|
253
|
-
/** A JSON Schema (OpenAPI object subset) describing a tool's input
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
|
|
257
|
-
const properties: Record<string, unknown> = {};
|
|
258
|
-
const required: string[] = [];
|
|
259
|
-
for (const p of op.parameters) {
|
|
260
|
-
if (p.in === "path" || p.in === "query") {
|
|
261
|
-
properties[p.name] = p.schema ?? {};
|
|
262
|
-
if (p.required) required.push(p.name);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (op.requestBodySchema) {
|
|
266
|
-
properties.body = op.requestBodySchema;
|
|
267
|
-
if (op.requestBodyRequired) required.push("body");
|
|
268
|
-
}
|
|
269
|
-
const schema: Record<string, unknown> = { type: "object", properties };
|
|
270
|
-
if (required.length > 0) schema.required = required;
|
|
271
|
-
return schema;
|
|
272
|
-
}
|
|
258
|
+
/** A JSON Schema (OpenAPI object subset) describing a tool's input is derived once, in
|
|
259
|
+
* `openapi/spec.ts`'s {@link toolInputSchema}, from an operation's resolved (`$ref`-free) parameter
|
|
260
|
+
* and request-body metadata — so the tool input schema the runtime advertises and the schema the CI
|
|
261
|
+
* parity guard pins are the SAME derivation (one source of truth, ADR 0053/0067). */
|
|
273
262
|
|
|
274
263
|
/** Substitute an operation's `{param}` path-template segments with the supplied arguments, building
|
|
275
264
|
* the request path under the shared `/app/api` base so dispatch hits the exact `mountApi` route. */
|
|
@@ -351,6 +340,10 @@ function toHttpRequest(
|
|
|
351
340
|
args: Record<string, unknown>,
|
|
352
341
|
forwardHeaders: Record<string, string | string[] | undefined>,
|
|
353
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).
|
|
354
347
|
const bodyText = op.requestBodySchema !== undefined && args.body !== undefined
|
|
355
348
|
? JSON.stringify(args.body)
|
|
356
349
|
: "";
|
|
@@ -369,6 +362,76 @@ function toHttpRequest(
|
|
|
369
362
|
};
|
|
370
363
|
}
|
|
371
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
|
+
|
|
372
435
|
/** Wrap a value as an MCP tool text result. A JSON payload is stringified; `isError` flags a
|
|
373
436
|
* non-2xx dispatch so the client sees the failure (carrying the delegate's own error body). */
|
|
374
437
|
function textResult(value: unknown, isError = false): {
|
|
@@ -853,10 +916,16 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
|
|
|
853
916
|
true,
|
|
854
917
|
);
|
|
855
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);
|
|
856
925
|
// Forward the MCP connection's headers so the reused router enforces the operation's own
|
|
857
926
|
// OpenAPI `security` (a mutating — or secured read — app op is guarded exactly as its HTTP
|
|
858
927
|
// route). A `401`/`403`/`500` from that enforcement flows back as an error tool result.
|
|
859
|
-
const res = await apiRouter(toHttpRequest(op, args, headers));
|
|
928
|
+
const res = await apiRouter(toHttpRequest(op, normalized.args, headers));
|
|
860
929
|
const status = res.status ?? 200;
|
|
861
930
|
return textResult(res.body ?? "", status >= 400);
|
|
862
931
|
};
|