@nanobpm/urban 0.83.0 → 0.85.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.
Files changed (32) hide show
  1. package/dist/runtime/core/host.d.ts +87 -1
  2. package/dist/runtime/core/host.js +4 -0
  3. package/dist/runtime/core/modules/api.d.ts +5 -0
  4. package/dist/runtime/core/modules/api.js +2 -2
  5. package/dist/runtime/core/modules/mcp.d.ts +57 -0
  6. package/dist/runtime/core/modules/mcp.js +709 -0
  7. package/dist/runtime/core/modules/surfaces.js +11 -0
  8. package/dist/runtime/engine/nanosdk.d.ts +41 -1
  9. package/dist/runtime/engine/nanosdk.js +118 -0
  10. package/dist/runtime/index.d.ts +1 -1
  11. package/package.json +2 -1
  12. package/src/runtime/core/host.ts +89 -0
  13. package/src/runtime/core/modules/actions.test.ts +4 -0
  14. package/src/runtime/core/modules/agent.test.ts +4 -0
  15. package/src/runtime/core/modules/api.test.ts +4 -0
  16. package/src/runtime/core/modules/api.ts +2 -2
  17. package/src/runtime/core/modules/cancel.test.ts +4 -0
  18. package/src/runtime/core/modules/connectors.test.ts +4 -0
  19. package/src/runtime/core/modules/datasource.migrations.test.ts +4 -0
  20. package/src/runtime/core/modules/instance-tracking.test.ts +4 -0
  21. package/src/runtime/core/modules/mcp.test.ts +687 -0
  22. package/src/runtime/core/modules/mcp.ts +814 -0
  23. package/src/runtime/core/modules/pages.test.ts +4 -0
  24. package/src/runtime/core/modules/surfaces.test.ts +24 -0
  25. package/src/runtime/core/modules/surfaces.ts +11 -0
  26. package/src/runtime/core/modules/triggers.test.ts +4 -0
  27. package/src/runtime/core/modules/workers.test.ts +4 -0
  28. package/src/runtime/core/runtime.extensions.test.ts +6 -0
  29. package/src/runtime/core/runtime.test.ts +6 -0
  30. package/src/runtime/engine/nanosdk.test.ts +103 -0
  31. package/src/runtime/engine/nanosdk.ts +152 -0
  32. package/src/runtime/index.ts +4 -0
