@nanobpm/urban 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -128,6 +128,11 @@ export interface OperationInfo {
128
128
  * else the document-level default, else `[]`). An empty list means the operation is open. */
129
129
  security: OpenApiSecurityRequirement[];
130
130
  eject: boolean;
131
+ /** Whether the operation opts OUT of MCP tool projection via the `x-mcp` extension (ADR 0067).
132
+ * Defaults to `false` — an operation IS exposed as a tool unless it explicitly excludes itself.
133
+ * Security-relevant: this is the single authoring switch that keeps an operator-only door (e.g.
134
+ * a human-gated dispatch) out of the agent-facing tool surface. See {@link isMcpExcluded}. */
135
+ mcpExcluded: boolean;
131
136
  summary?: string;
132
137
  }
133
138
  /** Parse an OpenAPI document from text. Accepts both YAML (the sanctioned authoring format for
@@ -143,6 +148,73 @@ export declare function resolveSchema(doc: OpenApiDoc, schema: OpenApiSchema | u
143
148
  * (path, method) order. Operations without an operationId are skipped here; the runtime mount logs
144
149
  * them at `warn` and skips them too — this keeps the pure collector total. */
145
150
  export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
151
+ /**
152
+ * Read the `x-mcp` operation extension (ADR 0067 — MCP tool projection). An operation is EXPOSED as
153
+ * an MCP tool by default; it is excluded only when it says so explicitly, so a spec author opts a
154
+ * door OUT rather than every operation having to opt in. Recognised exclusion forms:
155
+ * - `x-mcp: false` — a terse boolean opt-out;
156
+ * - `x-mcp: { exclude: true }` — the object form (room for future keys, e.g. per-tool metadata).
157
+ * Any other value (including `x-mcp: true` / `{ exclude: false }` / absent) leaves the operation
158
+ * exposed — fail-open to the documented default. This is a SECURITY-RELEVANT authoring rule: it is
159
+ * the one switch that keeps an operator-only operation out of the agent-facing tool surface, so it
160
+ * is enforced from the same single walker the runtime and the parity guard both read (no second
161
+ * source of truth to drift, ADR 0053).
162
+ */
163
+ export declare function isMcpExcluded(ext: unknown): boolean;
164
+ /** Whether an operation's HTTP method mutates state — anything other than a safe read verb. Drives
165
+ * the MCP read/mutate split: read tools are unguarded on loopback, mutating tools are gated. */
166
+ 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). */
171
+ export interface McpToolProjection {
172
+ operationId: string;
173
+ method: HttpMethodLower;
174
+ mutating: boolean;
175
+ excluded: boolean;
176
+ }
177
+ /**
178
+ * Project every operationId-bearing operation to its MCP-tool facts, in the stable
179
+ * `collectOperations` (path, method) order. Excluded operations are RETAINED here (flagged
180
+ * `excluded: true`) rather than dropped, so the parity guard can positively assert that an
181
+ * `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
182
+ * tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
183
+ * `mountApi` routes.
184
+ */
185
+ export declare function collectMcpToolProjection(doc: OpenApiDoc): McpToolProjection[];
186
+ /**
187
+ * Compare a previously-captured MCP-tool projection (the committed snapshot) against the projection
188
+ * freshly computed from the spec, returning a human-readable line for every skew — an operation the
189
+ * spec now projects that the snapshot lacks, one the snapshot still lists that the spec no longer
190
+ * projects, or one whose method / mutating / excluded facts changed. An empty array means the spec
191
+ * and the projected tool list are in parity. This is the pure core of the `check:mcp` CI drift gate
192
+ * (ADR 0067) — the same "generated derived artifact + fail-on-drift" pattern the repo already uses
193
+ * for `check:runtime` — and is unit-tested by feeding it an injected skew.
194
+ */
195
+ export declare function diffMcpToolProjection(snapshot: McpToolProjection[], current: McpToolProjection[]): string[];
196
+ /**
197
+ * Every app shared-secret security scheme (ADR 0067/0059): the `apiKey` schemes in
198
+ * `components.securitySchemes` presented in a request HEADER and pointing at an env var via
199
+ * `x-nano-secret-env` (declared, even if empty — an empty pointer is a misconfiguration surfaced as
200
+ * 500 downstream, not "no scheme"), in document (authoring) order. Single source of truth for both the guard
201
+ * selector (`sharedSecretSchemeName`) and its ambiguity check — a mutating MCP tool REUSES the
202
+ * app's existing shared secret (the same one a `webhook` trigger or a guarded operation already
203
+ * uses) instead of minting an MCP-specific synonym.
204
+ */
205
+ export declare function sharedSecretSchemeNames(doc: OpenApiDoc): string[];
206
+ /**
207
+ * The name of the app's single shared-secret security scheme (ADR 0067/0059) — the credential the
208
+ * framework MUTATING MCP tools require. `undefined` when the app declares no such scheme, in which
209
+ * case the mutating tools stay closed unless an explicit runtime opt-in is set.
210
+ *
211
+ * THROWS when the spec declares MORE THAN ONE candidate: which scheme becomes "the" MCP mutation
212
+ * guard would otherwise depend on `securitySchemes` object iteration (authoring) order, silently
213
+ * guarding with an unintended secret (or rejecting a different, valid shared-secret header). That is
214
+ * an incoherent spec, surfaced loudly — the caller maps it to a 500 misconfiguration — rather than
215
+ * resolved by an invisible "first wins" rule. Derived from `sharedSecretSchemeNames` (no drift).
216
+ */
217
+ export declare function sharedSecretSchemeName(doc: OpenApiDoc): string | undefined;
146
218
  /**
147
219
  * Throw if the document declares the same `operationId` on more than one operation. An operationId
148
220
  * must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
@@ -172,12 +172,142 @@ export function collectOperations(doc) {
172
172
  responseSchemas: collectResponseSchemas(responses),
173
173
  security,
174
174
  eject: opRaw["x-urban-eject"] === true,
175
+ mcpExcluded: isMcpExcluded(opRaw["x-mcp"]),
175
176
  summary: typeof opRaw.summary === "string" ? opRaw.summary : undefined,
176
177
  });
177
178
  }
178
179
  }
179
180
  return out;
180
181
  }
182
+ /**
183
+ * Read the `x-mcp` operation extension (ADR 0067 — MCP tool projection). An operation is EXPOSED as
184
+ * an MCP tool by default; it is excluded only when it says so explicitly, so a spec author opts a
185
+ * door OUT rather than every operation having to opt in. Recognised exclusion forms:
186
+ * - `x-mcp: false` — a terse boolean opt-out;
187
+ * - `x-mcp: { exclude: true }` — the object form (room for future keys, e.g. per-tool metadata).
188
+ * Any other value (including `x-mcp: true` / `{ exclude: false }` / absent) leaves the operation
189
+ * exposed — fail-open to the documented default. This is a SECURITY-RELEVANT authoring rule: it is
190
+ * the one switch that keeps an operator-only operation out of the agent-facing tool surface, so it
191
+ * is enforced from the same single walker the runtime and the parity guard both read (no second
192
+ * source of truth to drift, ADR 0053).
193
+ */
194
+ export function isMcpExcluded(ext) {
195
+ if (ext === false)
196
+ return true;
197
+ return isRecord(ext) && ext.exclude === true;
198
+ }
199
+ /** The HTTP methods MCP treats as read-only (safe, non-mutating). These are exactly the HTTP "safe"
200
+ * verbs (RFC 9110 §9.2.1) that {@link HTTP_METHODS} can express: GET/HEAD, plus OPTIONS (a
201
+ * CORS/preflight metadata probe that never mutates). Every other method an operation can declare is
202
+ * a mutation whose tool is guarded (ADR 0067 Slice 3). */
203
+ const READ_ONLY_METHODS = ["get", "head", "options"];
204
+ /** Whether an operation's HTTP method mutates state — anything other than a safe read verb. Drives
205
+ * the MCP read/mutate split: read tools are unguarded on loopback, mutating tools are gated. */
206
+ export function isMutatingMethod(method) {
207
+ return !READ_ONLY_METHODS.includes(method);
208
+ }
209
+ /**
210
+ * Project every operationId-bearing operation to its MCP-tool facts, in the stable
211
+ * `collectOperations` (path, method) order. Excluded operations are RETAINED here (flagged
212
+ * `excluded: true`) rather than dropped, so the parity guard can positively assert that an
213
+ * `x-mcp`-excluded door is present-but-withheld — the runtime drops them when it builds the live
214
+ * tool list. Derived entirely from {@link collectOperations}, so it can never diverge from what
215
+ * `mountApi` routes.
216
+ */
217
+ export function collectMcpToolProjection(doc) {
218
+ return collectOperations(doc).map((op) => ({
219
+ operationId: op.operationId,
220
+ method: op.method,
221
+ mutating: isMutatingMethod(op.method),
222
+ excluded: op.mcpExcluded,
223
+ }));
224
+ }
225
+ /**
226
+ * Compare a previously-captured MCP-tool projection (the committed snapshot) against the projection
227
+ * freshly computed from the spec, returning a human-readable line for every skew — an operation the
228
+ * spec now projects that the snapshot lacks, one the snapshot still lists that the spec no longer
229
+ * projects, or one whose method / mutating / excluded facts changed. An empty array means the spec
230
+ * and the projected tool list are in parity. This is the pure core of the `check:mcp` CI drift gate
231
+ * (ADR 0067) — the same "generated derived artifact + fail-on-drift" pattern the repo already uses
232
+ * for `check:runtime` — and is unit-tested by feeding it an injected skew.
233
+ */
234
+ export function diffMcpToolProjection(snapshot, current) {
235
+ const drift = [];
236
+ const byId = (list) => new Map(list.map((p) => [p.operationId, p]));
237
+ const snapshotById = byId(snapshot);
238
+ const currentById = byId(current);
239
+ for (const [id, cur] of currentById) {
240
+ const snap = snapshotById.get(id);
241
+ if (!snap) {
242
+ drift.push(`+ ${id}: spec projects a tool the snapshot is missing (regenerate the snapshot)`);
243
+ continue;
244
+ }
245
+ if (snap.method !== cur.method || snap.mutating !== cur.mutating || snap.excluded !== cur.excluded) {
246
+ drift.push(`~ ${id}: projection changed — snapshot {method:${snap.method}, mutating:${snap.mutating}, ` +
247
+ `excluded:${snap.excluded}} vs spec {method:${cur.method}, mutating:${cur.mutating}, ` +
248
+ `excluded:${cur.excluded}}`);
249
+ }
250
+ }
251
+ for (const id of snapshotById.keys()) {
252
+ if (!currentById.has(id)) {
253
+ drift.push(`- ${id}: snapshot lists a tool the spec no longer projects (regenerate the snapshot)`);
254
+ }
255
+ }
256
+ return drift;
257
+ }
258
+ /**
259
+ * Every app shared-secret security scheme (ADR 0067/0059): the `apiKey` schemes in
260
+ * `components.securitySchemes` presented in a request HEADER and pointing at an env var via
261
+ * `x-nano-secret-env` (declared, even if empty — an empty pointer is a misconfiguration surfaced as
262
+ * 500 downstream, not "no scheme"), in document (authoring) order. Single source of truth for both the guard
263
+ * selector (`sharedSecretSchemeName`) and its ambiguity check — a mutating MCP tool REUSES the
264
+ * app's existing shared secret (the same one a `webhook` trigger or a guarded operation already
265
+ * uses) instead of minting an MCP-specific synonym.
266
+ */
267
+ export function sharedSecretSchemeNames(doc) {
268
+ const schemes = doc.components?.securitySchemes ?? {};
269
+ const names = [];
270
+ for (const [name, scheme] of Object.entries(schemes)) {
271
+ // A malformed scheme value (e.g. `webhookKey:` with no body parses as `null` in YAML, or a
272
+ // primitive) is not a valid apiKey shared-secret candidate — skip it rather than dereference it
273
+ // and throw a bare TypeError. This mirrors `evaluateSecurity`, which never treats a non-object
274
+ // scheme as a valid credential (a referenced null/primitive scheme surfaces there as a 500), so
275
+ // there is no silent security hole: mutations fail closed and a genuine misconfig is caught at
276
+ // request time by the enforcement path.
277
+ if (!isRecord(scheme))
278
+ continue;
279
+ // Structural presence check (`typeof … === "string"`), NOT truthiness: a declared-but-EMPTY
280
+ // `x-nano-secret-env` is a misconfiguration, not "no scheme". Selecting it here lets
281
+ // `evaluateSecurity` surface it as a 500 (missing env-var pointer) — the same way it treats an
282
+ // empty pointer — instead of silently dropping the scheme so the guard reports a misleading 401.
283
+ if (scheme.type === "apiKey" &&
284
+ (scheme.in ?? "header") === "header" &&
285
+ typeof scheme["x-nano-secret-env"] === "string") {
286
+ names.push(name);
287
+ }
288
+ }
289
+ return names;
290
+ }
291
+ /**
292
+ * The name of the app's single shared-secret security scheme (ADR 0067/0059) — the credential the
293
+ * framework MUTATING MCP tools require. `undefined` when the app declares no such scheme, in which
294
+ * case the mutating tools stay closed unless an explicit runtime opt-in is set.
295
+ *
296
+ * THROWS when the spec declares MORE THAN ONE candidate: which scheme becomes "the" MCP mutation
297
+ * guard would otherwise depend on `securitySchemes` object iteration (authoring) order, silently
298
+ * guarding with an unintended secret (or rejecting a different, valid shared-secret header). That is
299
+ * an incoherent spec, surfaced loudly — the caller maps it to a 500 misconfiguration — rather than
300
+ * resolved by an invisible "first wins" rule. Derived from `sharedSecretSchemeNames` (no drift).
301
+ */
302
+ export function sharedSecretSchemeName(doc) {
303
+ const names = sharedSecretSchemeNames(doc);
304
+ if (names.length > 1) {
305
+ throw new Error(`OpenAPI spec declares multiple shared-secret schemes (apiKey header with x-nano-secret-env): ` +
306
+ `${names.join(", ")}. Exactly one may serve as the MCP mutation guard — remove the extras so ` +
307
+ "the guard scheme is unambiguous.");
308
+ }
309
+ return names[0];
310
+ }
181
311
  /**
182
312
  * Throw if the document declares the same `operationId` on more than one operation. An operationId
183
313
  * must be unique across the whole OpenAPI document (it names the delegate module `<dir>/<operationId>`
@@ -13,6 +13,10 @@ export interface ApiBinding {
13
13
  * for free — the UI reads the SAME OpenAPI document the runtime routes + validates from. */
