@nanobpm/urban 0.85.0 → 0.86.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
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.
1
+ // Red/Green tests for the runtime-served MCP surface (ADR 0067). They drive real JSON-RPC over the
2
+ // mounted `/app/mcp` route through the SDK transport — an initialize/tools-list/tools-call handshake
3
+ // against a booted app — and assert: every non-excluded app operation projects to a tool whose
4
+ // input schema matches the spec; an `x-mcp`-excluded operation is withheld; a tool call flows
5
+ // through the SAME `mountApi` delegate + validation as the HTTP call; the framework debug tools
6
+ // (read + mutating) return engine truth and ADR 0065 projection data; a MUTATING tool refuses
7
+ // without the shared-secret guard credential and succeeds with it (while read tools stay open on
8
+ // loopback); the spec↔tool parity guard fails on injected skew; and the system-brief resource +
9
+ // orientation prompt are served. Access gating (loopback-only, flag) is covered too.
8
10
 
9
11
  import { test } from "node:test";
10
12
  import assert from "node:assert/strict";
@@ -29,7 +31,8 @@ import { makeGateway } from "./gateway.ts";
29
31
  import { DataLayer, type ProvisionedSource } from "./datasource.ts";
30
32
  import { InstanceStateStore } from "./instance-state-store.ts";
31
33
  import { OpenUserTasksStore } from "./open-user-tasks-store.ts";
32
- import { evictExcessSessions, isLoopbackRequest, missingRequiredArgs, mountMcp, newSessionId, readMcpConfig } from "./mcp.ts";
34
+ import { evictExcessSessions, isLoopbackRequest, missingRequiredArgs, mountMcp, newSessionId, readHeader, readMcpConfig } from "./mcp.ts";
35
+ import { collectMcpToolProjection, diffMcpToolProjection, parseSpec } from "../../../openapi/spec.ts";
33
36
 
34
37
  // ---- fixtures ---------------------------------------------------------------------------------
35
38
 
@@ -98,9 +101,16 @@ function fakeEngine(overrides: Partial<EngineClient> = {}): EngineClient {
98
101
  };
99
102
  }
100
103
 
104
+ interface LogEntry {
105
+ level: "debug" | "info" | "warn" | "error";
106
+ msg: string;
107
+ fields?: Record<string, unknown>;
108
+ }
109
+
101
110
  interface Harness {
102
111
  router: (r: HttpRequest) => Promise<HttpResponse>;
103
112
  imported: string[];
113
+ logs: LogEntry[];
104
114
  }
105
115
 