@@ -0,0 +1,687 @@
1
+ // Red/Green tests for the runtime-served MCP surface (ADR 0067, Slice 1). They drive real
2
+ // JSON-RPC over the mounted `/app/mcp` route through the SDK transport — an initialize/tools-list/
3
+ // tools-call handshake against a booted app — and assert: every non-excluded read-only app
4
+ // operation projects to a tool whose input schema matches the spec; a mutating operation is
5
+ // withheld; a tool call flows through the SAME `mountApi` delegate + validation as the HTTP call;
6
+ // the framework debug tools return engine truth and ADR 0065 projection data; and the system-brief
7
+ // resource + orientation prompt are served. Access gating (loopback-only, flag) is covered too.
8
+
9
+ import { test } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { createNodeHost } from "../../adapters/node.ts";
15
+ import { createLogger } from "../logger.ts";
16
+ import type { AppApi, RuntimeContext } from "../context.ts";
17
+ import type { AppManifest } from "../manifest.ts";
18
+ import type {
19
+ EngineClient,
20
+ HostContext,
21
+ HttpRequest,
22
+ HttpResponse,
23
+ ProcessInstanceSnapshot,
24
+ SqliteDb,
25
+ } from "../host.ts";
26
+ import { makeRouter } from "../router.ts";
27
+ import { mountApi } from "./api.ts";
28
+ import { makeGateway } from "./gateway.ts";
29
+ import { DataLayer, type ProvisionedSource } from "./datasource.ts";
30
+ import { InstanceStateStore } from "./instance-state-store.ts";
31
+ import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
32
+ import { evictExcessSessions, isLoopbackRequest, missingRequiredArgs, mountMcp, newSessionId, readMcpConfig } from "./mcp.ts";
33
+
34
+ // ---- fixtures ---------------------------------------------------------------------------------
35
+
36
+ const SPEC = JSON.stringify({
37
+ openapi: "3.0.0",
38
+ components: {
39
+ schemas: {
40
+ Invoice: {
41
+ type: "object",
42
+ properties: { id: { type: "string" }, amount: { type: "integer", minimum: 1 } },
43
+ required: ["id", "amount"],
44
+ additionalProperties: false,
45
+ },
46
+ },
47
+ },
48
+ paths: {
49
+ "/invoices": {
50
+ get: {
51
+ operationId: "listInvoices",
52
+ summary: "List invoices",
53
+ parameters: [{ name: "limit", in: "query", required: true, schema: { type: "integer", minimum: 1 } }],
54
+ responses: { "200": {} },
55
+ },
56
+ post: {
57
+ operationId: "createInvoice",
58
+ requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/Invoice" } } } },
59
+ responses: { "201": {} },
60
+ },
61
+ },
62
+ "/invoices/{id}": {
63
+ get: {
64
+ operationId: "getInvoice",
65
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
66
+ responses: { "200": {} },
67
+ },
68
+ },
69
+ },
70
+ });
71
+
72
+ const SYSTEM_BRIEF = JSON.stringify({ app: "T", processes: [] });
73
+
74
+ const SPEC_PATH = "/app/openapi.json";
75
+ const BRIEF_PATH = "/app/nano-generated/system-brief.json";
76
+
77
+ function fakeEngine(overrides: Partial<EngineClient> = {}): EngineClient {
78
+ return {
79
+ deployResources: async () => ({ deployed: 0 }),
80
+ createInstance: async () => ({ processInstanceKey: "pi" }),
81
+ cancelInstance: async () => {},
82
+ publishMessage: async () => {},
83
+ searchUserTasks: async () => [],
84
+ openUserTasks: async () => [],
85
+ getForm: async () => null,
86
+ searchProcessInstances: async () => [],
87
+ searchElementInstances: async () => [],
88
+ searchElementInstanceWaitStates: async () => [],
89
+ getElementInstance: async () => null,
90
+ searchIncidents: async () => [],
91
+ resolveIncident: async () => {},
92
+ updateJobRetries: async () => {},
93
+ setVariables: async () => {},
94
+ completeUserTask: async () => {},
95
+ registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
96
+ close: async () => {},
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ interface Harness {
102
+ router: (r: HttpRequest) => Promise<HttpResponse>;
103
+ imported: string[];
104
+ }
105
+
106
+ function buildHarness(opts: {
107
+ modules?: Record<string, Record<string, unknown>>;
108
+ engine?: EngineClient;
109
+ data?: DataLayer;
110
+ env?: (v: string) => string | undefined;
111
+ manifestExtra?: Record<string, unknown>;
112
+ briefExists?: boolean;
113
+ spec?: string;
114
+ } = {}): Harness {
115
+ const modules = opts.modules ?? {};
116
+ const engine = opts.engine ?? fakeEngine();
117
+ const data = opts.data ?? new DataLayer(new Map(), undefined, {});
118
+ const env = opts.env ?? (() => undefined);
119
+ const imported: string[] = [];
120
+ const briefExists = opts.briefExists ?? true;
121
+ const spec = opts.spec ?? SPEC;
122
+ const host: HostContext = {
123
+ runtime: "node",
124
+ env,
125
+ readTextFile: async (p: string) => {
126
+ if (p === BRIEF_PATH) return SYSTEM_BRIEF;
127
+ return spec;
128
+ },
129
+ listDir: async () => [],
130
+ exists: async (p: string) => (p === BRIEF_PATH ? briefExists : false),
131
+ openSqlite: () => {
132
+ throw new Error("sqlite not used in this harness");
133
+ },
134
+ importModule: (path: string) => {
135
+ imported.push(path);
136
+ const mod = modules[path];
137
+ if (!mod) return Promise.reject(new Error(`no module at ${path}`));
138
+ return Promise.resolve(mod);
139
+ },
140
+ serveHttp: async () => ({ port: 0, stop: async () => {} }),
141
+ now: () => 0,
142
+ log: () => {},
143
+ };
144
+ const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
145
+ Reflect.set(manifest, "api", { spec: "openapi.json" });
146
+ if (opts.manifestExtra) for (const [k, v] of Object.entries(opts.manifestExtra)) Reflect.set(manifest, k, v);
147
+ const app: AppApi = {
148
+ manifest: { schemaVersion: 1, id: "t", name: "T" },
149
+ data,
150
+ engine,
151
+ env: () => undefined,
152
+ now: () => 0,
153
+ wait: () => Promise.resolve(),
154
+ log: createLogger(() => {}),
155
+ };
156
+ const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
157
+ const handle = mountMcp(ctx, app, mountApi(ctx, app).routes);
158
+ const router = makeRouter(handle.routes);
159
+ return { router: (r) => Promise.resolve(router(r)), imported };
160
+ }
161
+
162
+ // ---- MCP JSON-RPC driver ----------------------------------------------------------------------
163
+
164
+ function mcpPost(body: unknown, headers: Record<string, string> = {}): HttpRequest {
165
+ const text = JSON.stringify(body);
166
+ return {
167
+ method: "POST",
168
+ path: "/app/mcp",
169
+ query: new URLSearchParams(),
170
+ headers: new Headers({
171
+ "content-type": "application/json",
172
+ accept: "application/json, text/event-stream",
173
+ host: "localhost",
174
+ ...headers,
175
+ }),
176
+ text: () => Promise.resolve(text),
177
+ };
178
+ }
179
+
180
+ interface Session {
181
+ sessionId: string;
182
+ protocolVersion: string;
183
+ headers: Record<string, string>;
184
+ }
185
+
186
+ async function initialize(router: Harness["router"]): Promise<Session> {
187
+ const res = await router(
188
+ mcpPost({
189
+ jsonrpc: "2.0",
190
+ id: 0,
191
+ method: "initialize",
192
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } },
193
+ }),
194
+ );
195
+ assert.equal(res.status ?? 200, 200, `initialize should be 200, got ${res.status}: ${res.body}`);
196
+ const sessionId = res.headers?.["mcp-session-id"];
197
+ assert.ok(sessionId, "initialize must assign an mcp-session-id");
198
+ const parsed = JSON.parse(res.body ?? "{}");
199
+ const protocolVersion = parsed.result?.protocolVersion ?? "2025-06-18";
200
+ const headers = { "mcp-session-id": sessionId, "mcp-protocol-version": protocolVersion };
201
+ await router(mcpPost({ jsonrpc: "2.0", method: "notifications/initialized" }, headers));
202
+ return { sessionId, protocolVersion, headers };
203
+ }
204
+
205
+ async function rpc(router: Harness["router"], session: Session, method: string, params: unknown, id = 1): Promise<{ result?: Record<string, unknown>; error?: { code: number; message: string } }> {
206
+ const res = await router(mcpPost({ jsonrpc: "2.0", id, method, params }, session.headers));
207
+ assert.equal(res.status ?? 200, 200, `${method} should be 200, got ${res.status}: ${res.body}`);
208
+ return JSON.parse(res.body ?? "{}");
209
+ }
210
+
211
+ async function connect(router: Harness["router"]): Promise<Session> {
212
+ return initialize(router);
213
+ }
214
+
215
+ function toolContentText(result: Record<string, unknown> | undefined): string {
216
+ assert.ok(result, "tools/call must return a result");
217
+ const content = result.content;
218
+ assert.ok(Array.isArray(content) && content.length > 0, "tool result must carry content");
219
+ const first = content[0];
220
+ assert.ok(first && typeof first === "object" && "text" in first, "tool content must be a text block");
221
+ return String(Reflect.get(first, "text"));
222
+ }
223
+
224
+ // ---- handshake --------------------------------------------------------------------------------
225
+
226
+ test("the endpoint speaks the MCP initialize handshake for a booted app", async () => {
227
+ const { router } = buildHarness();
228
+ const session = await connect(router);
229
+ assert.ok(session.sessionId.length > 0);
230
+ });
231
+
232
+ test("a POST without a session and without an initialize request is a 400", async () => {
233
+ const { router } = buildHarness();
234
+ const res = await router(mcpPost({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }));
235
+ assert.equal(res.status, 400);
236
+ });
237
+
238
+ // ---- tool projection --------------------------------------------------------------------------
239
+
240
+ test("every read-only app operation projects to a tool; mutating operations are withheld", async () => {
241
+ const { router } = buildHarness();
242
+ const session = await connect(router);
243
+ const list = await rpc(router, session, "tools/list", {});
244
+ const tools = list.result?.tools;
245
+ assert.ok(Array.isArray(tools));
246
+ const names = tools.map((t) => Reflect.get(t, "name"));
247
+ assert.ok(names.includes("getInvoice"), "read-only getInvoice must be a tool");
248
+ assert.ok(names.includes("listInvoices"), "read-only listInvoices must be a tool");
249
+ assert.ok(!names.includes("createInvoice"), "mutating createInvoice must NOT be a tool in this slice");
250
+ });
251
+
252
+ test("a projected tool's input schema matches the spec's params", async () => {
253
+ const { router } = buildHarness();
254
+ const session = await connect(router);
255
+ const list = await rpc(router, session, "tools/list", {});
256
+ const tools = list.result?.tools;
257
+ assert.ok(Array.isArray(tools));
258
+ const getInvoice = tools.find((t) => Reflect.get(t, "name") === "getInvoice");
259
+ const schema = Reflect.get(getInvoice ?? {}, "inputSchema");
260
+ assert.deepEqual(schema, {
261
+ type: "object",
262
+ properties: { id: { type: "string" } },
263
+ required: ["id"],
264
+ });
265
+ const listInvoices = tools.find((t) => Reflect.get(t, "name") === "listInvoices");
266
+ const listSchema = Reflect.get(listInvoices ?? {}, "inputSchema");
267
+ assert.deepEqual(listSchema, {
268
+ type: "object",
269
+ properties: { limit: { type: "integer", minimum: 1 } },
270
+ required: ["limit"],
271
+ });
272
+ });
273
+
274
+ test("a tool call routes through the SAME mountApi delegate as the HTTP call", async () => {
275
+ let seenParams: Record<string, unknown> | undefined;
276
+ const { router, imported } = buildHarness({
277
+ modules: {
278
+ "/app/operations/getInvoice": {
279
+ default: (input: { params: Record<string, unknown> }) => {
280
+ seenParams = input.params;
281
+ return { body: { id: "42", amount: 7 } };
282
+ },
283
+ },
284
+ },
285
+ });
286
+ const session = await connect(router);
287
+ const call = await rpc(router, session, "tools/call", { name: "getInvoice", arguments: { id: "42" } });
288
+ const text = toolContentText(call.result);
289
+ assert.deepEqual(JSON.parse(text), { id: "42", amount: 7 });
290
+ assert.deepEqual(seenParams, { id: "42" }, "the delegate must receive the validated path param");
291
+ assert.ok(imported.includes("/app/operations/getInvoice"), "the SAME api delegate module must be imported");
292
+ assert.notEqual(call.result?.isError, true);
293
+ });
294
+
295
+ test("a tool call with schema-invalid input fails validation identically to the HTTP call", async () => {
296
+ const { router } = buildHarness({
297
+ modules: {
298
+ "/app/operations/listInvoices": { default: () => ({ body: [] }) },
299
+ },
300
+ });
301
+ const session = await connect(router);
302
+ // limit=0 violates `minimum: 1` — the reused validator must reject before the delegate runs.
303
+ const call = await rpc(router, session, "tools/call", { name: "listInvoices", arguments: { limit: 0 } });
304
+ assert.equal(call.result?.isError, true, "a validation failure must surface as an error tool result");
305
+ const text = toolContentText(call.result);
306
+ assert.match(text, /validation failed/i);
307
+ });
308
+
309
+ test("a tool call missing a required path parameter is rejected before dispatch", async () => {
310
+ let delegateRan = false;
311
+ const { router } = buildHarness({
312
+ modules: {
313
+ "/app/operations/getInvoice": {
314
+ default: () => {
315
+ delegateRan = true;
316
+ return { body: { id: "x" } };
317
+ },
318
+ },
319
+ },
320
+ });
321
+ const session = await connect(router);
322
+ // `getInvoice` declares a required `{id}` path param; omitting it must fail validation up front
323
+ // rather than silently substitute an empty string and 404 (or mis-route to a different shape).
324
+ const call = await rpc(router, session, "tools/call", { name: "getInvoice", arguments: {} });
325
+ assert.equal(call.result?.isError, true, "a missing required path param must surface as an error result");
326
+ const text = toolContentText(call.result);
327
+ assert.match(text, /missing required path parameter/i);
328
+ assert.match(text, /\bid\b/);
329
+ assert.equal(delegateRan, false, "the delegate must not run for a missing-path-param tool call");
330
+
331
+ // A whitespace-only path arg is blank under the shared trimmed-presence rule: it would `fillPath`
332
+ // to a blank segment and mis-route, so it is rejected the same as an omitted param.
333
+ const blankCall = await rpc(router, session, "tools/call", { name: "getInvoice", arguments: { id: " " } });
334
+ assert.equal(blankCall.result?.isError, true, "a whitespace-only path param must also be rejected");
335
+ assert.match(toolContentText(blankCall.result), /missing required path parameter/i);
336
+ assert.equal(delegateRan, false, "the delegate must not run for a blank-path-param tool call");
337
+ });
338
+
339
+ // ---- framework debug tools --------------------------------------------------------------------
340
+
341
+ test("the framework debug tools appear and return engine truth", async () => {
342
+ const snapshot: ProcessInstanceSnapshot = {
343
+ processInstanceKey: "pi-1",
344
+ state: "ACTIVE",
345
+ };
346
+ const engine = fakeEngine({ searchProcessInstances: async () => [snapshot] });
347
+ const { router } = buildHarness({ engine });
348
+ const session = await connect(router);
349
+ const list = await rpc(router, session, "tools/list", {});
350
+ const tools = list.result?.tools;
351
+ assert.ok(Array.isArray(tools));
352
+ const names = tools.map((t) => Reflect.get(t, "name"));
353
+ assert.ok(names.includes("urban_debug_search_process_instances"));
354
+ assert.ok(names.includes("urban_debug_search_element_instance_wait_states"));
355
+ assert.ok(names.includes("urban_debug_get_element_instance"));
356
+ assert.ok(names.includes("urban_debug_search_user_tasks"));
357
+ assert.ok(names.includes("urban_debug_instance_state"));
358
+ assert.ok(names.includes("urban_debug_open_user_tasks"));
359
+
360
+ const call = await rpc(router, session, "tools/call", {
361
+ name: "urban_debug_search_process_instances",
362
+ arguments: { state: "ACTIVE" },
363
+ });
364
+ const text = toolContentText(call.result);
365
+ assert.deepEqual(JSON.parse(text), [snapshot]);
366
+ });
367
+
368
+ test("an app operationId in the reserved urban_debug_ namespace can't shadow a framework tool", async () => {
369
+ // A `urban_debug_*` operationId is a legal safe path segment, so an app could declare one. Because
370
+ // `CallTool` resolves framework debug tools before app ops, an unreserved namespace would let such
371
+ // an op be shadowed by (and duplicate the name of) the framework tool. The reservation must drop
372
+ // the colliding app op before projection so `tools/list` stays duplicate-free and the name still
373
+ // reaches the framework debug tool.
374
+ const collidingSpec = JSON.stringify({
375
+ openapi: "3.0.0",
376
+ paths: {
377
+ "/shadow": {
378
+ get: {
379
+ operationId: "urban_debug_search_process_instances",
380
+ summary: "malicious/accidental shadow of a framework tool",
381
+ responses: { "200": {} },
382
+ },
383
+ },
384
+ },
385
+ });
386
+ const snapshot: ProcessInstanceSnapshot = { processInstanceKey: "pi-1", state: "ACTIVE" };
387
+ const engine = fakeEngine({ searchProcessInstances: async () => [snapshot] });
388
+ const { router } = buildHarness({ engine, spec: collidingSpec });
389
+ const session = await connect(router);
390
+
391
+ const list = await rpc(router, session, "tools/list", {});
392
+ const tools = list.result?.tools;
393
+ assert.ok(Array.isArray(tools));
394
+ const names = tools.map((t) => Reflect.get(t, "name"));
395
+ const shadows = names.filter((n) => n === "urban_debug_search_process_instances");
396
+ assert.equal(shadows.length, 1, "the reserved-namespace app op must not appear as a duplicate tool");
397
+
398
+ // The name resolves to the framework debug tool (engine truth), not the dropped app op.
399
+ const call = await rpc(router, session, "tools/call", {
400
+ name: "urban_debug_search_process_instances",
401
+ arguments: { state: "ACTIVE" },
402
+ });
403
+ const text = toolContentText(call.result);
404
+ assert.deepEqual(JSON.parse(text), [snapshot]);
405
+ });
406
+
407
+ test("the projection debug tools read the ADR 0065 canonical stores", async () => {
408
+ const dir = await mkdtemp(join(tmpdir(), "urban-mcp-proj-"));
409
+ const host = createNodeHost({ cwd: dir, log: () => {} });
410
+ const db: SqliteDb = host.openSqlite(join(dir, "test.db"));
411
+ try {
412
+ new InstanceStateStore(db, { clock: { now: () => 0 } }).ensureSchema();
413
+ new InstanceStateStore(db, { clock: { now: () => 0 } }).recordState("pi-7", "ACTIVE");
414
+ const openStore = new OpenUserTasksStore(db, { clock: { now: () => 0 } });
415
+ openStore.ensureSchema();
416
+ openStore.recordOpenTask("pi-7", { userTaskKey: "ut-1", elementId: "Task_A" });
417
+
418
+ const source: ProvisionedSource = {
419
+ name: "default",
420
+ driver: "sqlite",
421
+ db,
422
+ source: makeGateway(db),
423
+ migrationsApplied: [],
424
+ close: () => {},
425
+ };
426
+ const data = new DataLayer(new Map([["default", source]]), "default", {});
427
+ const { router } = buildHarness({ data });
428
+ const session = await connect(router);
429
+
430
+ const stateCall = await rpc(router, session, "tools/call", {
431
+ name: "urban_debug_instance_state",
432
+ arguments: { processInstanceKey: "pi-7" },
433
+ });
434
+ const state = JSON.parse(toolContentText(stateCall.result));
435
+ assert.equal(Reflect.get(state, "state"), "ACTIVE");
436
+
437
+ const tasksCall = await rpc(router, session, "tools/call", {
438
+ name: "urban_debug_open_user_tasks",
439
+ arguments: { processInstanceKey: "pi-7" },
440
+ });
441
+ const tasks = JSON.parse(toolContentText(tasksCall.result));
442
+ assert.ok(Array.isArray(tasks) && tasks.length === 1);
443
+ assert.equal(Reflect.get(tasks[0], "userTaskKey"), "ut-1");
444
+ } finally {
445
+ db.close();
446
+ await rm(dir, { recursive: true, force: true });
447
+ }
448
+ });
449
+
450
+ // ---- resource + prompt ------------------------------------------------------------------------
451
+
452
+ test("a debug tool call missing a required parameter is rejected before its handler runs", async () => {
453
+ let handlerRan = false;
454
+ const engine = fakeEngine({
455
+ getElementInstance: async () => {
456
+ handlerRan = true;
457
+ return null;
458
+ },
459
+ });
460
+ const { router } = buildHarness({ engine });
461
+ const session = await connect(router);
462
+ // `urban_debug_get_element_instance` declares `elementInstanceKey` required; omitting it must
463
+ // fail validation up front rather than substitute an empty string and query the engine with it.
464
+ const call = await rpc(router, session, "tools/call", {
465
+ name: "urban_debug_get_element_instance",
466
+ arguments: {},
467
+ });
468
+ assert.equal(call.result?.isError, true, "a missing required debug-tool arg must surface as an error result");
469
+ const text = toolContentText(call.result);
470
+ assert.match(text, /missing required parameter/i);
471
+ assert.match(text, /elementInstanceKey/);
472
+ assert.equal(handlerRan, false, "the debug handler must not run for a missing required-arg call");
473
+
474
+ // An empty string is treated as absent too — it is exactly the masked-invalid-call the guard closes.
475
+ const emptyCall = await rpc(router, session, "tools/call", {
476
+ name: "urban_debug_get_element_instance",
477
+ arguments: { elementInstanceKey: "" },
478
+ });
479
+ assert.equal(emptyCall.result?.isError, true, "an empty required arg must also be rejected");
480
+ assert.equal(handlerRan, false, "the debug handler must not run for an empty required-arg call");
481
+
482
+ // A whitespace-only value is absent under the same trimmed-presence rule `presentFormIdentifier`
483
+ // enforces — it would otherwise slip through and query the engine with a blank identifier.
484
+ const blankCall = await rpc(router, session, "tools/call", {
485
+ name: "urban_debug_get_element_instance",
486
+ arguments: { elementInstanceKey: " " },
487
+ });
488
+ assert.equal(blankCall.result?.isError, true, "a whitespace-only required arg must also be rejected");
489
+ assert.match(toolContentText(blankCall.result), /missing required parameter/i);
490
+ assert.equal(handlerRan, false, "the debug handler must not run for a whitespace-only required-arg call");
491
+ });
492
+
493
+ test("missingRequiredArgs treats a present non-string value as provided, not missing", () => {
494
+ // Presence is decided solely by isBlankArg, so a required numeric/boolean/object arg that is
495
+ // actually supplied must NOT be reported missing — the earlier `typeof value !== "string"` guard
496
+ // wrongly rejected every non-string required input.
497
+ const schema = { required: ["count", "enabled", "filter"] };
498
+ assert.deepEqual(
499
+ missingRequiredArgs(schema, { count: 0, enabled: false, filter: { state: "ACTIVE" } }),
500
+ [],
501
+ "present non-string required args (including falsy 0/false) must be accepted",
502
+ );
503
+
504
+ // Absent (undefined/null) and blank/whitespace-only string values remain rejected.
505
+ assert.deepEqual(
506
+ missingRequiredArgs(schema, { count: undefined, enabled: null, filter: " " }),
507
+ ["count", "enabled", "filter"],
508
+ "undefined, null, and whitespace-only string required args must be reported missing",
509
+ );
510
+
511
+ // A required key entirely omitted from args is missing; a non-empty string is provided.
512
+ assert.deepEqual(missingRequiredArgs({ required: ["id"] }, {}), ["id"]);
513
+ assert.deepEqual(missingRequiredArgs({ required: ["id"] }, { id: "x" }), []);
514
+ });
515
+
516
+ test("the system brief is served as an MCP resource", async () => {
517
+ const { router } = buildHarness();
518
+ const session = await connect(router);
519
+ const list = await rpc(router, session, "resources/list", {});
520
+ const resources = list.result?.resources;
521
+ assert.ok(Array.isArray(resources) && resources.length >= 1);
522
+ const uri = Reflect.get(resources[0], "uri");
523
+ const read = await rpc(router, session, "resources/read", { uri });
524
+ const contents = read.result?.contents;
525
+ assert.ok(Array.isArray(contents) && contents.length === 1);
526
+ assert.equal(Reflect.get(contents[0], "text"), SYSTEM_BRIEF);
527
+ });
528
+
529
+ test("the orientation prompt is registered and served", async () => {
530
+ const { router } = buildHarness();
531
+ const session = await connect(router);
532
+ const list = await rpc(router, session, "prompts/list", {});
533
+ const prompts = list.result?.prompts;
534
+ assert.ok(Array.isArray(prompts) && prompts.length >= 1);
535
+ const name = Reflect.get(prompts[0], "name");
536
+ assert.equal(name, "urban-orientation");
537
+ const get = await rpc(router, session, "prompts/get", { name });
538
+ const messages = get.result?.messages;
539
+ assert.ok(Array.isArray(messages) && messages.length >= 1);
540
+ });
541
+
542
+ // ---- access gating ----------------------------------------------------------------------------
543
+
544
+ test("readMcpConfig defaults ON for loopback and honours env/manifest flags", () => {
545
+ const on = readMcpConfig({}, () => undefined);
546
+ assert.equal(on.enabled, true);
547
+ assert.equal(on.allowRemote, false);
548
+
549
+ const offByEnv = readMcpConfig({}, (n) => (n === "URBAN_MCP_ENABLED" ? "false" : undefined));
550
+ assert.equal(offByEnv.enabled, false);
551
+
552
+ const remoteByEnv = readMcpConfig({}, (n) => (n === "URBAN_MCP_ALLOW_REMOTE" ? "true" : undefined));
553
+ assert.equal(remoteByEnv.allowRemote, true);
554
+
555
+ const manifest: Record<string, unknown> = { mcp: { enabled: false } };
556
+ assert.equal(readMcpConfig(manifest, () => undefined).enabled, false);
557
+
558
+ // Non-boolean manifest values are ignored (strict type check, like `readApiBinding`): they fall
559
+ // back to the defaults rather than coercing — e.g. the string "false" is NOT a boolean `false`.
560
+ const stringy: Record<string, unknown> = { mcp: { enabled: "false", allowRemote: "true" } };
561
+ const resolved = readMcpConfig(stringy, () => undefined);
562
+ assert.equal(resolved.enabled, true, "a non-boolean `enabled` must fall back to the default (ON)");
563
+ assert.equal(resolved.allowRemote, false, "a non-boolean `allowRemote` must fall back to the default (OFF)");
564
+ });
565
+
566
+ test("isLoopbackRequest recognises loopback hosts and rejects remote ones", () => {
567
+ const mk = (host?: string): HttpRequest => ({
568
+ method: "POST",
569
+ path: "/app/mcp",
570
+ query: new URLSearchParams(),
571
+ headers: new Headers(host ? { host } : {}),
572
+ text: () => Promise.resolve(""),
573
+ });
574
+ assert.equal(isLoopbackRequest(mk("localhost:3000")), true);
575
+ assert.equal(isLoopbackRequest(mk("127.0.0.1:3000")), true);
576
+ assert.equal(isLoopbackRequest(mk("[::1]:3000")), true);
577
+ assert.equal(isLoopbackRequest(mk()), true);
578
+ assert.equal(isLoopbackRequest(mk("example.com")), false);
579
+ });
580
+
581
+ test("a disabled surface answers 404 and a remote caller is refused 403", async () => {
582
+ const disabled = buildHarness({ env: (v) => (v === "URBAN_MCP_ENABLED" ? "false" : undefined) });
583
+ const res404 = await disabled.router(mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }));
584
+ assert.equal(res404.status, 404);
585
+
586
+ const loopbackOnly = buildHarness();
587
+ const res403 = await loopbackOnly.router(
588
+ mcpPost(
589
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
590
+ { host: "example.com" },
591
+ ),
592
+ );
593
+ assert.equal(res403.status, 403);
594
+ });
595
+
596
+ test("a loopback-only surface bound to all interfaces refuses even a loopback Host", async () => {
597
+ // With bind=all the client-controlled `Host` header can't prove loopback, so every caller is
598
+ // refused unless remote access is explicitly opted in.
599
+ const boundAll = buildHarness({ env: (v) => (v === "URBAN_BIND" ? "all" : undefined) });
600
+ const refused = await boundAll.router(
601
+ mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, { host: "localhost" }),
602
+ );
603
+ assert.equal(refused.status, 403, "a loopback Host must not bypass the gate when bound to all interfaces");
604
+
605
+ // Manifest `network.bind: "all"` is honoured the same way.
606
+ const boundAllManifest = buildHarness({ manifestExtra: { network: { bind: "all" } } });
607
+ const refused2 = await boundAllManifest.router(
608
+ mcpPost({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, { host: "127.0.0.1" }),
609
+ );
610
+ assert.equal(refused2.status, 403);
611
+
612
+ // Opting into remote access lets the all-interfaces bind serve again.
613
+ const remote = buildHarness({
614
+ env: (v) => (v === "URBAN_BIND" ? "all" : v === "URBAN_MCP_ALLOW_REMOTE" ? "true" : undefined),
615
+ });
616
+ const session = await connect(remote.router);
617
+ assert.ok(session.sessionId.length > 0, "allowRemote must let an all-interfaces bind serve");
618
+ });
619
+
620
+ test("newSessionId mints a fresh id and never throws when Web Crypto is absent", () => {
621
+ // With Web Crypto present it delegates to randomUUID (unique across calls).
622
+ const a = newSessionId();
623
+ const b = newSessionId();
624
+ assert.ok(a.length > 0 && b.length > 0, "a session id must be non-empty");
625
+ assert.notEqual(a, b, "successive session ids must differ");
626
+
627
+ // Core treats Web Crypto as optional: in a host without `crypto` the generator must fall back
628
+ // rather than throw (a bare `crypto.randomUUID()` would throw a ReferenceError here). Prove the
629
+ // fallback branch actually ran by removing `crypto` and asserting the `sess-` prefix it mints.
630
+ const savedCrypto = Reflect.getOwnPropertyDescriptor(globalThis, "crypto");
631
+ assert.notEqual(
632
+ savedCrypto?.configurable,
633
+ false,
634
+ "test requires globalThis.crypto to be configurable to simulate a host without Web Crypto",
635
+ );
636
+ try {
637
+ Reflect.defineProperty(globalThis, "crypto", { value: undefined, configurable: true, writable: true });
638
+ assert.equal(globalThis.crypto, undefined, "crypto must actually be absent to exercise the fallback branch");
639
+ const fallback = newSessionId();
640
+ assert.ok(fallback.startsWith("sess-"), "the id must come from the time+random fallback when crypto is unavailable");
641
+ assert.notEqual(fallback, newSessionId(), "fallback ids must still differ");
642
+ } finally {
643
+ if (savedCrypto) Reflect.defineProperty(globalThis, "crypto", savedCrypto);
644
+ else Reflect.deleteProperty(globalThis, "crypto");
645
+ }
646
+ });
647
+
648
+ test("evictExcessSessions caps the session map by evicting least-recently-used pairs, closing each", async () => {
649
+ // Guards the defect class the reviewer flagged: an unbounded `sessions` map is a memory/handle
650
+ // exhaustion vector. A hard LRU cap must bound the map no matter the client's `initialize` cadence.
651
+ const closed: string[] = [];
652
+ const sessions = new Map<string, { id: string }>();
653
+ for (const id of ["a", "b", "c", "d"]) sessions.set(id, { id });
654
+
655
+ await evictExcessSessions(sessions, 2, async (s) => {
656
+ closed.push(s.id);
657
+ });
658
+
659
+ assert.equal(sessions.size, 2, "the map must be trimmed down to the cap");
660
+ assert.deepEqual([...sessions.keys()], ["c", "d"], "the two most-recently-inserted survive");
661
+ assert.deepEqual(closed, ["a", "b"], "evicted (LRU) sessions must be closed, in eviction order");
662
+
663
+ // Under the cap it is a no-op and closes nothing.
664
+ const noop: string[] = [];
665
+ await evictExcessSessions(sessions, 5, async (s) => {
666
+ noop.push(s.id);
667
+ });
668
+ assert.equal(sessions.size, 2);
669
+ assert.deepEqual(noop, [], "nothing is evicted while under the cap");
670
+ });
671
+
672
+ test("evictExcessSessions keeps closing when a victim's close() rejects (best-effort)", async () => {
673
+ // A victim whose transport is already torn down must not abort eviction of the rest, or a single
674
+ // dead session could wedge the cap and re-open the unbounded-growth hole.
675
+ const closed: string[] = [];
676
+ const sessions = new Map<string, { id: string }>();
677
+ for (const id of ["x", "y", "z"]) sessions.set(id, { id });
678
+
679
+ await evictExcessSessions(sessions, 1, async (s) => {
680
+ closed.push(s.id);
681
+ if (s.id === "x") throw new Error("transport already closed");
682
+ });
683
+
684
+ assert.equal(sessions.size, 1);
685
+ assert.deepEqual([...sessions.keys()], ["z"], "the newest entry survives despite a failing close");
686
+ assert.deepEqual(closed, ["x", "y"], "both LRU victims are attempted even though the first rejected");
687
+ });