14
14
  docs?: boolean | string;
15
15
  }
16
+ /** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
17
+ * live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
18
+ * stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
19
+ export declare const API_BASE = "/app/api";
16
20
  /**
17
21
  * Derive the ABSOLUTE spec + operations-server URLs the Swagger UI page should use, from the docs
18
22
  * page's own address (`pageHref`), the mount-root-relative docs path (`docsBase`, e.g.
@@ -97,6 +101,7 @@ export declare class NotImplemented extends Error {
97
101
  export declare function defineOperation<Req extends OperationContract = DefaultContract, Res = unknown>(_id: string, handler: OperationHandler<Req, Res>): OperationHandler<Req, Res>;
98
102
  /** Resolve an operation delegate from a loaded module: `default` (when a function) else `handler`. */
99
103
  export declare function resolveOperationHandler(mod: Record<string, unknown>): OperationHandler | undefined;
104
+ export declare function readApiBinding(manifest: unknown): ApiBinding | undefined;
100
105
  /** The Swagger UI route for an app's `api` binding, or `undefined` when there is no api surface
101
106
  * or docs are disabled. Lets other surfaces (e.g. the pages shell's "API docs" badge) link to
102
107
  * the docs without re-deriving the path. */
@@ -19,7 +19,7 @@ import { collectOperations, isSafeOperationId, operationsWithoutId, operationsWi
19
19
  /** The canonical, non-configurable mount prefix for an app's OpenAPI operations. Operations always
20
20
  * live under `/app/api` so page actions, links, and any `callRoute` can name an operation path as a
21
21
  * stable framework constant (`/app/api/<op path>`) — there is no per-app `base` knob to drift. */
22
- const API_BASE = "/app/api";
22
+ export const API_BASE = "/app/api";
23
23
  /** Pinned Swagger UI dist served from a CDN, so the docs UI adds zero bundle/runtime deps to an
24
24
  * Urban app (matching the deps-free spirit of the rest of the surface — validators are standalone,
25
25
  * ADR 0058 Consequences). Pinned (not `latest`) for reproducibility. */
@@ -147,7 +147,7 @@ export function resolveOperationHandler(mod) {
147
147
  return mod.handler;
148
148
  return undefined;
149
149
  }
150
- function readApiBinding(manifest) {
150
+ export function readApiBinding(manifest) {
151
151
  const raw = manifest && typeof manifest === "object" ? Reflect.get(manifest, "api") : undefined;
152
152
  if (!raw || typeof raw !== "object")
153
153
  return undefined;
@@ -0,0 +1,75 @@
1
+ import type { AppApi, RuntimeContext } from "../context.ts";
2
+ import type { HttpRequest } from "../host.ts";
3
+ import { type Route } from "../router.ts";
4
+ export interface McpHandle {
5
+ readonly name: string;
6
+ routes: Route[];
7
+ /** Whether the surface actually answers vs. 404s (config-enabled). The route is always mounted
8
+ * for a stable address, so callers deriving an "active surfaces" list must gate on this. */
9
+ readonly enabled: boolean;
10
+ describe(): Record<string, unknown>;
11
+ }
12
+ /** Mint a fresh MCP session id, host-agnostically. Prefers the Web Crypto `randomUUID` (a global,
13
+ * not `node:crypto`) where present, with a time+random fallback so a host without `crypto` never
14
+ * throws — mirroring how core treats Web Crypto as optional (see `mintRootRequestKey`). */
15
+ export declare function newSessionId(): string;
16
+ /** Bound the live-session map to `max` by evicting least-recently-used entries (the `Map`'s leading
17
+ * keys, given `touchSession` re-inserts on use), closing each victim best-effort so its transport /
18
+ * server releases. This is the categorical fix for the unbounded-growth vector: no client can pin
19
+ * more than `max` server/transport pairs, whatever its `initialize` cadence. Exported for a direct
20
+ * unit test of the LRU eviction contract. */
21
+ export declare function evictExcessSessions<S>(sessions: Map<string, S>, max: number, close: (session: S) => Promise<void>): Promise<void>;
22
+ /** The resolved MCP access policy for this app. */
23
+ interface McpConfig {
24
+ /** Whether the MCP surface answers at all. Defaults ON. */
25
+ readonly enabled: boolean;
26
+ /** Whether non-loopback callers are allowed. Defaults OFF (loopback-only). */
27
+ readonly allowRemote: boolean;
28
+ /** Explicit runtime opt-in that authorizes the framework MUTATING debug tools WITHOUT a
29
+ * shared-secret credential (ADR 0067 Slice 3). Defaults OFF: mutations then require the app's
30
+ * shared secret. An operator sets this to open mutations on a trusted, credential-less setup
31
+ * (e.g. a purely local debugging box). It is honoured LOOPBACK-ONLY: when `allowRemote` is also
32
+ * enabled the credential-free bypass is ignored and remote mutations still require the shared
33
+ * secret, so opening the surface to non-loopback callers can never silently expose unguarded
34
+ * mutating tools. */
35
+ readonly allowMutations: boolean;
36
+ }
37
+ /**
38
+ * Resolve the MCP access policy from the manifest
39
+ * (`mcp: { enabled?, allowRemote?, allowMutations? }`, read reflectively until the app schema folds
40
+ * the field in — mirroring how `readApiBinding` reads `api`) and the environment
41
+ * (`URBAN_MCP_ENABLED`, `URBAN_MCP_ALLOW_REMOTE`, `URBAN_MCP_ALLOW_MUTATIONS`). The env flags win so
42
+ * an operator can force the surface off (or open it to a non-loopback bind, or enable mutating
43
+ * tools) without editing the manifest. Enabled defaults ON and access defaults to loopback-only
44
+ * ("defaults ON for loopback"); `allowMutations` defaults OFF (mutating tools stay closed).
45
+ */
46
+ export declare function readMcpConfig(manifest: unknown, env: (name: string) => string | undefined): McpConfig;
47
+ /** Whether the request targets a loopback host — the best-effort loopback signal available at the
48
+ * host seam (which carries no peer address). A missing Host header is treated as loopback (a direct
49
+ * local client that sent none). */
50
+ export declare function isLoopbackRequest(req: HttpRequest): boolean;
51
+ /** Read a header value from the SDK's per-request `requestInfo.headers` map (lower-cased keys,
52
+ * values `string | string[] | undefined`). A repeated header (`string[]`, as Node's raw adapter
53
+ * surfaces it) is comma-joined to MATCH `Headers.get()` semantics — the same read `mountApi` and
54
+ * the reconstructed `toHttpRequest` use for OpenAPI route enforcement — so the shared-secret guard
55
+ * cannot diverge from route enforcement on a repeated credential header. Returns `undefined` when
56
+ * absent. Used to read the shared-secret credential the client presents on the MCP connection for a
57
+ * mutating tool call. */
58
+ export declare function readHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined;
59
+ /** The `required` keys a tool's `inputSchema` declares but the call omitted. Presence is decided
60
+ * solely by {@link isBlankArg} — the single source of truth also used for path args — so a value is
61
+ * "provided" unless it is absent (`undefined`/`null`) or a blank/whitespace-only string. A required
62
+ * non-string input (e.g. a numeric/boolean/object arg) that is present is therefore accepted, not
63
+ * wrongly reported missing. Derived from the schema's own `required` array so declaring a field
64
+ * required is enough to have missing (or whitespace-only) calls rejected rather than silently
65
+ * substituted with an empty string. */
66
+ export declare function missingRequiredArgs(inputSchema: Record<string, unknown>, args: Record<string, unknown>): string[];
67
+ /**
68
+ * Mount the runtime-served MCP surface at `/app/mcp` (ADR 0067). Returns a single `*`-method route
69
+ * (mounted unconditionally, like `/app/agent`); the handler bridges the host request/response seam
70
+ * to the SDK's Web-Standard Streamable-HTTP transport, maintaining one MCP session per
71
+ * `mcp-session-id`. The tool projection reuses the SAME OpenAPI enumeration and dispatch path as
72
+ * `mountApi`, so a tool call is equivalent to the corresponding HTTP call.
73
+ */
74
+ export declare function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]): McpHandle;
75
+ export {};