@nanobpm/urban 0.86.0 → 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.
- package/dist/openapi/spec.d.ts +63 -5
- package/dist/openapi/spec.js +172 -3
- package/dist/runtime/core/host.d.ts +67 -9
- package/dist/runtime/core/host.js +48 -0
- package/dist/runtime/core/modules/mcp.js +5 -24
- package/dist/runtime/engine/nanosdk.js +9 -5
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.js +4 -0
- package/package.json +1 -1
- package/src/openapi/mcp-parity.fixture.yaml +46 -0
- package/src/openapi/mcp-tools.snapshot.json +50 -2
- package/src/openapi/spec.test.ts +221 -0
- package/src/openapi/spec.ts +204 -7
- package/src/runtime/core/host.ts +83 -9
- package/src/runtime/core/modules/mcp.test.ts +29 -0
- package/src/runtime/core/modules/mcp.ts +5 -20
- package/src/runtime/engine/nanosdk.test.ts +69 -11
- package/src/runtime/engine/nanosdk.ts +9 -5
- package/src/runtime/index.ts +8 -0
package/dist/openapi/spec.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
/**
|
package/dist/openapi/spec.js
CHANGED
|
@@ -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
|
|
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)) {
|
|
@@ -260,12 +260,58 @@ export interface ElementInstanceSummary {
|
|
|
260
260
|
}
|
|
261
261
|
/**
|
|
262
262
|
* The kind of wait an element instance is parked in, as a
|
|
263
|
-
* `/v2/element-instances/wait-states/search` result reports it.
|
|
264
|
-
*
|
|
265
|
-
* (an event awaiting correlation), a `TIMER`, a `SIGNAL`, a `CONDITION`, as well
|
|
266
|
-
*
|
|
263
|
+
* `/v2/element-instances/wait-states/search` result reports it. This is the *canonical*
|
|
264
|
+
* six-type contract (Zeebe's, as of 2026): a `JOB` (a service task awaiting a worker), a
|
|
265
|
+
* `MESSAGE` (an event awaiting correlation), a `TIMER`, a `SIGNAL`, a `CONDITION`, as well
|
|
266
|
+
* as a `USER_TASK`.
|
|
267
|
+
*
|
|
268
|
+
* **Not every deployed engine implements the whole union.** It is forward-looking on
|
|
269
|
+
* purpose (the response mapper and {@link normalizeWaitStateType} tolerate all six so a
|
|
270
|
+
* newer engine's rows map cleanly), but the *currently deployed* nanobpmn gateway's
|
|
271
|
+
* read-model floor is narrower — see {@link DEPLOYED_WAIT_STATE_TYPES}. A
|
|
272
|
+
* {@link ElementInstanceWaitStateFilter} whose `waitStateType` falls outside that floor is
|
|
273
|
+
* rejected (the gateway answers HTTP 422); {@link assertDeployedWaitStateType} enforces the
|
|
274
|
+
* same floor client-side so the divergence surfaces as a clear error offline, in tests, and
|
|
275
|
+
* against a live engine alike. Ref: Magikcraft/nano-bpm#1042.
|
|
267
276
|
*/
|
|
268
277
|
export type WaitStateType = "JOB" | "MESSAGE" | "USER_TASK" | "TIMER" | "SIGNAL" | "CONDITION";
|
|
278
|
+
/**
|
|
279
|
+
* The `waitStateType` values the *currently deployed* nanobpmn gateway's wait-state read
|
|
280
|
+
* model actually implements — the **deployed floor**, a strict subset of the canonical
|
|
281
|
+
* {@link WaitStateType} union. The gateway's read model is a snapshot of the Zeebe contract
|
|
282
|
+
* taken 2026-06-09 (the same day Zeebe extended it with `USER_TASK`, then `TIMER`/`SIGNAL`/
|
|
283
|
+
* `CONDITION`), so it recognizes only `JOB | MESSAGE` and rejects any other filter value
|
|
284
|
+
* with HTTP 422.
|
|
285
|
+
*
|
|
286
|
+
* This constant is the **single source of truth** for that floor: the SDK-backed and
|
|
287
|
+
* WASM-backed {@link EngineClient} adapters both gate through {@link assertDeployedWaitStateType},
|
|
288
|
+
* and the WASM emulation synthesizes only these park kinds — so a test cannot pass against
|
|
289
|
+
* the emulation while a real gateway would 422 (No Drift Surfaces). Widen it (adding a leg
|
|
290
|
+
* backed by the real gateway) only once the engine actually serves the richer type — the
|
|
291
|
+
* tracking gap is Magikcraft/nano-bpm#1042.
|
|
292
|
+
*/
|
|
293
|
+
export declare const DEPLOYED_WAIT_STATE_TYPES: readonly WaitStateType[];
|
|
294
|
+
/**
|
|
295
|
+
* Thrown when a wait-state search is asked to filter on a {@link WaitStateType} the deployed
|
|
296
|
+
* gateway's read model does not implement (outside {@link DEPLOYED_WAIT_STATE_TYPES}). It
|
|
297
|
+
* fails fast client-side with a clear, offline-detectable error rather than letting the call
|
|
298
|
+
* reach the gateway and surface as an opaque HTTP 422 "did not match any variant of untagged
|
|
299
|
+
* enum WaitStateTypeFilterProperty". Ref: Magikcraft/nano-bpm#1042.
|
|
300
|
+
*/
|
|
301
|
+
export declare class UnsupportedWaitStateTypeError extends Error {
|
|
302
|
+
readonly waitStateType: string;
|
|
303
|
+
readonly supported: readonly WaitStateType[];
|
|
304
|
+
constructor(waitStateType: string);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Assert a wait-state filter's `waitStateType` is within the deployed floor
|
|
308
|
+
* ({@link DEPLOYED_WAIT_STATE_TYPES}), throwing {@link UnsupportedWaitStateTypeError}
|
|
309
|
+
* otherwise. An absent selector is always allowed (the search is unfiltered by type). Both
|
|
310
|
+
* {@link EngineClient} adapters call this before issuing a wait-state search, so the floor is
|
|
311
|
+
* enforced identically whether the query runs against a live gateway or the in-process
|
|
312
|
+
* emulation. No-ops for an in-floor type.
|
|
313
|
+
*/
|
|
314
|
+
export declare function assertDeployedWaitStateType(waitStateType?: WaitStateType): void;
|
|
269
315
|
/**
|
|
270
316
|
* A single parked (waiting) element instance as
|
|
271
317
|
* {@link EngineClient.searchElementInstanceWaitStates} reports it. Every variant carries the
|
|
@@ -312,6 +358,10 @@ export interface ElementInstanceFilter {
|
|
|
312
358
|
* Selectors for an element-instance *wait-state* search: which process instance, element,
|
|
313
359
|
* and/or kind of wait to match. Used by {@link EngineClient.searchElementInstanceWaitStates}.
|
|
314
360
|
* There is deliberately no lifecycle-`state` selector — a wait state is always an active park.
|
|
361
|
+
*
|
|
362
|
+
* A `waitStateType` selector must fall within the deployed floor
|
|
363
|
+
* ({@link DEPLOYED_WAIT_STATE_TYPES}); a value outside it is rejected with
|
|
364
|
+
* {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422).
|
|
315
365
|
*/
|
|
316
366
|
export interface ElementInstanceWaitStateFilter {
|
|
317
367
|
processInstanceKey?: string;
|
|
@@ -476,11 +526,19 @@ export interface EngineClient {
|
|
|
476
526
|
searchElementInstances(filter?: ElementInstanceFilter): Promise<ElementInstanceSummary[]>;
|
|
477
527
|
/**
|
|
478
528
|
* Search the *wait states* of element instances
|
|
479
|
-
* (`POST /v2/element-instances/wait-states/search`) —
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
529
|
+
* (`POST /v2/element-instances/wait-states/search`) — the parks the deployed engine's
|
|
530
|
+
* read model serves. A zero-wait read; each result is discriminated by `waitStateType`
|
|
531
|
+
* (see {@link ElementInstanceWaitState}), so a consumer can read the job/message parks a
|
|
532
|
+
* {@link searchUserTasks} cannot surface.
|
|
533
|
+
*
|
|
534
|
+
* **Deployed floor.** The currently deployed nanobpmn gateway implements only
|
|
535
|
+
* `JOB | MESSAGE` ({@link DEPLOYED_WAIT_STATE_TYPES}) — a `USER_TASK` park is read via
|
|
536
|
+
* {@link searchUserTasks}, not here. A `waitStateType` filter outside the floor is rejected
|
|
537
|
+
* with {@link UnsupportedWaitStateTypeError} (the live gateway answers HTTP 422); an
|
|
538
|
+
* unfiltered search returns only in-floor parks. The `waitStateType` union is
|
|
539
|
+
* forward-looking (richer engines may serve more), but this adapter contract pins the
|
|
540
|
+
* floor so a query cannot pass in emulation while a real engine rejects it. Ref:
|
|
541
|
+
* Magikcraft/nano-bpm#1042.
|
|
484
542
|
*/
|
|
485
543
|
searchElementInstanceWaitStates(filter?: ElementInstanceWaitStateFilter): Promise<ElementInstanceWaitState[]>;
|
|
486
544
|
/**
|
|
@@ -42,6 +42,54 @@ export function presentFormIdentifier(value) {
|
|
|
42
42
|
const trimmed = value?.trim();
|
|
43
43
|
return trimmed ? trimmed : undefined;
|
|
44
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The `waitStateType` values the *currently deployed* nanobpmn gateway's wait-state read
|
|
47
|
+
* model actually implements — the **deployed floor**, a strict subset of the canonical
|
|
48
|
+
* {@link WaitStateType} union. The gateway's read model is a snapshot of the Zeebe contract
|
|
49
|
+
* taken 2026-06-09 (the same day Zeebe extended it with `USER_TASK`, then `TIMER`/`SIGNAL`/
|
|
50
|
+
* `CONDITION`), so it recognizes only `JOB | MESSAGE` and rejects any other filter value
|
|
51
|
+
* with HTTP 422.
|
|
52
|
+
*
|
|
53
|
+
* This constant is the **single source of truth** for that floor: the SDK-backed and
|
|
54
|
+
* WASM-backed {@link EngineClient} adapters both gate through {@link assertDeployedWaitStateType},
|
|
55
|
+
* and the WASM emulation synthesizes only these park kinds — so a test cannot pass against
|
|
56
|
+
* the emulation while a real gateway would 422 (No Drift Surfaces). Widen it (adding a leg
|
|
57
|
+
* backed by the real gateway) only once the engine actually serves the richer type — the
|
|
58
|
+
* tracking gap is Magikcraft/nano-bpm#1042.
|
|
59
|
+
*/
|
|
60
|
+
export const DEPLOYED_WAIT_STATE_TYPES = ["JOB", "MESSAGE"];
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when a wait-state search is asked to filter on a {@link WaitStateType} the deployed
|
|
63
|
+
* gateway's read model does not implement (outside {@link DEPLOYED_WAIT_STATE_TYPES}). It
|
|
64
|
+
* fails fast client-side with a clear, offline-detectable error rather than letting the call
|
|
65
|
+
* reach the gateway and surface as an opaque HTTP 422 "did not match any variant of untagged
|
|
66
|
+
* enum WaitStateTypeFilterProperty". Ref: Magikcraft/nano-bpm#1042.
|
|
67
|
+
*/
|
|
68
|
+
export class UnsupportedWaitStateTypeError extends Error {
|
|
69
|
+
waitStateType;
|
|
70
|
+
supported;
|
|
71
|
+
constructor(waitStateType) {
|
|
72
|
+
super(`unsupported waitStateType "${waitStateType}": the deployed nanobpmn gateway's ` +
|
|
73
|
+
`wait-state read model implements only ${DEPLOYED_WAIT_STATE_TYPES.join(" | ")} and ` +
|
|
74
|
+
`rejects any other value with HTTP 422. Ref: Magikcraft/nano-bpm#1042.`);
|
|
75
|
+
this.name = "UnsupportedWaitStateTypeError";
|
|
76
|
+
this.waitStateType = waitStateType;
|
|
77
|
+
this.supported = DEPLOYED_WAIT_STATE_TYPES;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Assert a wait-state filter's `waitStateType` is within the deployed floor
|
|
82
|
+
* ({@link DEPLOYED_WAIT_STATE_TYPES}), throwing {@link UnsupportedWaitStateTypeError}
|
|
83
|
+
* otherwise. An absent selector is always allowed (the search is unfiltered by type). Both
|
|
84
|
+
* {@link EngineClient} adapters call this before issuing a wait-state search, so the floor is
|
|
85
|
+
* enforced identically whether the query runs against a live gateway or the in-process
|
|
86
|
+
* emulation. No-ops for an in-floor type.
|
|
87
|
+
*/
|
|
88
|
+
export function assertDeployedWaitStateType(waitStateType) {
|
|
89
|
+
if (waitStateType !== undefined && !DEPLOYED_WAIT_STATE_TYPES.includes(waitStateType)) {
|
|
90
|
+
throw new UnsupportedWaitStateTypeError(waitStateType);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
45
93
|
/**
|
|
46
94
|
* The names of every method on {@link EngineClient} — the single *runtime* source of
|
|
47
95
|
* truth for the interface's surface. It is compile-time-pinned to `keyof EngineClient`
|
|
@@ -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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
|
|
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) {
|
|
@@ -18,7 +18,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
18
18
|
}
|
|
19
19
|
return path;
|
|
20
20
|
};
|
|
21
|
-
import { isBpmnError } from "../core/host.js";
|
|
21
|
+
import { assertDeployedWaitStateType, isBpmnError } from "../core/host.js";
|
|
22
22
|
import { buildFormSchema, parseFormSchema, pickFormLinkage, resolveFormIdentifier, } from "../core/form-contract.js";
|
|
23
23
|
import { isRecord } from "../core/guards.js";
|
|
24
24
|
import { applyAmbientLineage } from "../core/lineage.js";
|
|
@@ -507,10 +507,14 @@ export class SdkEngineClient {
|
|
|
507
507
|
});
|
|
508
508
|
}
|
|
509
509
|
async searchElementInstanceWaitStates(filter) {
|
|
510
|
-
// The wait-states search surfaces
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
510
|
+
// The wait-states search surfaces the parks the deployed engine's read model serves.
|
|
511
|
+
// A `waitStateType` selector outside the deployed floor (`JOB | MESSAGE`) is rejected by
|
|
512
|
+
// the gateway with HTTP 422; fail fast client-side with a clear error instead, so the
|
|
513
|
+
// divergence is caught offline and identically to the WASM adapter (No Drift Surfaces).
|
|
514
|
+
assertDeployedWaitStateType(filter?.waitStateType);
|
|
515
|
+
// Same zero-wait read + per-row mapping-gate shape as `searchElementInstances`; the SDK
|
|
516
|
+
// nests park-specific fields under `details`, which `mapElementInstanceWaitStateRow`
|
|
517
|
+
// unwraps.
|
|
514
518
|
const f = {};
|
|
515
519
|
if (filter?.processInstanceKey)
|
|
516
520
|
f.processInstanceKey = filter.processInstanceKey;
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { createLogger } from "./core/logger.ts";
|
|
|
15
15
|
export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
|
|
16
16
|
export { BpmnError, isBpmnError } from "./core/host.ts";
|
|
17
17
|
export { ENGINE_CLIENT_METHODS } from "./core/host.ts";
|
|
18
|
+
export { assertDeployedWaitStateType, DEPLOYED_WAIT_STATE_TYPES, UnsupportedWaitStateTypeError, } from "./core/host.ts";
|
|
18
19
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.ts";
|
|
19
20
|
export type { ConnectorsHandle } from "./core/modules/connectors.ts";
|
|
20
21
|
export { DataLayer, TypeRepo } from "./core/modules/datasource.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -22,6 +22,10 @@ export { BpmnError, isBpmnError } from "./core/host.js";
|
|
|
22
22
|
// double / adapter iterates it to prove at runtime that it implements the full interface,
|
|
23
23
|
// catching the "fake lags the SDK" seam-lag class categorically.
|
|
24
24
|
export { ENGINE_CLIENT_METHODS } from "./core/host.js";
|
|
25
|
+
// Deployed wait-state floor (issue nanobpm/nano-ide#497): the `waitStateType` values the
|
|
26
|
+
// deployed gateway actually serves, plus the shared guard both EngineClient adapters use so
|
|
27
|
+
// a filter outside the floor fails identically in emulation and against a live engine.
|
|
28
|
+
export { assertDeployedWaitStateType, DEPLOYED_WAIT_STATE_TYPES, UnsupportedWaitStateTypeError, } from "./core/host.js";
|
|
25
29
|
// Connector packs (ADR 0050, in-process port)
|
|
26
30
|
export { adaptConnectorHandler, mountConnectors, resolveInstalledConnectors, } from "./core/modules/connectors.js";
|
|
27
31
|
// Data layer
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
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",
|