106
116
  function buildHarness(opts: {
@@ -119,6 +129,7 @@ function buildHarness(opts: {
119
129
  const imported: string[] = [];
120
130
  const briefExists = opts.briefExists ?? true;
121
131
  const spec = opts.spec ?? SPEC;
132
+ const logs: LogEntry[] = [];
122
133
  const host: HostContext = {
123
134
  runtime: "node",
124
135
  env,
@@ -139,7 +150,9 @@ function buildHarness(opts: {
139
150
  },
140
151
  serveHttp: async () => ({ port: 0, stop: async () => {} }),
141
152
  now: () => 0,
142
- log: () => {},
153
+ log: (level, msg, fields) => {
154
+ logs.push({ level, msg, fields });
155
+ },
143
156
  };
144
157
  const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
145
158
  Reflect.set(manifest, "api", { spec: "openapi.json" });
@@ -156,7 +169,7 @@ function buildHarness(opts: {
156
169
  const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
157
170
  const handle = mountMcp(ctx, app, mountApi(ctx, app).routes);
158
171
  const router = makeRouter(handle.routes);
159
- return { router: (r) => Promise.resolve(router(r)), imported };
172
+ return { router: (r) => Promise.resolve(router(r)), imported, logs };
160
173
  }
161
174
 
162
175
  // ---- MCP JSON-RPC driver ----------------------------------------------------------------------
@@ -237,16 +250,59 @@ test("a POST without a session and without an initialize request is a 400", asyn
237
250
 
238
251
  // ---- tool projection --------------------------------------------------------------------------
239
252
 
240
- test("every read-only app operation projects to a tool; mutating operations are withheld", async () => {
253
+ test("every app operation projects to a tool; read tools are annotated read-only, mutating ones destructive", async () => {
241
254
  const { router } = buildHarness();
242
255
  const session = await connect(router);
243
256
  const list = await rpc(router, session, "tools/list", {});
244
257
  const tools = list.result?.tools;
245
258
  assert.ok(Array.isArray(tools));
259
+ const byName = new Map(tools.map((t) => [Reflect.get(t, "name"), t]));
260
+ assert.ok(byName.has("getInvoice"), "read-only getInvoice must be a tool");
261
+ assert.ok(byName.has("listInvoices"), "read-only listInvoices must be a tool");
262
+ // Slice 3: mutating app operations ARE now projected (guarded by their OpenAPI security at
263
+ // dispatch, or open when the op declares none — matching the HTTP route), no longer withheld.
264
+ assert.ok(byName.has("createInvoice"), "mutating createInvoice must now be a tool (guarded, not withheld)");
265
+
266
+ const readAnn = Reflect.get(byName.get("getInvoice") ?? {}, "annotations");
267
+ assert.equal(Reflect.get(readAnn ?? {}, "readOnlyHint"), true, "a read op must be annotated read-only");
268
+ const mutAnn = Reflect.get(byName.get("createInvoice") ?? {}, "annotations");
269
+ assert.equal(Reflect.get(mutAnn ?? {}, "readOnlyHint"), false, "a mutating op must not be annotated read-only");
270
+ assert.equal(Reflect.get(mutAnn ?? {}, "destructiveHint"), true, "a mutating op must be annotated destructive");
271
+ });
272
+
273
+ test("an x-mcp-excluded operation is withheld from the tool surface while its siblings project", async () => {
274
+ const excludedSpec = JSON.stringify({
275
+ openapi: "3.1.0",
276
+ paths: {
277
+ "/things": {
278
+ get: { operationId: "listThings", summary: "read", responses: { "200": {} } },
279
+ },
280
+ "/things/reindex": {
281
+ post: {
282
+ operationId: "reindexThings",
283
+ summary: "operator-only, withheld",
284
+ "x-mcp": false,
285
+ responses: { "202": {} },
286
+ },
287
+ },
288
+ },
289
+ });
290
+ const { router } = buildHarness({ spec: excludedSpec });
291
+ const session = await connect(router);
292
+ const list = await rpc(router, session, "tools/list", {});
293
+ const tools = list.result?.tools;
294
+ assert.ok(Array.isArray(tools));
246
295
  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");
296
+ assert.ok(names.includes("listThings"), "a non-excluded op must still project");
297
+ assert.ok(!names.includes("reindexThings"), "an x-mcp-excluded op must NOT appear as a tool");
298
+
299
+ // And it is unreachable by name too — CallTool resolves it to neither a debug nor an app tool.
300
+ const call = await router(
301
+ mcpPost({ jsonrpc: "2.0", id: 9, method: "tools/call", params: { name: "reindexThings", arguments: {} } }, session.headers),
302
+ );
303
+ const parsed = JSON.parse(call.body ?? "{}");
304
+ assert.ok(parsed.error, "an excluded op must not be callable");
305
+ assert.match(String(parsed.error.message), /no such tool/i);
250
306
  });
251
307
 
252
308
  test("a projected tool's input schema matches the spec's params", async () => {
@@ -404,6 +460,31 @@ test("an app operationId in the reserved urban_debug_ namespace can't shadow a f
404
460
  assert.deepEqual(JSON.parse(text), [snapshot]);
405
461
  });
406
462
 
463
+ test("the reserved-namespace drop warns once, not on every tools/list and tools/call", async () => {
464
+ // The projected op table is memoized per parsed spec doc, so `collectOperations` + the reserved-
465
+ // namespace filter (which emits this warn) runs once — NOT on every request. Guards against the
466
+ // recompute/log-spam defect class: an app that accidentally declares a `urban_debug_*` op must not
467
+ // spam a warn on each of the many `tools/list`/`tools/call` requests a session makes.
468
+ const collidingSpec = JSON.stringify({
469
+ openapi: "3.0.0",
470
+ paths: {
471
+ "/shadow": {
472
+ get: { operationId: "urban_debug_search_process_instances", responses: { "200": {} } },
473
+ },
474
+ },
475
+ });
476
+ const engine = fakeEngine({ searchProcessInstances: async () => [] });
477
+ const { router, logs } = buildHarness({ engine, spec: collidingSpec });
478
+ const session = await connect(router);
479
+
480
+ await rpc(router, session, "tools/list", {});
481
+ await rpc(router, session, "tools/list", {});
482
+ await rpc(router, session, "tools/call", { name: "urban_debug_search_process_instances", arguments: { state: "ACTIVE" } });
483
+
484
+ const drops = logs.filter((l) => l.msg.includes("reserved framework tool namespace"));
485
+ assert.equal(drops.length, 1, "the reserved-namespace drop must warn exactly once across many requests");
486
+ });
487
+
407
488
  test("the projection debug tools read the ADR 0065 canonical stores", async () => {
408
489
  const dir = await mkdtemp(join(tmpdir(), "urban-mcp-proj-"));
409
490
  const host = createNodeHost({ cwd: dir, log: () => {} });
@@ -447,6 +528,437 @@ test("the projection debug tools read the ADR 0065 canonical stores", async () =
447
528
  }
448
529
  });
449
530
 
531
+ // ---- mutation guard (Slice 3) -----------------------------------------------------------------
532
+
533
+ // A repeated credential header must read the SAME under the MCP guard as under OpenAPI route
534
+ // enforcement. `mountApi`/`toHttpRequest` read it via `Headers.get()`, which comma-joins repeated
535
+ // values; `readHeader` must match that (not pick the first) so the shared-secret guard cannot
536
+ // diverge from route enforcement on a `string[]` header (as Node's raw adapter surfaces it).
537
+ test("readHeader comma-joins a repeated header to match Headers.get() semantics", () => {
538
+ // Baseline: a single string value is returned verbatim; a missing header is undefined.
539
+ assert.equal(readHeader({ "x-nano-key": "abc" }, "X-Nano-Key"), "abc");
540
+ assert.equal(readHeader({}, "X-Nano-Key"), undefined);
541
+ // A repeated header (string[]) is joined with ", " — exactly what `Headers.get()` returns for the
542
+ // reconstructed request in `toHttpRequest`, so the guard reads the identical credential string.
543
+ const joined = readHeader({ "x-nano-key": ["a", "b"] }, "X-Nano-Key");
544
+ const viaHeaders = (() => {
545
+ const h = new Headers();
546
+ for (const v of ["a", "b"]) h.append("X-Nano-Key", v);
547
+ return h.get("X-Nano-Key") ?? undefined;
548
+ })();
549
+ assert.equal(joined, viaHeaders, "readHeader must match Headers.get() for a repeated header");
550
+ });
551
+
552
+ // A spec that declares the app's shared-secret scheme (an apiKey header pointing at an env var via
553
+ // `x-nano-secret-env`) — the credential the framework MUTATING tools require. REUSING the app's
554
+ // existing shared secret, never an MCP-specific synonym (ADR 0067 §4).
555
+ const GUARDED_SPEC = JSON.stringify({
556
+ openapi: "3.1.0",
557
+ components: {
558
+ securitySchemes: {
559
+ appSecret: { type: "apiKey", in: "header", name: "X-Nano-Key", "x-nano-secret-env": "NANO_WEBHOOK_KEY" },
560
+ },
561
+ },
562
+ paths: {
563
+ "/invoices": {
564
+ get: { operationId: "listInvoices", summary: "List invoices", responses: { "200": {} } },
565
+ },
566
+ },
567
+ });
568
+
569
+ const SECRET = "s3cr3t-value";
570
+ const guardEnv = (v: string) => (v === "NANO_WEBHOOK_KEY" ? SECRET : undefined);
571
+
572
+ test("a mutating framework tool refuses without the guard credential and succeeds with it", async () => {
573
+ const cancelled: string[] = [];
574
+ const engine = fakeEngine({
575
+ cancelInstance: async ({ processInstanceKey }) => {
576
+ cancelled.push(processInstanceKey);
577
+ },
578
+ });
579
+ const { router } = buildHarness({ engine, spec: GUARDED_SPEC, env: guardEnv });
580
+ const session = await connect(router);
581
+
582
+ // Without the credential the mutation is refused before the engine is touched.
583
+ const refused = await rpc(router, session, "tools/call", {
584
+ name: "urban_debug_cancel_instance",
585
+ arguments: { processInstanceKey: "pi-1" },
586
+ });
587
+ assert.equal(refused.result?.isError, true, "a mutating tool must refuse without the guard credential");
588
+ assert.match(toolContentText(refused.result), /unauthorized/i);
589
+ assert.deepEqual(cancelled, [], "the engine mutation must not run for an unauthorized call");
590
+
591
+ // With the correct shared secret on the connection header the mutation runs.
592
+ const authedSession = { ...session, headers: { ...session.headers, "X-Nano-Key": SECRET } };
593
+ const ok = await rpc(router, authedSession, "tools/call", {
594
+ name: "urban_debug_cancel_instance",
595
+ arguments: { processInstanceKey: "pi-1" },
596
+ });
597
+ assert.notEqual(ok.result?.isError, true, "a mutating tool must succeed with the guard credential");
598
+ assert.deepEqual(cancelled, ["pi-1"], "the engine mutation must run once authorized");
599
+
600
+ // A wrong credential is refused too (constant-time compare via evaluateSecurity).
601
+ const wrongSession = { ...session, headers: { ...session.headers, "X-Nano-Key": "nope" } };
602
+ const wrong = await rpc(router, wrongSession, "tools/call", {
603
+ name: "urban_debug_cancel_instance",
604
+ arguments: { processInstanceKey: "pi-2" },
605
+ });
606
+ assert.equal(wrong.result?.isError, true, "a wrong credential must be refused");
607
+ assert.deepEqual(cancelled, ["pi-1"], "a wrong-credential call must not run the mutation");
608
+ });
609
+
610
+ test("read tools work on loopback without any credential, even when a shared-secret scheme is declared", async () => {
611
+ const engine = fakeEngine({ searchIncidents: async () => [] });
612
+ const { router } = buildHarness({
613
+ engine,
614
+ spec: GUARDED_SPEC,
615
+ env: guardEnv,
616
+ modules: { "/app/operations/listInvoices": { default: () => ({ body: [{ id: "1" }] }) } },
617
+ });
618
+ const session = await connect(router);
619
+
620
+ // A read app operation dispatches with no credential (its GET route declares no security).
621
+ const appRead = await rpc(router, session, "tools/call", { name: "listInvoices", arguments: {} });
622
+ assert.notEqual(appRead.result?.isError, true, "a read app op must serve on loopback without a credential");
623
+ assert.deepEqual(JSON.parse(toolContentText(appRead.result)), [{ id: "1" }]);
624
+
625
+ // A read framework debug tool likewise needs no credential.
626
+ const dbgRead = await rpc(router, session, "tools/call", {
627
+ name: "urban_debug_search_incidents",
628
+ arguments: { state: "ACTIVE" },
629
+ });
630
+ assert.notEqual(dbgRead.result?.isError, true, "a read debug tool must serve on loopback without a credential");
631
+ });
632
+
633
+ test("the explicit allowMutations opt-in authorizes a mutating tool without a credential", async () => {
634
+ const resolved: string[] = [];
635
+ const engine = fakeEngine({
636
+ resolveIncident: async ({ incidentKey }) => {
637
+ resolved.push(incidentKey);
638
+ },
639
+ });
640
+ // No shared-secret scheme in the spec; the runtime opt-in alone opens mutations.
641
+ const { router } = buildHarness({
642
+ engine,
643
+ env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
644
+ });
645
+ const session = await connect(router);
646
+ const ok = await rpc(router, session, "tools/call", {
647
+ name: "urban_debug_resolve_incident",
648
+ arguments: { incidentKey: "inc-9" },
649
+ });
650
+ assert.notEqual(ok.result?.isError, true, "allowMutations must authorize a mutation with no credential");
651
+ assert.deepEqual(resolved, ["inc-9"]);
652
+ });
653
+
654
+ test("the allowMutations opt-in is loopback-only: with allowRemote on, mutations still require the shared secret", async () => {
655
+ // Regression guard for the failure class: a credential-free opt-in must NOT silently apply once
656
+ // the surface is opened to non-loopback callers, or mutating urban_debug_* tools would be exposed
657
+ // remotely with no guard at all. With allowMutations AND allowRemote both on, the bypass is
658
+ // ignored and the shared secret is still enforced (refused without it, allowed with it).
659
+ const cancelled: string[] = [];
660
+ const engine = fakeEngine({
661
+ cancelInstance: async ({ processInstanceKey }) => {
662
+ cancelled.push(processInstanceKey);
663
+ },
664
+ });
665
+ const remoteMutateEnv = (v: string) =>
666
+ v === "URBAN_MCP_ALLOW_MUTATIONS" || v === "URBAN_MCP_ALLOW_REMOTE"
667
+ ? "true"
668
+ : v === "NANO_WEBHOOK_KEY"
669
+ ? SECRET
670
+ : undefined;
671
+ const { router } = buildHarness({ engine, spec: GUARDED_SPEC, env: remoteMutateEnv });
672
+ const session = await connect(router);
673
+
674
+ // allowMutations is set, but because allowRemote is on it must NOT bypass the shared secret.
675
+ const refused = await rpc(router, session, "tools/call", {
676
+ name: "urban_debug_cancel_instance",
677
+ arguments: { processInstanceKey: "pi-1" },
678
+ });
679
+ assert.equal(refused.result?.isError, true, "allowMutations must not bypass the guard when allowRemote is on");
680
+ assert.equal(JSON.parse(toolContentText(refused.result)).status, 401, "a remote-exposed mutation without the secret is 401");
681
+ assert.deepEqual(cancelled, [], "the engine mutation must not run for an unauthorized remote call");
682
+
683
+ // Presenting the app's shared secret still authorizes it (the shared-secret path is unchanged).
684
+ const authed = { ...session, headers: { ...session.headers, "X-Nano-Key": SECRET } };
685
+ const ok = await rpc(router, authed, "tools/call", {
686
+ name: "urban_debug_cancel_instance",
687
+ arguments: { processInstanceKey: "pi-1" },
688
+ });
689
+ assert.notEqual(ok.result?.isError, true, "the shared secret must still authorize a mutation under allowRemote");
690
+ assert.deepEqual(cancelled, ["pi-1"]);
691
+ });
692
+
693
+ test("with allowMutations + allowRemote but no shared-secret scheme, mutations fail closed", async () => {
694
+ // The remote-exposed bypass being ignored must leave NO other credential-free path: with no
695
+ // shared-secret scheme configured either, the mutation is refused rather than silently allowed.
696
+ const engine = fakeEngine({ resolveIncident: async () => assert.fail("must not run") });
697
+ const remoteMutateEnv = (v: string) =>
698
+ v === "URBAN_MCP_ALLOW_MUTATIONS" || v === "URBAN_MCP_ALLOW_REMOTE" ? "true" : undefined;
699
+ const { router } = buildHarness({ engine, env: remoteMutateEnv });
700
+ const session = await connect(router);
701
+ const refused = await rpc(router, session, "tools/call", {
702
+ name: "urban_debug_resolve_incident",
703
+ arguments: { incidentKey: "inc-9" },
704
+ });
705
+ assert.equal(refused.result?.isError, true, "a remote-exposed mutation with no scheme and no in-scope opt-in must fail closed");
706
+ });
707
+
708
+ test("with no shared-secret scheme and no opt-in, a mutating tool fails closed", async () => {
709
+ const engine = fakeEngine({ setVariables: async () => assert.fail("must not run") });
710
+ const { router } = buildHarness({ engine });
711
+ const session = await connect(router);
712
+ const refused = await rpc(router, session, "tools/call", {
713
+ name: "urban_debug_set_variables",
714
+ arguments: { scopeKey: "pi-1", variables: { x: 1 } },
715
+ });
716
+ assert.equal(refused.result?.isError, true, "a mutation must fail closed when nothing can authorize it");
717
+ });
718
+
719
+ test("a mutating tool refused for a misconfigured shared secret logs a 500 and surfaces the status", async () => {
720
+ // The spec declares a shared-secret scheme, but its secret env var is unset — a server
721
+ // misconfiguration (500), not a bad credential (401). Like `mountApi`, the MCP guard must log it
722
+ // for operators and carry the 500 status in the error payload so a client can tell them apart.
723
+ const engine = fakeEngine({ cancelInstance: async () => assert.fail("must not run") });
724
+ const { router, logs } = buildHarness({ engine, spec: GUARDED_SPEC, env: () => undefined });
725
+ const session = await connect(router);
726
+ const refused = await rpc(router, session, "tools/call", {
727
+ name: "urban_debug_cancel_instance",
728
+ arguments: { processInstanceKey: "pi-1" },
729
+ });
730
+ assert.equal(refused.result?.isError, true, "a misconfigured shared secret must refuse the mutation");
731
+ const payload = JSON.parse(toolContentText(refused.result));
732
+ assert.equal(payload.status, 500, "a misconfig refusal must surface status 500, not 401");
733
+ assert.match(payload.error, /misconfigured/i);
734
+ const misconfigLogs = logs.filter((l) => l.level === "error" && l.msg.includes("misconfigured"));
735
+ assert.equal(misconfigLogs.length, 1, "a 500-class refusal must be logged for operators");
736
+ assert.equal(misconfigLogs[0].fields?.tool, "urban_debug_cancel_instance");
737
+ });
738
+
739
+ test("a mutating tool refused for a missing credential surfaces a 401 status without an error log", async () => {
740
+ const engine = fakeEngine({ cancelInstance: async () => assert.fail("must not run") });
741
+ const { router, logs } = buildHarness({ engine, spec: GUARDED_SPEC, env: guardEnv });
742
+ const session = await connect(router);
743
+ const refused = await rpc(router, session, "tools/call", {
744
+ name: "urban_debug_cancel_instance",
745
+ arguments: { processInstanceKey: "pi-1" },
746
+ });
747
+ assert.equal(refused.result?.isError, true, "a missing credential must refuse the mutation");
748
+ const payload = JSON.parse(toolContentText(refused.result));
749
+ assert.equal(payload.status, 401, "a missing/invalid credential must surface status 401");
750
+ assert.equal(
751
+ logs.filter((l) => l.level === "error" && l.msg.includes("misconfigured")).length,
752
+ 0,
753
+ "a plain unauthorized (401) is not a misconfig and must not be logged as one",
754
+ );
755
+ });
756
+
757
+ test("a mutating tool refused because the OpenAPI spec failed to load surfaces a 500, not a 401", async () => {
758
+ // A broken/unparseable spec is a server misconfiguration: the guard cannot tell whether a
759
+ // shared-secret scheme exists, so it must surface a 500 (logged for operators) rather than a
760
+ // misleading 401 that looks like a missing credential.
761
+ const engine = fakeEngine({ cancelInstance: async () => assert.fail("must not run") });
762
+ // Valid JSON but not an object at the root — `parseSpec` throws, so `loadSpecDoc` fails to load.
763
+ const { router, logs } = buildHarness({ engine, spec: "[1, 2, 3]", env: guardEnv });
764
+ const session = await connect(router);
765
+ const refused = await rpc(router, session, "tools/call", {
766
+ name: "urban_debug_cancel_instance",
767
+ arguments: { processInstanceKey: "pi-1" },
768
+ });
769
+ assert.equal(refused.result?.isError, true, "a broken spec must refuse the mutation");
770
+ const payload = JSON.parse(toolContentText(refused.result));
771
+ assert.equal(payload.status, 500, "a spec-load failure must surface status 500, not 401");
772
+ assert.match(payload.error, /failed to load/i);
773
+ const loadWarns = logs.filter((l) => l.level === "warn" && l.msg.includes("failed to load the app OpenAPI spec"));
774
+ assert.equal(loadWarns.length >= 1, true, "the spec-load failure must be logged");
775
+ const misconfigLogs = logs.filter((l) => l.level === "error" && l.msg.includes("misconfigured"));
776
+ assert.equal(misconfigLogs.length, 1, "a 500-class refusal must be logged for operators");
777
+ assert.equal(misconfigLogs[0].fields?.tool, "urban_debug_cancel_instance");
778
+ });
779
+
780
+ test("an authorized mutating tool rejects a blank/non-string key or negative retries before touching the engine", async () => {
781
+ const touched: string[] = [];
782
+ const engine = fakeEngine({
783
+ cancelInstance: async () => {
784
+ touched.push("cancelInstance");
785
+ },
786
+ resolveIncident: async () => {
787
+ touched.push("resolveIncident");
788
+ },
789
+ updateJobRetries: async () => {
790
+ touched.push("updateJobRetries");
791
+ },
792
+ setVariables: async () => {
793
+ touched.push("setVariables");
794
+ },
795
+ });
796
+ // Fully authorized via the opt-in — so a rejection can only come from argument validation.
797
+ const { router } = buildHarness({
798
+ engine,
799
+ env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
800
+ });
801
+ const session = await connect(router);
802
+
803
+ const badCalls: { name: string; arguments: Record<string, unknown>; why: RegExp }[] = [
804
+ // Blank strings are rejected upstream as a missing required parameter; a non-string value slips
805
+ // past that check (it is "present") and must be rejected by the tool's own argument guard.
806
+ { name: "urban_debug_cancel_instance", arguments: { processInstanceKey: "" }, why: /missing required/i },
807
+ { name: "urban_debug_cancel_instance", arguments: { processInstanceKey: 123 }, why: /non-empty string/i },
808
+ { name: "urban_debug_resolve_incident", arguments: { incidentKey: 7 }, why: /non-empty string/i },
809
+ { name: "urban_debug_set_variables", arguments: { scopeKey: 1, variables: { x: 1 } }, why: /non-empty string/i },
810
+ { name: "urban_debug_retry_job", arguments: { jobKey: 42, retries: 0 }, why: /non-empty string/i },
811
+ { name: "urban_debug_retry_job", arguments: { jobKey: "j-1", retries: -1 }, why: /non-negative integer/i },
812
+ ];
813
+
814
+ for (const call of badCalls) {
815
+ const res = await rpc(router, session, "tools/call", { name: call.name, arguments: call.arguments });
816
+ assert.equal(res.result?.isError, true, `${call.name} must reject invalid args: ${JSON.stringify(call.arguments)}`);
817
+ assert.match(toolContentText(res.result), call.why);
818
+ }
819
+ assert.deepEqual(touched, [], "no invalid mutating call may reach the engine");
820
+ });
821
+
822
+ test("an authorized mutating tool trims a padded identifying key before mutating the engine", async () => {
823
+ // A padded key like " pi-1 " is "present" (the required-arg check trims via presentFormIdentifier),
824
+ // so it slips past presence validation. The tool's own `requireString` guard must apply the SAME
825
+ // trimmed-presence rule and forward the TRIMMED value, or the engine mutation targets a whitespace-
826
+ // padded entity that does not exist. This guards the drift between the presence check and the guard.
827
+ const cancelled: string[] = [];
828
+ const engine = fakeEngine({
829
+ cancelInstance: async ({ processInstanceKey }) => {
830
+ cancelled.push(processInstanceKey);
831
+ },
832
+ });
833
+ const { router } = buildHarness({
834
+ engine,
835
+ env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
836
+ });
837
+ const session = await connect(router);
838
+ const ok = await rpc(router, session, "tools/call", {
839
+ name: "urban_debug_cancel_instance",
840
+ arguments: { processInstanceKey: " pi-1 " },
841
+ });
842
+ assert.notEqual(ok.result?.isError, true, "a padded but present key must be accepted");
843
+ assert.deepEqual(cancelled, ["pi-1"], "the engine must receive the trimmed key, not the padded one");
844
+ });
845
+
846
+ test("a read tool trims a padded filter value before querying the engine", async () => {
847
+ // The read path had the same drift the mutating guard closes: an optional filter value read raw
848
+ // reaches the engine untrimmed, so a padded key like " pi-1 " becomes a filter that silently
849
+ // matches nothing. `readPresentString` — the single source of truth `requireString` derives from —
850
+ // applies the same trimmed-presence rule to optional reads, so search filters cannot mis-target.
851
+ const seen: (string | undefined)[] = [];
852
+ const engine = fakeEngine({
853
+ searchIncidents: async (filter) => {
854
+ seen.push(filter?.processInstanceKey);
855
+ return [];
856
+ },
857
+ });
858
+ const { router } = buildHarness({ engine });
859
+ const session = await connect(router);
860
+ const ok = await rpc(router, session, "tools/call", {
861
+ name: "urban_debug_search_incidents",
862
+ arguments: { processInstanceKey: " pi-1 " },
863
+ });
864
+ assert.notEqual(ok.result?.isError, true, "a padded filter value must be accepted");
865
+ assert.deepEqual(seen, ["pi-1"], "the engine must receive the trimmed filter value, not the padded one");
866
+ });
867
+
868
+ test("every debug tool with a required string key rejects a non-string value before querying/mutating", async () => {
869
+ // Defect-class guard (not a single-line squash): `missingRequiredArgs` treats a present non-string
870
+ // (number/object) as provided, so any tool that declares a key `required` but reads it with a
871
+ // degrade-to-empty pattern (`readPresentString(...) ?? ""`) would let that value slip past the
872
+ // presence check and query/mutate with an empty key — a confusing empty result, not a fast failure.
873
+ // The canonical fix is that EVERY required string key is read via `requireString`, which fails fast
874
+ // with InvalidParams. This test enumerates the live tool list and asserts that invariant holds for
875
+ // ALL current and future debug tools, so a new tool cannot silently reintroduce the drift.
876
+ const { router } = buildHarness({
877
+ // Fully authorized so a rejection can only come from argument validation, not the mutation gate.
878
+ env: (v) => (v === "URBAN_MCP_ALLOW_MUTATIONS" ? "true" : undefined),
879
+ });
880
+ const session = await connect(router);
881
+ const list = await rpc(router, session, "tools/list", {});
882
+ const tools = list.result?.tools;
883
+ assert.ok(Array.isArray(tools));
884
+
885
+ // A valid dummy for a required key of a given JSON-schema type, used to satisfy the OTHER required
886
+ // keys so the rejection is attributable solely to the one non-string key we poke.
887
+ const dummyForType = (type: unknown): unknown => {
888
+ if (type === "string") return "dummy";
889
+ if (type === "integer" || type === "number") return 0;
890
+ if (type === "boolean") return false;
891
+ if (type === "object") return {};
892
+ if (type === "array") return [];
893
+ return "dummy";
894
+ };
895
+
896
+ let requiredStringKeysChecked = 0;
897
+ for (const tool of tools) {
898
+ const name = Reflect.get(tool, "name");
899
+ if (typeof name !== "string" || !name.startsWith("urban_debug_")) continue;
900
+ const schema = Reflect.get(tool, "inputSchema");
901
+ if (schema === null || typeof schema !== "object") continue;
902
+ const required = Reflect.get(schema, "required");
903
+ const properties = Reflect.get(schema, "properties");
904
+ if (!Array.isArray(required) || properties === null || typeof properties !== "object") continue;
905
+
906
+ const requiredStringKeys = required.filter((k) => {
907
+ if (typeof k !== "string") return false;
908
+ const prop = Reflect.get(properties, k);
909
+ return prop !== null && typeof prop === "object" && Reflect.get(prop, "type") === "string";
910
+ });
911
+
912
+ for (const key of requiredStringKeys) {
913
+ const args: Record<string, unknown> = {};
914
+ for (const other of required) {
915
+ if (typeof other !== "string") continue;
916
+ const prop = Reflect.get(properties, other);
917
+ args[other] = dummyForType(prop !== null && typeof prop === "object" ? Reflect.get(prop, "type") : undefined);
918
+ }
919
+ // Poke exactly one required string key with a present-but-non-string value.
920
+ args[key] = 123;
921
+ const res = await rpc(router, session, "tools/call", { name, arguments: args });
922
+ assert.equal(
923
+ res.result?.isError,
924
+ true,
925
+ `${name} must reject a non-string ${key} instead of querying/mutating with an empty key`,
926
+ );
927
+ assert.match(toolContentText(res.result), /non-empty string/i, `${name}.${key} must fail fast via requireString`);
928
+ requiredStringKeysChecked++;
929
+ }
930
+ }
931
+ // Guard the guard: if this drops to zero the enumeration silently stopped covering anything.
932
+ assert.ok(requiredStringKeysChecked >= 3, "expected at least the known required-string-key debug tools to be checked");
933
+ });
934
+
935
+ // ---- spec ↔ tool parity guard (Slice 3) -------------------------------------------------------
936
+
937
+ test("the spec↔tool parity guard reports drift on an injected skew and none in parity", () => {
938
+ const doc = parseSpec(
939
+ JSON.stringify({
940
+ openapi: "3.1.0",
941
+ paths: {
942
+ "/a": { get: { operationId: "readA", responses: { "200": {} } } },
943
+ "/b": { post: { operationId: "mutateB", responses: { "201": {} } } },
944
+ "/c": { post: { operationId: "hiddenC", "x-mcp": false, responses: { "202": {} } } },
945
+ },
946
+ }),
947
+ );
948
+ const projection = collectMcpToolProjection(doc);
949
+ // A snapshot identical to the current projection is in parity.
950
+ assert.deepEqual(diffMcpToolProjection(projection, projection), []);
951
+
952
+ // Inject skew: drop one op, flip another's mutating/excluded facts.
953
+ const skewed = projection
954
+ .filter((p) => p.operationId !== "readA")
955
+ .map((p) => (p.operationId === "hiddenC" ? { ...p, excluded: false } : p));
956
+ const drift = diffMcpToolProjection(skewed, projection);
957
+ assert.ok(drift.length >= 2, "both a missing op and a changed fact must be reported");
958
+ assert.ok(drift.some((l) => l.includes("readA")), "the dropped op must be reported");
959
+ assert.ok(drift.some((l) => l.includes("hiddenC")), "the flipped exclusion must be reported");
960
+ });
961
+
450
962
  // ---- resource + prompt ------------------------------------------------------------------------
451
963
 
452
964
  test("a debug tool call missing a required parameter is rejected before its handler runs", async () => {