@nanobpm/urban 0.86.1 → 0.87.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.
@@ -53,6 +53,11 @@ export interface OpenApiParameter {
53
53
  in: "path" | "query" | "header" | "cookie";
54
54
  required?: boolean;
55
55
  schema?: OpenApiSchema;
56
+ /** The parameter's schema with every local component `$ref` inlined (via {@link resolveSchemaDeep}),
57
+ * so a consumer that cannot resolve `#/components/...` — e.g. an MCP client reading the projected
58
+ * tool `inputSchema` — sees a self-contained schema. Absent when the parameter declares no schema.
59
+ * The raw {@link schema} is retained for the type deriver, which maps a `$ref` to its named type. */
60
+ resolvedSchema?: OpenApiSchema;
56
61
  }
57
62
  export interface OpenApiMediaType {
58
63
  schema?: OpenApiSchema;
@@ -119,6 +124,16 @@ export interface OperationInfo {
119
124
  path: string;
120
125
  parameters: OpenApiParameter[];
121
126
  requestBodySchema?: OpenApiSchema;
127
+ /** The request-body schema with every local component `$ref` deeply inlined (via
128
+ * {@link resolveSchemaDeep}), so it is self-contained: no `$ref`, and an explicit `type` where the
129
+ * referenced component declared one. This is what the MCP tool projection copies into a tool's
130
+ * `inputSchema.properties.body` — an MCP client cannot resolve `#/components/...` outside the
131
+ * original document, so the projected schema must carry the concrete shape. The raw
132
+ * {@link requestBodySchema} is retained alongside it for the type deriver (which maps a `$ref` to
133
+ * its named TypeScript type) and for `sharedRequestBodySchemas` (which fingerprints shared refs).
134
+ * Absent exactly when {@link requestBodySchema} is absent. Downstream slices (ADR 0067) read the
135
+ * resolved body `type` from here rather than re-resolving a possibly-`$ref` raw schema. */
136
+ requestBodySchemaResolved?: OpenApiSchema;
122
137
  requestBodyRequired: boolean;
123
138
  /** Every documented JSON response, keyed by status code ("200","400",…,"default"), in a stable
124
139
  * order. The type layer unions these into the operation's response type; the runtime validates a
@@ -144,6 +159,20 @@ export declare function parseSpec(text: string): OpenApiDoc;
144
159
  export declare function refName(ref: string): string | undefined;
145
160
  /** Follow local component `$ref`s to the concrete schema (guarding cycles). Non-local refs → undefined. */
146
161
  export declare function resolveSchema(doc: OpenApiDoc, schema: OpenApiSchema | undefined): OpenApiSchema | undefined;
162
+ /** Deeply inline every local component `$ref` in a schema, returning a self-contained schema with no
163
+ * `$ref` anywhere in its tree (top-level chain plus nested `properties`/`items`/
164
+ * `additionalProperties`/`allOf`/`oneOf`/`anyOf`). This is the resolver the MCP tool projection uses
165
+ * so a tool's `inputSchema` carries the concrete shape rather than an opaque `#/components/...`
166
+ * pointer an MCP client cannot follow (ADR 0067). It reuses the same local-ref semantics as
167
+ * {@link resolveSchema}, extended to recurse into subschemas.
168
+ *
169
+ * Cycles and unresolvable refs FAIL LOUDLY rather than leak a `$ref`:
170
+ * - a `$ref` that reappears on its own resolution path (an irreducible cycle — a schema that
171
+ * cannot be finitely inlined) throws;
172
+ * - a `$ref` that is non-local or dangles (no matching `#/components/schemas/*`) throws.
173
+ * `seenRefs` tracks refs on the CURRENT path only, so a component referenced twice in sibling
174
+ * branches (a diamond) inlines fine — only genuine self-reference is rejected. */
175
+ export declare function resolveSchemaDeep(doc: OpenApiDoc, schema: OpenApiSchema | undefined, seenRefs?: ReadonlySet<string>): OpenApiSchema | undefined;
147
176
  /** Walk the document's paths and return every operation that carries an operationId, in a stable
148
177
  * (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
149
178
  * them at `warn` and skips them too — this keeps the pure collector total. */
@@ -164,15 +193,44 @@ export declare function isMcpExcluded(ext: unknown): boolean;
164
193
  /** Whether an operation's HTTP method mutates state — anything other than a safe read verb. Drives
165
194
  * the MCP read/mutate split: read tools are unguarded on loopback, mutating tools are gated. */
166
195
  export declare function isMutatingMethod(method: HttpMethodLower): boolean;
167
- /** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, and whether
168
- * `x-mcp` excludes it from projection. This is the shared unit the runtime MCP surface AND the CI
169
- * spec↔tool parity guard both read — a single derivation of "which operations become tools", never
170
- * a second walker (ADR 0053). */
196
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
197
+ * path/query parameters and its request-body schema. It is the SINGLE derivation both the runtime
198
+ * MCP surface (`tools/list`) and the CI parity guard read, so a tool's advertised input schema and
199
+ * the HTTP validation it dispatches to can never drift (ADR 0053/0067).
200
+ *
201
+ * Every property schema is taken from the operation's resolved (`$ref`-free) metadata — the
202
+ * parameters' `resolvedSchema` and the body's `requestBodySchemaResolved`, both produced by
203
+ * {@link resolveSchemaDeep} in {@link collectOperations} — so the emitted schema is self-contained:
204
+ * an MCP client, which cannot follow a `#/components/...` pointer outside the original document,
205
+ * sees the concrete shape (and an explicit `type` on the body). */
206
+ export declare function toolInputSchema(op: OperationInfo): Record<string, unknown>;
207
+ /** Walk a JSON value (a projected tool schema) and return a pointer for every `$ref` still present.
208
+ * Empty means the schema is self-contained — no `#/components/...` an MCP client cannot resolve. The
209
+ * path is a dotted trail to each leak (e.g. `properties.body.properties.foo`). Pure/total. */
210
+ export declare function findSchemaRefLeaks(value: unknown, path?: string): string[];
211
+ /** Self-containment violations in one projected tool's `inputSchema`: any surviving `$ref` (an
212
+ * MCP client cannot resolve `#/components/...`), and — when the tool has a `body` property — a
213
+ * `body` that carries no explicit `type` (so the client cannot tell it is an object/array and
214
+ * mis-encodes the argument). Empty means the schema is a usable, self-contained input contract.
215
+ * This is the pure assertion the `check:mcp` guard runs over every projected tool (ADR 0067). */
216
+ export declare function findToolSchemaViolations(operationId: string, inputSchema: Record<string, unknown>): string[];
217
+ /** Every self-containment violation across all projected (non-excluded) tools for a document — the
218
+ * aggregate the `check:mcp` guard fails on. Excluded operations are skipped: they are withheld from
219
+ * the live tool surface, so their schema is never advertised to a client. */
220
+ export declare function findAllToolSchemaViolations(doc: OpenApiDoc): string[];
221
+ /** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, whether
222
+ * `x-mcp` excludes it from projection, and its self-contained input schema. This is the shared unit
223
+ * the runtime MCP surface AND the CI spec↔tool parity guard both read — a single derivation of
224
+ * "which operations become tools and what each accepts", never a second walker (ADR 0053). */
171
225
  export interface McpToolProjection {
172
226
  operationId: string;
173
227
  method: HttpMethodLower;
174
228
  mutating: boolean;
175
229
  excluded: boolean;
230
+ /** The tool's `inputSchema` as advertised to an MCP client (absent for an excluded op, which is
231
+ * never advertised). Captured in the snapshot so a projection change — including a body `$ref`
232
+ * that stops being resolved — is caught by `check:mcp` drift, not discovered by a client. */
233
+ inputSchema?: Record<string, unknown>;
176
234
  }
177
235
  /**
178
236
  * Project every operationId-bearing operation to its MCP-tool facts, in the stable
@@ -180,7 +238,7 @@ export interface McpToolProjection {
180
238
  * `excluded: true`) rather than dropped, so the parity guard can positively assert that an
181
239
  * `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
182
240
  * tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
183
- * `mountApi` routes.
241
+ * `mountApi` routes. A non-excluded op also carries its self-contained {@link toolInputSchema}.
184
242
  */
185
243
  export declare function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[];
186
244
  /**
@@ -115,6 +115,54 @@ export function resolveSchema(doc, schema) {
115
115
  }
116
116
  return current;
117
117
  }
118
+ /** Deeply inline every local component `$ref` in a schema, returning a self-contained schema with no
119
+ * `$ref` anywhere in its tree (top-level chain plus nested `properties`/`items`/
120
+ * `additionalProperties`/`allOf`/`oneOf`/`anyOf`). This is the resolver the MCP tool projection uses
121
+ * so a tool's `inputSchema` carries the concrete shape rather than an opaque `#/components/...`
122
+ * pointer an MCP client cannot follow (ADR 0067). It reuses the same local-ref semantics as
123
+ * {@link resolveSchema}, extended to recurse into subschemas.
124
+ *
125
+ * Cycles and unresolvable refs FAIL LOUDLY rather than leak a `$ref`:
126
+ * - a `$ref` that reappears on its own resolution path (an irreducible cycle — a schema that
127
+ * cannot be finitely inlined) throws;
128
+ * - a `$ref` that is non-local or dangles (no matching `#/components/schemas/*`) throws.
129
+ * `seenRefs` tracks refs on the CURRENT path only, so a component referenced twice in sibling
130
+ * branches (a diamond) inlines fine — only genuine self-reference is rejected. */
131
+ export function resolveSchemaDeep(doc, schema, seenRefs = new Set()) {
132
+ if (!schema)
133
+ return undefined;
134
+ let current = schema;
135
+ let seen = seenRefs;
136
+ while (current.$ref) {
137
+ const ref = current.$ref;
138
+ if (seen.has(ref)) {
139
+ throw new Error(`OpenAPI schema $ref cycle: ${[...seen, ref].join(" -> ")} cannot be inlined into a self-contained schema`);
140
+ }
141
+ const name = refName(ref);
142
+ const next = name ? doc.components?.schemas?.[name] : undefined;
143
+ if (!next) {
144
+ throw new Error(`OpenAPI schema $ref "${ref}" does not resolve to a local component — a self-contained schema cannot contain an unresolvable $ref`);
145
+ }
146
+ seen = new Set(seen).add(ref);
147
+ current = next;
148
+ }
149
+ const out = { ...current };
150
+ delete out.$ref;
151
+ if (current.properties) {
152
+ out.properties = Object.fromEntries(Object.entries(current.properties).map(([k, v]) => [k, resolveSchemaDeep(doc, v, seen) ?? {}]));
153
+ }
154
+ if (current.items)
155
+ out.items = resolveSchemaDeep(doc, current.items, seen);
156
+ if (current.additionalProperties && typeof current.additionalProperties === "object") {
157
+ out.additionalProperties = resolveSchemaDeep(doc, current.additionalProperties, seen);
158
+ }
159
+ for (const key of ["allOf", "oneOf", "anyOf"]) {
160
+ const arr = current[key];
161
+ if (arr)
162
+ out[key] = arr.map((s) => resolveSchemaDeep(doc, s, seen) ?? {});
163
+ }
164
+ return out;
165
+ }
118
166
  function firstJsonSchema(content) {
119
167
  if (!content)
120
168
  return undefined;
@@ -151,10 +199,29 @@ export function collectOperations(doc) {
151
199
  const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
152
200
  if (!operationId || !isSafeOperationId(operationId))
153
201
  continue;
202
+ // Resolve exclusion BEFORE any deep `$ref` inlining. An `x-mcp`-excluded operation is never
203
+ // advertised as a tool, so its schema is never projected or self-containment-guarded; doing
204
+ // its (throwing) deep resolution eagerly would let a dangling/cyclic `$ref` on an EXCLUDED
205
+ // door crash projection/guards for the whole document — even though that door is withheld.
206
+ // Excluded ops therefore carry no `resolvedSchema`/`requestBodySchemaResolved` (unused).
207
+ const mcpExcluded = isMcpExcluded(opRaw["x-mcp"]);
154
208
  const opParams = Array.isArray(opRaw.parameters) ? opRaw.parameters : [];
155
- const parameters = normalizeParameters([...pathParams, ...opParams]);
209
+ const parameters = normalizeParameters([...pathParams, ...opParams]).map((p) => ({
210
+ ...p,
211
+ // Inline any local component `$ref` so the projected MCP tool schema is self-contained. The
212
+ // raw `p.schema` is kept for the type deriver; the tool projection reads `resolvedSchema`.
213
+ resolvedSchema: mcpExcluded ? undefined : resolveSchemaDeep(doc, p.schema),
214
+ }));
156
215
  const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
157
216
  const requestBodySchema = firstJsonSchema(isRecord(requestBody?.content) ? requestBody.content : undefined);
217
+ // Deeply inline component `$ref`s so downstream consumers (the MCP tool projection, ADR 0067)
218
+ // get a self-contained, explicitly-typed body schema — never an opaque `#/components/...`
219
+ // pointer. Raw `requestBodySchema` is retained (deriver + shared-ref fingerprinting). A cyclic
220
+ // or unresolvable body ref fails loudly here rather than leaking a `$ref` — but only for a
221
+ // projected (non-excluded) op; an excluded op skips this throwing work (see above).
222
+ const requestBodySchemaResolved = mcpExcluded
223
+ ? undefined
224
+ : resolveSchemaDeep(doc, requestBodySchema);
158
225
  const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
159
226
  // Effective security: an operation's own `security` (even an explicit `[]`, which opts out of
160
227
  // a document-level default) wins; otherwise inherit the document-level default; otherwise open.
@@ -168,11 +235,12 @@ export function collectOperations(doc) {
168
235
  path,
169
236
  parameters,
170
237
  requestBodySchema,
238
+ requestBodySchemaResolved,
171
239
  requestBodyRequired: requestBody?.required === true,
172
240
  responseSchemas: collectResponseSchemas(responses),
173
241
  security,
174
242
  eject: opRaw["x-urban-eject"] === true,
175
- mcpExcluded: isMcpExcluded(opRaw["x-mcp"]),
243
+ mcpExcluded,
176
244
  summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
177
245
  });
178
246
  }
@@ -206,13 +274,107 @@ const READ_ONLY_METHODS = ["get", "head", "options"];
206
274
  export function isMutatingMethod(method) {
207
275
  return !READ_ONLY_METHODS.includes(method);
208
276
  }
277
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
278
+ * path/query parameters and its request-body schema. It is the SINGLE derivation both the runtime
279
+ * MCP surface (`tools/list`) and the CI parity guard read, so a tool's advertised input schema and
280
+ * the HTTP validation it dispatches to can never drift (ADR 0053/0067).
281
+ *
282
+ * Every property schema is taken from the operation's resolved (`$ref`-free) metadata — the
283
+ * parameters' `resolvedSchema` and the body's `requestBodySchemaResolved`, both produced by
284
+ * {@link resolveSchemaDeep} in {@link collectOperations} — so the emitted schema is self-contained:
285
+ * an MCP client, which cannot follow a `#/components/...` pointer outside the original document,
286
+ * sees the concrete shape (and an explicit `type` on the body). */
287
+ export function toolInputSchema(op) {
288
+ const properties = {};
289
+ const required = [];
290
+ for (const p of op.parameters) {
291
+ if (p.in === "path" || p.in === "query") {
292
+ properties[p.name] = p.resolvedSchema ?? p.schema ?? {};
293
+ if (p.required)
294
+ required.push(p.name);
295
+ }
296
+ }
297
+ const body = op.requestBodySchemaResolved;
298
+ if (body) {
299
+ properties.body = body;
300
+ if (op.requestBodyRequired)
301
+ required.push("body");
302
+ }
303
+ const schema = { type: "object", properties };
304
+ if (required.length > 0)
305
+ schema.required = required;
306
+ return schema;
307
+ }
308
+ /** Walk a JSON value (a projected tool schema) and return a pointer for every `$ref` still present.
309
+ * Empty means the schema is self-contained — no `#/components/...` an MCP client cannot resolve. The
310
+ * path is a dotted trail to each leak (e.g. `properties.body.properties.foo`). Pure/total. */
311
+ export function findSchemaRefLeaks(value, path = "") {
312
+ const leaks = [];
313
+ const visit = (v, p) => {
314
+ if (Array.isArray(v)) {
315
+ v.forEach((item, i) => visit(item, `${p}[${i}]`));
316
+ return;
317
+ }
318
+ if (v && typeof v === "object") {
319
+ for (const [k, child] of Object.entries(v)) {
320
+ // A JSON-Schema `$ref` is a *string* pointer; only that form is an unresolvable leak. A
321
+ // property literally named `$ref` with a non-string value is an ordinary schema key, so
322
+ // recurse into it (real leaks can still hide nested beneath such a property).
323
+ if (k === "$ref" && typeof child === "string")
324
+ leaks.push(p || "<root>");
325
+ else
326
+ visit(child, p ? `${p}.${k}` : k);
327
+ }
328
+ }
329
+ };
330
+ visit(value, path);
331
+ return leaks;
332
+ }
333
+ /** Whether a (record-typed) schema declares an explicit JSON-Schema `type` — a non-empty string or a
334
+ * non-empty array of types. Operates on a raw record (not a typed {@link OpenApiSchema}) so it can
335
+ * vet a projected tool schema's `body` property without an unchecked cast. */
336
+ function hasExplicitType(schema) {
337
+ const t = schema.type;
338
+ if (typeof t === "string")
339
+ return t.length > 0;
340
+ return Array.isArray(t) && t.length > 0;
341
+ }
342
+ /** Self-containment violations in one projected tool's `inputSchema`: any surviving `$ref` (an
343
+ * MCP client cannot resolve `#/components/...`), and — when the tool has a `body` property — a
344
+ * `body` that carries no explicit `type` (so the client cannot tell it is an object/array and
345
+ * mis-encodes the argument). Empty means the schema is a usable, self-contained input contract.
346
+ * This is the pure assertion the `check:mcp` guard runs over every projected tool (ADR 0067). */
347
+ export function findToolSchemaViolations(operationId, inputSchema) {
348
+ const violations = [];
349
+ for (const leak of findSchemaRefLeaks(inputSchema)) {
350
+ violations.push(`${operationId}: inputSchema leaks an unresolved $ref at ${leak}`);
351
+ }
352
+ const properties = isRecord(inputSchema.properties) ? inputSchema.properties : undefined;
353
+ const body = properties && isRecord(properties.body) ? properties.body : undefined;
354
+ if (body && !hasExplicitType(body)) {
355
+ violations.push(`${operationId}: inputSchema.properties.body has no explicit \`type\``);
356
+ }
357
+ return violations;
358
+ }
359
+ /** Every self-containment violation across all projected (non-excluded) tools for a document — the
360
+ * aggregate the `check:mcp` guard fails on. Excluded operations are skipped: they are withheld from
361
+ * the live tool surface, so their schema is never advertised to a client. */
362
+ export function findAllToolSchemaViolations(doc) {
363
+ const violations = [];
364
+ for (const op of collectOperations(doc)) {
365
+ if (op.mcpExcluded)
366
+ continue;
367
+ violations.push(...findToolSchemaViolations(op.operationId, toolInputSchema(op)));
368
+ }
369
+ return violations;
370
+ }
209
371
  /**
210
372
  * Project every operationId-bearing operation to its MCP-tool facts, in the stable
211
373
  * `collectOperations` (path, method) order. Excluded operations are RETAINED here (flagged
212
374
  * `excluded: true`) rather than dropped, so the parity guard can positively assert that an
213
375
  * `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
214
376
  * tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
215
- * `mountApi` routes.
377
+ * `mountApi` routes. A non-excluded op also carries its self-contained {@link toolInputSchema}.
216
378
  */
217
379
  export function collectMcpToolProjection(doc) {
218
380
  return collectOperations(doc).map((op) => ({
@@ -220,6 +382,7 @@ export function collectMcpToolProjection(doc) {
220
382
  method: op.method,
221
383
  mutating: isMutatingMethod(op.method),
222
384
  excluded: op.mcpExcluded,
385
+ ...(op.mcpExcluded ? {} : { inputSchema: toolInputSchema(op) }),
223
386
  }));
224
387
  }
225
388
  /**
@@ -247,6 +410,12 @@ export function diffMcpToolProjection(snapshot, current) {
247
410
  `excluded:${snap.excluded}} vs spec {method:${cur.method}, mutating:${cur.mutating}, ` +
248
411
  `excluded:${cur.excluded}}`);
249
412
  }
413
+ if (JSON.stringify(snap.inputSchema ?? null) !== JSON.stringify(cur.inputSchema ?? null)) {
414
+ // Emit a concise, constant-size line rather than inlining both full `inputSchema` payloads:
415
+ // for a non-trivial schema that would explode CI logs and bury the signal. The committed
416
+ // snapshot's own diff carries the exact field-level detail; this line just names the drift.
417
+ drift.push(`~ ${id}: inputSchema changed (regenerate the snapshot to see the field-level diff)`);
418
+ }
250
419
  }
251
420
  for (const id of snapshotById.keys()) {
252
421
  if (!currentById.has(id)) {
@@ -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, } from "../../../openapi/spec.js";
37
+ import { collectOperations, evaluateSecurity, isMutatingMethod, parseSpec, 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";
@@ -173,29 +173,10 @@ export function readHeader(headers, name) {
173
173
  function isMutatingOperation(op) {
174
174
  return isMutatingMethod(op.method);
175
175
  }
176
- /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
177
- * path/query parameters and its request-body schema. Keeping the property schemas verbatim from the
178
- * spec means the tool input schema and the HTTP validation share one source of truth. */
179
- function toolInputSchema(op) {
180
- const properties = {};
181
- const required = [];
182
- for (const p of op.parameters) {
183
- if (p.in === "path" || p.in === "query") {
184
- properties[p.name] = p.schema ?? {};
185
- if (p.required)
186
- required.push(p.name);
187
- }
188
- }
189
- if (op.requestBodySchema) {
190
- properties.body = op.requestBodySchema;
191
- if (op.requestBodyRequired)
192
- required.push("body");
193
- }
194
- const schema = { type: "object", properties };
195
- if (required.length > 0)
196
- schema.required = required;
197
- return schema;
198
- }
176
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input is derived once, in
177
+ * `openapi/spec.ts`'s {@link toolInputSchema}, from an operation's resolved (`$ref`-free) parameter
178
+ * and request-body metadata — so the tool input schema the runtime advertises and the schema the CI
179
+ * parity guard pins are the SAME derivation (one source of truth, ADR 0053/0067). */
199
180
  /** Substitute an operation's `{param}` path-template segments with the supplied arguments, building
200
181
  * the request path under the shared `/app/api` base so dispatch hits the exact `mountApi` route. */
201
182
  function fillPath(op, args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.86.1",
3
+ "version": "0.87.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",
@@ -10,6 +10,15 @@
10
10
  # - an `x-mcp`-EXCLUDED operation → present in the doc but withheld from the
11
11
  # agent-facing tool surface (the security-
12
12
  # relevant opt-out, `x-mcp: false`)
13
+ # - a `$ref`-BODIED operation (POST) → its request body is a bare
14
+ # `{ $ref: #/components/schemas/... }` with
15
+ # no inline `type`, whose referenced
16
+ # component itself nests another `$ref`.
17
+ # The projection must inline both so the
18
+ # tool's `inputSchema.properties.body` is
19
+ # self-contained (no `$ref`, explicit
20
+ # `type`) — an MCP client cannot resolve
21
+ # `#/components/...` outside this document.
13
22
  #
14
23
  # Edit this fixture ONLY together with a reviewed `npm run gen:mcp-tools`.
15
24
  openapi: 3.1.0
@@ -23,6 +32,30 @@ components:
23
32
  in: header
24
33
  name: X-Nano-Key
25
34
  x-nano-secret-env: NANO_WEBHOOK_KEY
35
+ schemas:
36
+ # A request-body schema declared as a bare `$ref` with NO inline `type` — the exact shape that
37
+ # leaked an unresolved `#/components/...` pointer into a tool's inputSchema before it was resolved
38
+ # on the projection path (the motivating real op `previewDeliveryGraph`). It also NESTS another
39
+ # `$ref` (`root`) so deep resolution is exercised, not just a single top-level hop.
40
+ DeliveryGraphSubmit:
41
+ type: object
42
+ required:
43
+ - root
44
+ properties:
45
+ root:
46
+ $ref: "#/components/schemas/DeliveryNode"
47
+ note:
48
+ type: string
49
+ DeliveryNode:
50
+ type: object
51
+ required:
52
+ - id
53
+ properties:
54
+ id:
55
+ type: string
56
+ weight:
57
+ type: integer
58
+ minimum: 0
26
59
  paths:
27
60
  /widgets:
28
61
  get:
@@ -47,3 +80,16 @@ paths:
47
80
  responses:
48
81
  "202":
49
82
  description: accepted
83
+ /delivery-graph/preview:
84
+ post:
85
+ operationId: previewDeliveryGraph
86
+ summary: Preview a delivery graph (mutating — `$ref`-bodied, body must resolve to a self-contained schema).
87
+ requestBody:
88
+ required: true
89
+ content:
90
+ application/json:
91
+ schema:
92
+ $ref: "#/components/schemas/DeliveryGraphSubmit"
93
+ responses:
94
+ "200":
95
+ description: ok
@@ -1,4 +1,44 @@
1
1
  [
2
+ {
3
+ "operationId": "previewDeliveryGraph",
4
+ "method": "post",
5
+ "mutating": true,
6
+ "excluded": false,
7
+ "inputSchema": {
8
+ "type": "object",
9
+ "properties": {
10
+ "body": {
11
+ "type": "object",
12
+ "required": [
13
+ "root"
14
+ ],
15
+ "properties": {
16
+ "root": {
17
+ "type": "object",
18
+ "required": [
19
+ "id"
20
+ ],
21
+ "properties": {
22
+ "id": {
23
+ "type": "string"
24
+ },
25
+ "weight": {
26
+ "type": "integer",
27
+ "minimum": 0
28
+ }
29
+ }
30
+ },
31
+ "note": {
32
+ "type": "string"
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "required": [
38
+ "body"
39
+ ]
40
+ }
41
+ },
2
42
  {
3
43
  "operationId": "reindexWidgets",
4
44
  "method": "post",
@@ -9,12 +49,20 @@
9
49
  "operationId": "listWidgets",
10
50
  "method": "get",
11
51
  "mutating": false,
12
- "excluded": false
52
+ "excluded": false,
53
+ "inputSchema": {
54
+ "type": "object",
55
+ "properties": {}
56
+ }
13
57
  },
14
58
  {
15
59
  "operationId": "createWidget",
16
60
  "method": "post",
17
61
  "mutating": true,
18
- "excluded": false
62
+ "excluded": false,
63
+ "inputSchema": {
64
+ "type": "object",
65
+ "properties": {}
66
+ }
19
67
  }
20
68
  ]
@@ -2,7 +2,12 @@ import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import {
4
4
  collectOperations,
5
+ collectMcpToolProjection,
6
+ diffMcpToolProjection,
5
7
  evaluateSecurity,
8
+ findAllToolSchemaViolations,
9
+ findSchemaRefLeaks,
10
+ findToolSchemaViolations,
6
11
  isObjectSchema,
7
12
  isSafeOperationId,
8
13
  isMutatingMethod,
@@ -12,10 +17,12 @@ import {
12
17
  operationsWithUnsafeId,
13
18
  parseSpec,
14
19
  resolveSchema,
20
+ resolveSchemaDeep,
15
21
  responseSchemaForStatus,
16
22
  sharedRequestBodySchemas,
17
23
  sharedSecretSchemeName,
18
24
  sharedSecretSchemeNames,
25
+ toolInputSchema,
19
26
  toRouteMatcher,
20
27
  undeclaredPathParams,
21
28
  undeclaredSecuritySchemes,
@@ -179,6 +186,220 @@ test("resolveSchema follows a local $ref and guards cycles", () => {
179
186
  assert.equal(resolveSchema(cyclic, { $ref: "#/components/schemas/A" }), undefined);
180
187
  });
181
188
 
189
+ test("resolveSchemaDeep inlines nested $refs into a self-contained, $ref-free schema", () => {
190
+ const nestedDoc: OpenApiDoc = {
191
+ openapi: "3.1.0",
192
+ components: {
193
+ schemas: {
194
+ Submit: {
195
+ type: "object",
196
+ required: ["root"],
197
+ properties: { root: { $ref: "#/components/schemas/Node" }, note: { type: "string" } },
198
+ },
199
+ Node: {
200
+ type: "object",
201
+ required: ["id"],
202
+ properties: { id: { type: "string" }, meta: { $ref: "#/components/schemas/Meta" } },
203
+ },
204
+ Meta: { type: "object", properties: { label: { type: "string" } } },
205
+ },
206
+ },
207
+ paths: {},
208
+ };
209
+ const resolved = resolveSchemaDeep(nestedDoc, { $ref: "#/components/schemas/Submit" });
210
+ // A bare `$ref` body with no inline `type` resolves to an explicit `type: object`, and the nested
211
+ // `Node` ref inside `root` (and the deeper `Meta` ref inside `Node`) are inlined too — no `$ref`
212
+ // survives at any depth.
213
+ assert.equal(resolved?.type, "object");
214
+ assert.ok(!JSON.stringify(resolved).includes("$ref"));
215
+ const root = resolved?.properties?.root;
216
+ assert.equal(root?.type, "object");
217
+ assert.equal(root?.properties?.id?.type, "string");
218
+ assert.equal(root?.properties?.meta?.type, "object");
219
+ assert.equal(root?.properties?.meta?.properties?.label?.type, "string");
220
+ });
221
+
222
+ test("resolveSchemaDeep inlines a diamond (same component in sibling branches) without a false cycle", () => {
223
+ const diamond: OpenApiDoc = {
224
+ openapi: "3.1.0",
225
+ components: {
226
+ schemas: {
227
+ Pair: {
228
+ type: "object",
229
+ properties: { left: { $ref: "#/components/schemas/Leaf" }, right: { $ref: "#/components/schemas/Leaf" } },
230
+ },
231
+ Leaf: { type: "object", properties: { v: { type: "integer" } } },
232
+ },
233
+ },
234
+ paths: {},
235
+ };
236
+ const resolved = resolveSchemaDeep(diamond, { $ref: "#/components/schemas/Pair" });
237
+ assert.equal(resolved?.properties?.left?.properties?.v?.type, "integer");
238
+ assert.equal(resolved?.properties?.right?.properties?.v?.type, "integer");
239
+ });
240
+
241
+ test("resolveSchemaDeep fails loudly on an irreducible cycle rather than leaking a $ref", () => {
242
+ const cyclic: OpenApiDoc = {
243
+ openapi: "3.1.0",
244
+ components: { schemas: { A: { type: "object", properties: { self: { $ref: "#/components/schemas/A" } } } } },
245
+ paths: {},
246
+ };
247
+ assert.throws(
248
+ () => resolveSchemaDeep(cyclic, { $ref: "#/components/schemas/A" }),
249
+ /\$ref cycle/,
250
+ );
251
+ });
252
+
253
+ test("resolveSchemaDeep fails loudly on a dangling/non-local $ref rather than leaking it", () => {
254
+ assert.throws(
255
+ () => resolveSchemaDeep(doc, { $ref: "#/components/schemas/Missing" }),
256
+ /does not resolve to a local component/,
257
+ );
258
+ assert.throws(
259
+ () => resolveSchemaDeep(doc, { $ref: "https://example.com/external.json#/X" }),
260
+ /does not resolve to a local component/,
261
+ );
262
+ });
263
+
264
+ test("collectOperations exposes the resolved request-body schema as operation metadata", () => {
265
+ // Downstream consumers (ADR 0067 P1) read the resolved body type from `requestBodySchemaResolved`;
266
+ // the raw `requestBodySchema` keeps its `$ref` for the type deriver + shared-ref fingerprinting.
267
+ const create = collectOperations(doc).find((o) => o.operationId === "createInvoice");
268
+ assert.equal(create?.requestBodySchema?.$ref, "#/components/schemas/Invoice");
269
+ assert.equal(create?.requestBodySchemaResolved?.type, "object");
270
+ assert.ok(!JSON.stringify(create?.requestBodySchemaResolved).includes("$ref"));
271
+ });
272
+
273
+ test("toolInputSchema resolves a $ref body to a self-contained typed schema", () => {
274
+ const create = collectOperations(doc).find((o) => o.operationId === "createInvoice");
275
+ assert.ok(create);
276
+ const schema = toolInputSchema(create);
277
+ assert.ok(!JSON.stringify(schema).includes("$ref"));
278
+ assert.deepEqual(schema, {
279
+ type: "object",
280
+ properties: {
281
+ body: {
282
+ type: "object",
283
+ properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
284
+ required: ["id", "amount"],
285
+ additionalProperties: false,
286
+ },
287
+ },
288
+ required: ["body"],
289
+ });
290
+ });
291
+
292
+ test("findSchemaRefLeaks reports every surviving $ref path, empty when self-contained", () => {
293
+ assert.deepEqual(findSchemaRefLeaks({ type: "object", properties: { a: { type: "string" } } }), []);
294
+ assert.deepEqual(
295
+ findSchemaRefLeaks({ properties: { body: { $ref: "#/components/schemas/X" } } }),
296
+ ["properties.body"],
297
+ );
298
+ // A property literally named `$ref` whose value is NOT a string is an ordinary schema key, not a
299
+ // JSON-Schema reference — it must not be reported as a leak, and real leaks nested beneath it are
300
+ // still caught.
301
+ assert.deepEqual(
302
+ findSchemaRefLeaks({ type: "object", properties: { $ref: { type: "string" } } }),
303
+ [],
304
+ );
305
+ assert.deepEqual(
306
+ findSchemaRefLeaks({ properties: { $ref: { body: { $ref: "#/components/schemas/X" } } } }),
307
+ ["properties.$ref.body"],
308
+ );
309
+ });
310
+
311
+ test("findToolSchemaViolations flags a leaked $ref and a body missing an explicit type", () => {
312
+ assert.deepEqual(findToolSchemaViolations("ok", { type: "object", properties: { body: { type: "object" } } }), []);
313
+ const leak = findToolSchemaViolations("leaky", { type: "object", properties: { body: { $ref: "#/components/schemas/X" } } });
314
+ assert.equal(leak.length, 2); // both the $ref leak AND the missing body type
315
+ assert.ok(leak.some((m) => /leaks an unresolved \$ref/.test(m)));
316
+ assert.ok(leak.some((m) => /body has no explicit `type`/.test(m)));
317
+ const untyped = findToolSchemaViolations("untyped", { type: "object", properties: { body: { properties: {} } } });
318
+ assert.deepEqual(untyped, ["untyped: inputSchema.properties.body has no explicit `type`"]);
319
+ });
320
+
321
+ test("findAllToolSchemaViolations passes a resolved-body doc and catches a would-be $ref leak", () => {
322
+ // The real doc (a $ref body) is self-contained AFTER projection resolution — no violations.
323
+ assert.deepEqual(findAllToolSchemaViolations(doc), []);
324
+ // Excluded ops are never advertised, so their schema is not asserted.
325
+ const excludedDoc: OpenApiDoc = {
326
+ openapi: "3.1.0",
327
+ components: { schemas: { Invoice: { type: "object", properties: { id: { type: "string" } } } } },
328
+ paths: {
329
+ "/x": {
330
+ post: {
331
+ operationId: "hiddenOp",
332
+ "x-mcp": false,
333
+ requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
334
+ responses: { "200": {} },
335
+ },
336
+ },
337
+ },
338
+ };
339
+ assert.deepEqual(findAllToolSchemaViolations(excludedDoc), []);
340
+ });
341
+
342
+ test("an x-mcp-excluded op with a dangling/cyclic $ref never crashes projection or guards", () => {
343
+ // An EXCLUDED door is withheld from the tool surface, so its schema is never advertised — a
344
+ // broken `$ref` on it must not make the whole document's projection/guards throw. Regression:
345
+ // deep resolution used to run eagerly, before the exclusion check.
346
+ const excludedBrokenDoc: OpenApiDoc = {
347
+ openapi: "3.1.0",
348
+ components: { schemas: { Cyclic: { $ref: "#/components/schemas/Cyclic" } } },
349
+ paths: {
350
+ "/hidden": {
351
+ post: {
352
+ operationId: "hiddenBroken",
353
+ "x-mcp": false,
354
+ parameters: [{ name: "q", in: "query", schema: { $ref: "#/components/schemas/Missing" } }],
355
+ requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/Cyclic" } } } },
356
+ responses: { "200": {} },
357
+ },
358
+ },
359
+ "/shown": {
360
+ post: {
361
+ operationId: "shownOp",
362
+ requestBody: { content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" } } } } } },
363
+ responses: { "200": {} },
364
+ },
365
+ },
366
+ },
367
+ };
368
+ const ops = collectOperations(excludedBrokenDoc);
369
+ const hidden = ops.find((o) => o.operationId === "hiddenBroken");
370
+ assert.equal(hidden?.mcpExcluded, true);
371
+ assert.equal(hidden?.requestBodySchemaResolved, undefined);
372
+ assert.deepEqual(findAllToolSchemaViolations(excludedBrokenDoc), []);
373
+ const projection = collectMcpToolProjection(excludedBrokenDoc);
374
+ assert.equal(projection.find((p) => p.operationId === "hiddenBroken")?.excluded, true);
375
+ assert.equal(projection.find((p) => p.operationId === "shownOp")?.inputSchema?.type, "object");
376
+ });
377
+
378
+ test("diffMcpToolProjection reports an inputSchema skew concisely, not by inlining payloads", () => {
379
+ const projection = collectMcpToolProjection(doc);
380
+ const big = { type: "object", properties: { body: { type: "object", properties: { huge: { type: "string" } } } }, required: ["body"] };
381
+ const stale = projection.map((p) => (p.operationId === "createInvoice" ? { ...p, inputSchema: big } : p));
382
+ const drift = diffMcpToolProjection(stale, projection);
383
+ const line = drift.find((l) => /createInvoice: inputSchema changed/.test(l));
384
+ assert.ok(line, "expected an inputSchema-changed drift line for createInvoice");
385
+ assert.ok(!line?.includes("huge"), "drift line must not inline the full schema payload");
386
+ });
387
+
388
+ test("collectMcpToolProjection carries a self-contained inputSchema; diff catches an inputSchema skew", () => {
389
+ const projection = collectMcpToolProjection(doc);
390
+ const create = projection.find((p) => p.operationId === "createInvoice");
391
+ assert.equal(create?.inputSchema?.type, "object");
392
+ assert.ok(!JSON.stringify(create?.inputSchema).includes("$ref"));
393
+ // A snapshot whose body schema regressed to a $ref (or otherwise differs) is reported as drift.
394
+ const stale = projection.map((p) =>
395
+ p.operationId === "createInvoice"
396
+ ? { ...p, inputSchema: { type: "object", properties: { body: { $ref: "#/components/schemas/Invoice" } }, required: ["body"] } }
397
+ : p,
398
+ );
399
+ const drift = diffMcpToolProjection(stale, projection);
400
+ assert.ok(drift.some((line) => /createInvoice: inputSchema changed/.test(line)));
401
+ });
402
+
182
403
  test("validateValue enforces the JSON-Schema subset via $ref", () => {
183
404
  const schema = { $ref: "#/components/schemas/Invoice" };
184
405
  assert.deepEqual(validateValue(doc, schema, { id: "i1", amount: 5 }, "body"), []);
@@ -106,6 +106,11 @@ export interface OpenApiParameter {
106
106
  in: "path" | "query" | "header" | "cookie";
107
107
  required?: boolean;
108
108
  schema?: OpenApiSchema;
109
+ /** The parameter's schema with every local component `$ref` inlined (via {@link resolveSchemaDeep}),
110
+ * so a consumer that cannot resolve `#/components/...` — e.g. an MCP client reading the projected
111
+ * tool `inputSchema` — sees a self-contained schema. Absent when the parameter declares no schema.
112
+ * The raw {@link schema} is retained for the type deriver, which maps a `$ref` to its named type. */
113
+ resolvedSchema?: OpenApiSchema;
109
114
  }
110
115
 
111
116
  export interface OpenApiMediaType {
@@ -178,6 +183,16 @@ export interface OperationInfo {
178
183
  path: string;
179
184
  parameters: OpenApiParameter[];
180
185
  requestBodySchema?: OpenApiSchema;
186
+ /** The request-body schema with every local component `$ref` deeply inlined (via
187
+ * {@link resolveSchemaDeep}), so it is self-contained: no `$ref`, and an explicit `type` where the
188
+ * referenced component declared one. This is what the MCP tool projection copies into a tool's
189
+ * `inputSchema.properties.body` — an MCP client cannot resolve `#/components/...` outside the
190
+ * original document, so the projected schema must carry the concrete shape. The raw
191
+ * {@link requestBodySchema} is retained alongside it for the type deriver (which maps a `$ref` to
192
+ * its named TypeScript type) and for `sharedRequestBodySchemas` (which fingerprints shared refs).
193
+ * Absent exactly when {@link requestBodySchema} is absent. Downstream slices (ADR 0067) read the
194
+ * resolved body `type` from here rather than re-resolving a possibly-`$ref` raw schema. */
195
+ requestBodySchemaResolved?: OpenApiSchema;
181
196
  requestBodyRequired: boolean;
182
197
  /** Every documented JSON response, keyed by status code ("200","400",…,"default"), in a stable
183
198
  * order. The type layer unions these into the operation's response type; the runtime validates a
@@ -254,6 +269,62 @@ export function resolveSchema(
254
269
  return current;
255
270
  }
256
271
 
272
+ /** Deeply inline every local component `$ref` in a schema, returning a self-contained schema with no
273
+ * `$ref` anywhere in its tree (top-level chain plus nested `properties`/`items`/
274
+ * `additionalProperties`/`allOf`/`oneOf`/`anyOf`). This is the resolver the MCP tool projection uses
275
+ * so a tool's `inputSchema` carries the concrete shape rather than an opaque `#/components/...`
276
+ * pointer an MCP client cannot follow (ADR 0067). It reuses the same local-ref semantics as
277
+ * {@link resolveSchema}, extended to recurse into subschemas.
278
+ *
279
+ * Cycles and unresolvable refs FAIL LOUDLY rather than leak a `$ref`:
280
+ * - a `$ref` that reappears on its own resolution path (an irreducible cycle — a schema that
281
+ * cannot be finitely inlined) throws;
282
+ * - a `$ref` that is non-local or dangles (no matching `#/components/schemas/*`) throws.
283
+ * `seenRefs` tracks refs on the CURRENT path only, so a component referenced twice in sibling
284
+ * branches (a diamond) inlines fine — only genuine self-reference is rejected. */
285
+ export function resolveSchemaDeep(
286
+ doc: OpenApiDoc,
287
+ schema: OpenApiSchema | undefined,
288
+ seenRefs: ReadonlySet<string> = new Set(),
289
+ ): OpenApiSchema | undefined {
290
+ if (!schema) return undefined;
291
+ let current: OpenApiSchema = schema;
292
+ let seen = seenRefs;
293
+ while (current.$ref) {
294
+ const ref = current.$ref;
295
+ if (seen.has(ref)) {
296
+ throw new Error(
297
+ `OpenAPI schema $ref cycle: ${[...seen, ref].join(" -> ")} cannot be inlined into a self-contained schema`,
298
+ );
299
+ }
300
+ const name = refName(ref);
301
+ const next = name ? doc.components?.schemas?.[name] : undefined;
302
+ if (!next) {
303
+ throw new Error(
304
+ `OpenAPI schema $ref "${ref}" does not resolve to a local component — a self-contained schema cannot contain an unresolvable $ref`,
305
+ );
306
+ }
307
+ seen = new Set(seen).add(ref);
308
+ current = next;
309
+ }
310
+ const out: OpenApiSchema = { ...current };
311
+ delete out.$ref;
312
+ if (current.properties) {
313
+ out.properties = Object.fromEntries(
314
+ Object.entries(current.properties).map(([k, v]) => [k, resolveSchemaDeep(doc, v, seen) ?? {}]),
315
+ );
316
+ }
317
+ if (current.items) out.items = resolveSchemaDeep(doc, current.items, seen);
318
+ if (current.additionalProperties && typeof current.additionalProperties === "object") {
319
+ out.additionalProperties = resolveSchemaDeep(doc, current.additionalProperties, seen);
320
+ }
321
+ for (const key of ["allOf", "oneOf", "anyOf"] as const) {
322
+ const arr = current[key];
323
+ if (arr) out[key] = arr.map((s) => resolveSchemaDeep(doc, s, seen) ?? {});
324
+ }
325
+ return out;
326
+ }
327
+
257
328
  function firstJsonSchema(
258
329
  content: Record<string, unknown> | undefined,
259
330
  ): OpenApiSchema | undefined {
@@ -289,12 +360,31 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
289
360
  if (!isRecord(opRaw)) continue;
290
361
  const operationId = typeof opRaw.operationId === "string" ? opRaw.operationId : undefined;
291
362
  if (!operationId || !isSafeOperationId(operationId)) continue;
363
+ // Resolve exclusion BEFORE any deep `$ref` inlining. An `x-mcp`-excluded operation is never
364
+ // advertised as a tool, so its schema is never projected or self-containment-guarded; doing
365
+ // its (throwing) deep resolution eagerly would let a dangling/cyclic `$ref` on an EXCLUDED
366
+ // door crash projection/guards for the whole document — even though that door is withheld.
367
+ // Excluded ops therefore carry no `resolvedSchema`/`requestBodySchemaResolved` (unused).
368
+ const mcpExcluded = isMcpExcluded(opRaw["x-mcp"]);
292
369
  const opParams = Array.isArray(opRaw.parameters) ? opRaw.parameters : [];
293
- const parameters = normalizeParameters([...pathParams, ...opParams]);
370
+ const parameters = normalizeParameters([...pathParams, ...opParams]).map((p) => ({
371
+ ...p,
372
+ // Inline any local component `$ref` so the projected MCP tool schema is self-contained. The
373
+ // raw `p.schema` is kept for the type deriver; the tool projection reads `resolvedSchema`.
374
+ resolvedSchema: mcpExcluded ? undefined : resolveSchemaDeep(doc, p.schema),
375
+ }));
294
376
  const requestBody = isRecord(opRaw.requestBody) ? opRaw.requestBody : undefined;
295
377
  const requestBodySchema = firstJsonSchema(
296
378
  isRecord(requestBody?.content) ? requestBody.content : undefined,
297
379
  );
380
+ // Deeply inline component `$ref`s so downstream consumers (the MCP tool projection, ADR 0067)
381
+ // get a self-contained, explicitly-typed body schema — never an opaque `#/components/...`
382
+ // pointer. Raw `requestBodySchema` is retained (deriver + shared-ref fingerprinting). A cyclic
383
+ // or unresolvable body ref fails loudly here rather than leaking a `$ref` — but only for a
384
+ // projected (non-excluded) op; an excluded op skips this throwing work (see above).
385
+ const requestBodySchemaResolved = mcpExcluded
386
+ ? undefined
387
+ : resolveSchemaDeep(doc, requestBodySchema);
298
388
  const responses = isRecord(opRaw.responses) ? opRaw.responses : {};
299
389
  // Effective security: an operation's own `security` (even an explicit `[]`, which opts out of
300
390
  // a document-level default) wins; otherwise inherit the document-level default; otherwise open.
@@ -308,11 +398,12 @@ export function collectOperations(doc: OpenApiDoc): OperationInfo[] {
308
398
  path,
309
399
  parameters,
310
400
  requestBodySchema,
401
+ requestBodySchemaResolved,
311
402
  requestBodyRequired: requestBody?.required === true,
312
403
  responseSchemas: collectResponseSchemas(responses),
313
404
  security,
314
405
  eject: opRaw["x-urban-eject"] === true,
315
- mcpExcluded: isMcpExcluded(opRaw["x-mcp"]),
406
+ mcpExcluded,
316
407
  summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
317
408
  });
318
409
  }
@@ -349,15 +440,114 @@ export function isMutatingMethod(method: HttpMethodLower): boolean {
349
440
  return !READ_ONLY_METHODS.includes(method);
350
441
  }
351
442
 
352
- /** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, and whether
353
- * `x-mcp` excludes it from projection. This is the shared unit the runtime MCP surface AND the CI
354
- * spec↔tool parity guard both read — a single derivation of "which operations become tools", never
355
- * a second walker (ADR 0053). */
443
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
444
+ * path/query parameters and its request-body schema. It is the SINGLE derivation both the runtime
445
+ * MCP surface (`tools/list`) and the CI parity guard read, so a tool's advertised input schema and
446
+ * the HTTP validation it dispatches to can never drift (ADR 0053/0067).
447
+ *
448
+ * Every property schema is taken from the operation's resolved (`$ref`-free) metadata — the
449
+ * parameters' `resolvedSchema` and the body's `requestBodySchemaResolved`, both produced by
450
+ * {@link resolveSchemaDeep} in {@link collectOperations} — so the emitted schema is self-contained:
451
+ * an MCP client, which cannot follow a `#/components/...` pointer outside the original document,
452
+ * sees the concrete shape (and an explicit `type` on the body). */
453
+ export function toolInputSchema(op: OperationInfo): Record<string, unknown> {
454
+ const properties: Record<string, unknown> = {};
455
+ const required: string[] = [];
456
+ for (const p of op.parameters) {
457
+ if (p.in === "path" || p.in === "query") {
458
+ properties[p.name] = p.resolvedSchema ?? p.schema ?? {};
459
+ if (p.required) required.push(p.name);
460
+ }
461
+ }
462
+ const body = op.requestBodySchemaResolved;
463
+ if (body) {
464
+ properties.body = body;
465
+ if (op.requestBodyRequired) required.push("body");
466
+ }
467
+ const schema: Record<string, unknown> = { type: "object", properties };
468
+ if (required.length > 0) schema.required = required;
469
+ return schema;
470
+ }
471
+
472
+ /** Walk a JSON value (a projected tool schema) and return a pointer for every `$ref` still present.
473
+ * Empty means the schema is self-contained — no `#/components/...` an MCP client cannot resolve. The
474
+ * path is a dotted trail to each leak (e.g. `properties.body.properties.foo`). Pure/total. */
475
+ export function findSchemaRefLeaks(value: unknown, path = ""): string[] {
476
+ const leaks: string[] = [];
477
+ const visit = (v: unknown, p: string): void => {
478
+ if (Array.isArray(v)) {
479
+ v.forEach((item, i) => visit(item, `${p}[${i}]`));
480
+ return;
481
+ }
482
+ if (v && typeof v === "object") {
483
+ for (const [k, child] of Object.entries(v)) {
484
+ // A JSON-Schema `$ref` is a *string* pointer; only that form is an unresolvable leak. A
485
+ // property literally named `$ref` with a non-string value is an ordinary schema key, so
486
+ // recurse into it (real leaks can still hide nested beneath such a property).
487
+ if (k === "$ref" && typeof child === "string") leaks.push(p || "<root>");
488
+ else visit(child, p ? `${p}.${k}` : k);
489
+ }
490
+ }
491
+ };
492
+ visit(value, path);
493
+ return leaks;
494
+ }
495
+
496
+ /** Whether a (record-typed) schema declares an explicit JSON-Schema `type` — a non-empty string or a
497
+ * non-empty array of types. Operates on a raw record (not a typed {@link OpenApiSchema}) so it can
498
+ * vet a projected tool schema's `body` property without an unchecked cast. */
499
+ function hasExplicitType(schema: Record<string, unknown>): boolean {
500
+ const t = schema.type;
501
+ if (typeof t === "string") return t.length > 0;
502
+ return Array.isArray(t) && t.length > 0;
503
+ }
504
+
505
+ /** Self-containment violations in one projected tool's `inputSchema`: any surviving `$ref` (an
506
+ * MCP client cannot resolve `#/components/...`), and — when the tool has a `body` property — a
507
+ * `body` that carries no explicit `type` (so the client cannot tell it is an object/array and
508
+ * mis-encodes the argument). Empty means the schema is a usable, self-contained input contract.
509
+ * This is the pure assertion the `check:mcp` guard runs over every projected tool (ADR 0067). */
510
+ export function findToolSchemaViolations(
511
+ operationId: string,
512
+ inputSchema: Record<string, unknown>,
513
+ ): string[] {
514
+ const violations: string[] = [];
515
+ for (const leak of findSchemaRefLeaks(inputSchema)) {
516
+ violations.push(`${operationId}: inputSchema leaks an unresolved $ref at ${leak}`);
517
+ }
518
+ const properties = isRecord(inputSchema.properties) ? inputSchema.properties : undefined;
519
+ const body = properties && isRecord(properties.body) ? properties.body : undefined;
520
+ if (body && !hasExplicitType(body)) {
521
+ violations.push(`${operationId}: inputSchema.properties.body has no explicit \`type\``);
522
+ }
523
+ return violations;
524
+ }
525
+
526
+ /** Every self-containment violation across all projected (non-excluded) tools for a document — the
527
+ * aggregate the `check:mcp` guard fails on. Excluded operations are skipped: they are withheld from
528
+ * the live tool surface, so their schema is never advertised to a client. */
529
+ export function findAllToolSchemaViolations(doc: OpenApiDoc): string[] {
530
+ const violations: string[] = [];
531
+ for (const op of collectOperations(doc)) {
532
+ if (op.mcpExcluded) continue;
533
+ violations.push(...findToolSchemaViolations(op.operationId, toolInputSchema(op)));
534
+ }
535
+ return violations;
536
+ }
537
+
538
+ /** One operation's MCP-tool projection facts: its id, HTTP method, whether it mutates, whether
539
+ * `x-mcp` excludes it from projection, and its self-contained input schema. This is the shared unit
540
+ * the runtime MCP surface AND the CI spec↔tool parity guard both read — a single derivation of
541
+ * "which operations become tools and what each accepts", never a second walker (ADR 0053). */
356
542
  export interface McpToolProjection {
357
543
  operationId: string;
358
544
  method: HttpMethodLower;
359
545
  mutating: boolean;
360
546
  excluded: boolean;
547
+ /** The tool's `inputSchema` as advertised to an MCP client (absent for an excluded op, which is
548
+ * never advertised). Captured in the snapshot so a projection change — including a body `$ref`
549
+ * that stops being resolved — is caught by `check:mcp` drift, not discovered by a client. */
550
+ inputSchema?: Record<string, unknown>;
361
551
  }
362
552
 
363
553
  /**
@@ -366,7 +556,7 @@ export interface McpToolProjection {
366
556
  * `excluded: true`) rather than dropped, so the parity guard can positively assert that an
367
557
  * `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
368
558
  * tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
369
- * `mountApi` routes.
559
+ * `mountApi` routes. A non-excluded op also carries its self-contained {@link toolInputSchema}.
370
560
  */
371
561
  export function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[] {
372
562
  return collectOperations(doc).map((op) => ({
@@ -374,6 +564,7 @@ export function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[] {
374
564
  method: op.method,
375
565
  mutating: isMutatingMethod(op.method),
376
566
  excluded: op.mcpExcluded,
567
+ ...(op.mcpExcluded ? {} : { inputSchema: toolInputSchema(op) }),
377
568
  }));
378
569
  }
379
570
 
@@ -408,6 +599,12 @@ export function diffMcpToolProjection(
408
599
  `excluded:${cur.excluded}}`,
409
600
  );
410
601
  }
602
+ if (JSON.stringify(snap.inputSchema ?? null) !== JSON.stringify(cur.inputSchema ?? null)) {
603
+ // Emit a concise, constant-size line rather than inlining both full `inputSchema` payloads:
604
+ // for a non-trivial schema that would explode CI logs and bury the signal. The committed
605
+ // snapshot's own diff carries the exact field-level detail; this line just names the drift.
606
+ drift.push(`~ ${id}: inputSchema changed (regenerate the snapshot to see the field-level diff)`);
607
+ }
411
608
  }
412
609
  for (const id of snapshotById.keys()) {
413
610
  if (!currentById.has(id)) {
@@ -327,6 +327,35 @@ 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
+
330
359
  test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
331
360
  let seenParams: Record<string, unknown> | undefined;
332
361
  const { router, imported } = buildHarness({
@@ -55,6 +55,7 @@ import {
55
55
  parseSpec,
56
56
  type SecurityDecision,
57
57
  sharedSecretSchemeName,
58
+ toolInputSchema,
58
59
  } from "../../../openapi/spec.ts";
59
60
  import type { AppApi, RuntimeContext } from "../context.ts";
60
61
  import { errorMessage } from "../guards.ts";
@@ -250,26 +251,10 @@ function isMutatingOperation(op: OperationInfo): boolean {
250
251
  return isMutatingMethod(op.method);
251
252
  }
252
253
 
253
- /** A JSON Schema (OpenAPI object subset) describing a tool's input, derived from an operation's
254
- * path/query parameters and its request-body schema. Keeping the property schemas verbatim from the
255
- * spec means the tool input schema and the HTTP validation share one source of truth. */
256
- function toolInputSchema(op: OperationInfo): Record<string, unknown> {
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
- }
254
+ /** A JSON Schema (OpenAPI object subset) describing a tool's input is derived once, in
255
+ * `openapi/spec.ts`'s {@link toolInputSchema}, from an operation's resolved (`$ref`-free) parameter
256
+ * and request-body metadata — so the tool input schema the runtime advertises and the schema the CI
257
+ * parity guard pins are the SAME derivation (one source of truth, ADR 0053/0067). */
273
258
 
274
259
  /** Substitute an operation's `{param}` path-template segments with the supplied arguments, building
275
260
  * the request path under the shared `/app/api` base so dispatch hits the exact `mountApi` route. */