create-ailk 0.10.0 → 0.11.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 (70) hide show
  1. package/README.md +7 -2
  2. package/dist/cli.js +5 -1
  3. package/dist/content-payloads.d.ts +37 -0
  4. package/dist/content-payloads.js +64 -0
  5. package/dist/lib/fetch-module.d.ts +3 -3
  6. package/dist/lib/fetch-module.js +11 -5
  7. package/dist/lib/fetch-template.d.ts +1 -1
  8. package/dist/lib/fetch-template.js +7 -4
  9. package/dist/module-architecture.d.ts +9 -0
  10. package/dist/module-architecture.js +43 -12
  11. package/dist/parse-args.d.ts +2 -0
  12. package/dist/parse-args.js +7 -0
  13. package/dist/surfaces.js +2 -3
  14. package/package.json +3 -3
  15. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +4 -3
  16. package/templates/.claude/settings.json +12 -0
  17. package/templates/.env.example +1 -1
  18. package/templates/CLAUDE.md +38 -22
  19. package/templates/README.md +12 -12
  20. package/templates/apps/api/jest.config.cjs +1 -0
  21. package/templates/apps/api/package.json +1 -0
  22. package/templates/apps/mcp/CLAUDE.md +23 -10
  23. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +160 -17
  24. package/templates/apps/mcp/__tests__/index.test.ts +118 -1
  25. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +425 -5
  26. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +67 -3
  27. package/templates/apps/mcp/__tests__/server.test.ts +162 -1
  28. package/templates/apps/mcp/src/index.ts +35 -3
  29. package/templates/apps/mcp/src/lib/commerce-claim.ts +193 -24
  30. package/templates/apps/mcp/src/server.ts +77 -5
  31. package/templates/apps/mcp/src/tools/purchase.ts +1 -1
  32. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +15 -0
  33. package/templates/apps/web/app/.well-known/mcp.json/route.ts +10 -3
  34. package/templates/apps/web/src/lib/commerce-claim.ts +15 -6
  35. package/templates/content/_site.mdx +23 -4
  36. package/templates/database/CHANGELOG.md +7 -0
  37. package/templates/database/package.json +1 -1
  38. package/versions.json +34 -33
  39. package/templates/.claude/skills/README.md +0 -37
  40. package/templates/.claude/skills/add-auth/SKILL.md +0 -313
  41. package/templates/.claude/skills/add-auth/references/env-vars.md +0 -106
  42. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +0 -180
  43. package/templates/.claude/skills/add-page/SKILL.md +0 -260
  44. package/templates/.claude/skills/add-page/references/page-types.md +0 -123
  45. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +0 -87
  46. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +0 -99
  47. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +0 -18
  48. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +0 -43
  49. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +0 -52
  50. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +0 -38
  51. package/templates/.claude/skills/check-site/SKILL.md +0 -245
  52. package/templates/.claude/skills/check-site/references/common-errors.md +0 -116
  53. package/templates/.claude/skills/check-site/references/findings-glossary.md +0 -140
  54. package/templates/.claude/skills/check-site/references/severity-levels.md +0 -114
  55. package/templates/.claude/skills/fill-page/SKILL.md +0 -199
  56. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +0 -80
  57. package/templates/.claude/skills/fill-page/references/section-sources.md +0 -41
  58. package/templates/.claude/skills/setup-database/SKILL.md +0 -156
  59. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +0 -154
  60. package/templates/.claude/skills/setup-deploy/SKILL.md +0 -312
  61. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +0 -204
  62. package/templates/.claude/skills/setup-deploy/references/vercel-json-template.md +0 -98
  63. package/templates/.claude/skills/update-page/SKILL.md +0 -117
  64. package/templates/.claude/skills/update-page/scripts/update-page.ts +0 -640
  65. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +0 -31
  66. package/templates/.claude/skills/upgrade-plan/SKILL.md +0 -121
  67. package/templates/.claude/skills/upgrade-plan/references/options.md +0 -24
  68. package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +0 -527
  69. package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +0 -75
  70. package/templates/apps/mcp/src/lib/require-paid-tier.ts +0 -44
@@ -1,17 +1,25 @@
1
1
  /**
2
- * Tests for the boot-memoised commerce claim (#5971 spec D2).
2
+ * Tests for the boot-memoised commerce claim (#5971 spec D2) and the held
3
+ * licence-feature set resolved from the same read (#7136 spec D3).
3
4
  *
4
- * Idiom: `__tests__/lib/require-paid-tier.test.ts` — `checkLicense()` is
5
- * injected, never called for real, so no suite reaches a licence server
6
- * (NFR-6). Every arm that cannot POSITIVELY establish the claim must resolve
7
- * to `"unclaimed"` (NFR-3, fail closed).
5
+ * Idiom: the `checkLicense` option — `checkLicense()` is injected, never
6
+ * called for real, so no suite reaches a licence server (NFR-6). Every arm
7
+ * that cannot POSITIVELY establish the claim must resolve to `"unclaimed"`
8
+ * and an empty feature set (NFR-3, fail closed).
8
9
  */
9
10
 
10
11
  import {
12
+ MCP_CLAIM_MAX_MISSED_REREADS,
13
+ MCP_CLAIM_REFRESH_MS,
11
14
  initMcpCommerceClaim,
12
15
  mcpCommerceClaim,
16
+ mcpFeatureGateOverride,
17
+ mcpHeldFeatures,
13
18
  resetMcpCommerceClaim,
19
+ startMcpClaimRefresh,
14
20
  __setMcpCommerceClaimForTest,
21
+ __setMcpFeatureGateForTest,
22
+ __setMcpHeldFeaturesForTest,
15
23
  } from "../../src/lib/commerce-claim.js";
16
24
 
17
25
  /**
@@ -109,3 +117,415 @@ describe("mcpCommerceClaim — fail-closed initial state", () => {
109
117
  expect(mcpCommerceClaim()).toBe("claimed");
110
118
  });
111
119
  });
120
+
121
+ describe("mcpHeldFeatures — the deployment's feature set, from the same read (#7136)", () => {
122
+ const FEATURES = ["scheduling", "flows-deliverables", "commerce"] as const;
123
+
124
+ it("one checkLicense() call resolves both the commerce claim and the features", async () => {
125
+ const fake = jest.fn(resolving({ tier: "pro", features: [...FEATURES] }));
126
+
127
+ await initMcpCommerceClaim({ checkLicense: fake });
128
+
129
+ expect(fake).toHaveBeenCalledTimes(1);
130
+ expect([...mcpHeldFeatures()].sort()).toEqual([...FEATURES].sort());
131
+ expect(mcpCommerceClaim()).toBe("claimed");
132
+ });
133
+
134
+ it("is empty before any init", () => {
135
+ expect(mcpHeldFeatures().size).toBe(0);
136
+ });
137
+
138
+ it("is empty when the licence is revoked, even carrying the features", async () => {
139
+ await initMcpCommerceClaim({
140
+ checkLicense: resolving({
141
+ tier: "pro",
142
+ features: [...FEATURES],
143
+ revoked: true,
144
+ }),
145
+ });
146
+
147
+ expect(mcpHeldFeatures().size).toBe(0);
148
+ });
149
+
150
+ it("is empty — never throws — when checkLicense() rejects", async () => {
151
+ await initMcpCommerceClaim({
152
+ checkLicense: resolving({ tier: "pro", features: [...FEATURES] }),
153
+ });
154
+ await initMcpCommerceClaim({ checkLicense: rejecting() });
155
+
156
+ expect(mcpHeldFeatures().size).toBe(0);
157
+ });
158
+
159
+ it("reset empties a populated set and clears the mode override", async () => {
160
+ await initMcpCommerceClaim({
161
+ checkLicense: resolving({ tier: "pro", features: [...FEATURES] }),
162
+ });
163
+ __setMcpFeatureGateForTest("refuse");
164
+ expect(mcpFeatureGateOverride()).toBe("refuse");
165
+
166
+ resetMcpCommerceClaim();
167
+
168
+ expect(mcpHeldFeatures().size).toBe(0);
169
+ expect(mcpFeatureGateOverride()).toBeUndefined();
170
+ });
171
+ });
172
+
173
+ /** A licence reader whose promise never settles — the "one read in flight"
174
+ * probe (#7156 AC-1). */
175
+ function neverResolving(): CheckLicenseSeam {
176
+ return () => new Promise<LicenseShape>(() => {});
177
+ }
178
+
179
+ /** A licence reader the test resolves on its own schedule — the "a stale
180
+ * read is discarded" probe (#7156 AC-1). */
181
+ function deferred(): {
182
+ reader: CheckLicenseSeam;
183
+ resolve: (result: Partial<LicenseShape>) => void;
184
+ } {
185
+ let resolveFn!: (value: LicenseShape) => void;
186
+ const promise = new Promise<LicenseShape>((resolve) => {
187
+ resolveFn = resolve;
188
+ });
189
+ return {
190
+ reader: () => promise,
191
+ resolve: (result) => resolveFn(result as LicenseShape),
192
+ };
193
+ }
194
+
195
+ describe("startMcpClaimRefresh — bounded re-read (#7156 AC-1)", () => {
196
+ beforeEach(() => {
197
+ jest.useFakeTimers();
198
+ });
199
+
200
+ afterEach(() => {
201
+ resetMcpCommerceClaim();
202
+ jest.useRealTimers();
203
+ });
204
+
205
+ it("the bound is a 5-minute constant", () => {
206
+ expect(MCP_CLAIM_REFRESH_MS).toBe(300_000);
207
+ });
208
+
209
+ it("re-reads in both directions once the bound elapses", async () => {
210
+ await initMcpCommerceClaim({
211
+ checkLicense: resolving({
212
+ tier: "pro",
213
+ features: ["scheduling", "commerce"],
214
+ }),
215
+ });
216
+
217
+ let current: Partial<LicenseShape> = {
218
+ tier: "pro",
219
+ features: ["scheduling", "commerce"],
220
+ };
221
+ const fake = jest.fn<CheckLicenseSeam>(() =>
222
+ Promise.resolve(current as LicenseShape),
223
+ );
224
+ startMcpClaimRefresh({ checkLicense: fake });
225
+
226
+ current = { tier: "free", features: [] };
227
+
228
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS - 1);
229
+ expect(fake).not.toHaveBeenCalled();
230
+ expect([...mcpHeldFeatures()].sort()).toEqual(
231
+ ["commerce", "scheduling"].sort(),
232
+ );
233
+
234
+ await jest.advanceTimersByTimeAsync(1);
235
+ expect(fake).toHaveBeenCalledTimes(1);
236
+ expect(mcpHeldFeatures().size).toBe(0);
237
+ expect(mcpCommerceClaim()).toBe("unclaimed");
238
+
239
+ current = { tier: "pro", features: ["scheduling", "commerce"] };
240
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
241
+ expect([...mcpHeldFeatures()].sort()).toEqual(
242
+ ["commerce", "scheduling"].sort(),
243
+ );
244
+ expect(mcpCommerceClaim()).toBe("claimed");
245
+ });
246
+
247
+ it("fails closed on a re-read that reports revoked", async () => {
248
+ await initMcpCommerceClaim({
249
+ checkLicense: resolving({ tier: "pro", features: ["commerce"] }),
250
+ });
251
+ startMcpClaimRefresh({
252
+ checkLicense: resolving({
253
+ tier: "pro",
254
+ features: ["commerce"],
255
+ revoked: true,
256
+ }),
257
+ });
258
+
259
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
260
+
261
+ expect(mcpHeldFeatures().size).toBe(0);
262
+ expect(mcpCommerceClaim()).toBe("unclaimed");
263
+ });
264
+
265
+ it("fails closed on a re-read that rejects, without throwing", async () => {
266
+ await initMcpCommerceClaim({
267
+ checkLicense: resolving({ tier: "pro", features: ["commerce"] }),
268
+ });
269
+ startMcpClaimRefresh({ checkLicense: rejecting() });
270
+
271
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
272
+
273
+ expect(mcpHeldFeatures().size).toBe(0);
274
+ expect(mcpCommerceClaim()).toBe("unclaimed");
275
+ });
276
+
277
+ it("is single-flight — a read that never settles is called once across multiple intervals", async () => {
278
+ const fake = jest.fn(neverResolving());
279
+ startMcpClaimRefresh({ checkLicense: fake });
280
+
281
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS * 3);
282
+
283
+ expect(fake).toHaveBeenCalledTimes(1);
284
+ });
285
+
286
+ it("starting the loop twice replaces the first — only one loop runs", async () => {
287
+ const fake = jest.fn(resolving({ tier: "free", features: [] }));
288
+ startMcpClaimRefresh({ checkLicense: fake });
289
+ startMcpClaimRefresh({ checkLicense: fake });
290
+
291
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
292
+
293
+ expect(fake).toHaveBeenCalledTimes(1);
294
+ });
295
+
296
+ it("resetMcpCommerceClaim() stops the loop", async () => {
297
+ const fake = jest.fn(resolving({ tier: "free", features: [] }));
298
+ startMcpClaimRefresh({ checkLicense: fake });
299
+
300
+ resetMcpCommerceClaim();
301
+
302
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS * 2);
303
+
304
+ expect(fake).not.toHaveBeenCalled();
305
+ });
306
+
307
+ it("a stale read is discarded after a reset and a fresh boot", async () => {
308
+ const held = deferred();
309
+ startMcpClaimRefresh({ checkLicense: held.reader });
310
+
311
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
312
+
313
+ resetMcpCommerceClaim();
314
+ await initMcpCommerceClaim({
315
+ checkLicense: resolving({ tier: "pro", features: ["scheduling"] }),
316
+ });
317
+
318
+ held.resolve({ tier: "pro", features: ["scheduling", "commerce"] });
319
+ await Promise.resolve();
320
+ await Promise.resolve();
321
+
322
+ expect([...mcpHeldFeatures()]).toEqual(["scheduling"]);
323
+ expect(mcpCommerceClaim()).toBe("unclaimed");
324
+ });
325
+
326
+ it("no stuck in-flight flag survives a reset — a fresh loop re-reads on its first interval", async () => {
327
+ const never = jest.fn(neverResolving());
328
+ startMcpClaimRefresh({ checkLicense: never });
329
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
330
+ resetMcpCommerceClaim();
331
+
332
+ const fake = jest.fn(resolving({ tier: "pro", features: ["commerce"] }));
333
+ startMcpClaimRefresh({ checkLicense: fake });
334
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
335
+
336
+ expect(fake).toHaveBeenCalledTimes(1);
337
+ expect(mcpCommerceClaim()).toBe("claimed");
338
+ });
339
+
340
+ it("never holds a process open — the timer is unref'd", () => {
341
+ const setIntervalSpy = jest.spyOn(global, "setInterval");
342
+ startMcpClaimRefresh({
343
+ checkLicense: resolving({ tier: "free", features: [] }),
344
+ });
345
+
346
+ const timer = setIntervalSpy.mock.results[0]?.value as {
347
+ hasRef(): boolean;
348
+ };
349
+ expect(timer.hasRef()).toBe(false);
350
+
351
+ setIntervalSpy.mockRestore();
352
+ });
353
+ });
354
+
355
+ describe("startMcpClaimRefresh — staleness ceiling (#7315 AC-3, L1)", () => {
356
+ const BOTH_PLUS_COMMERCE = [
357
+ "scheduling",
358
+ "flows-deliverables",
359
+ "commerce",
360
+ ] as const;
361
+
362
+ beforeEach(() => {
363
+ jest.useFakeTimers();
364
+ });
365
+
366
+ afterEach(() => {
367
+ resetMcpCommerceClaim();
368
+ jest.useRealTimers();
369
+ });
370
+
371
+ it("is a 3-interval constant", () => {
372
+ expect(MCP_CLAIM_MAX_MISSED_REREADS).toBe(3);
373
+ });
374
+
375
+ it("fires at the fourth interval — falls back to no claim, starts a fresh read, and an abandoned read cannot write once recovery has happened", async () => {
376
+ await initMcpCommerceClaim({
377
+ checkLicense: resolving({
378
+ tier: "pro",
379
+ features: [...BOTH_PLUS_COMMERCE],
380
+ }),
381
+ });
382
+
383
+ let resolveFirst!: (value: LicenseShape) => void;
384
+ let resolveSecond!: (value: LicenseShape) => void;
385
+ const first = new Promise<LicenseShape>((resolve) => {
386
+ resolveFirst = resolve;
387
+ });
388
+ const second = new Promise<LicenseShape>((resolve) => {
389
+ resolveSecond = resolve;
390
+ });
391
+ const fake = jest
392
+ .fn<CheckLicenseSeam>()
393
+ .mockReturnValueOnce(first)
394
+ .mockReturnValueOnce(second);
395
+ startMcpClaimRefresh({ checkLicense: fake });
396
+
397
+ // Interval 1 starts the (never-settling, for now) read; intervals 2 and
398
+ // 3 are misses 1 and 2 — the ceiling has not fired yet.
399
+ await jest.advanceTimersByTimeAsync(3 * MCP_CLAIM_REFRESH_MS);
400
+ expect(fake).toHaveBeenCalledTimes(1);
401
+ expect([...mcpHeldFeatures()].sort()).toEqual(
402
+ [...BOTH_PLUS_COMMERCE].sort(),
403
+ );
404
+ expect(mcpCommerceClaim()).toBe("claimed");
405
+
406
+ // Interval 4 — the third miss reaches the ceiling: fall back to no
407
+ // claim and start a fresh (second) read on the same tick.
408
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
409
+ expect(mcpHeldFeatures().size).toBe(0);
410
+ expect(mcpCommerceClaim()).toBe("unclaimed");
411
+ expect(fake).toHaveBeenCalledTimes(2);
412
+
413
+ // Recovery — the fresh read finishing restores the claim.
414
+ resolveSecond({
415
+ tier: "pro",
416
+ features: [...BOTH_PLUS_COMMERCE],
417
+ } as LicenseShape);
418
+ await Promise.resolve();
419
+ await Promise.resolve();
420
+ expect([...mcpHeldFeatures()].sort()).toEqual(
421
+ [...BOTH_PLUS_COMMERCE].sort(),
422
+ );
423
+ expect(mcpCommerceClaim()).toBe("claimed");
424
+
425
+ // The read the ceiling abandoned resolving afterwards must not write —
426
+ // it is no longer the most recently started read.
427
+ resolveFirst({ tier: "pro", features: [] } as LicenseShape);
428
+ await Promise.resolve();
429
+ await Promise.resolve();
430
+ expect([...mcpHeldFeatures()].sort()).toEqual(
431
+ [...BOTH_PLUS_COMMERCE].sort(),
432
+ );
433
+ expect(mcpCommerceClaim()).toBe("claimed");
434
+ });
435
+
436
+ it("resets the missed count when a read finishes, so a merely slow store never reaches the ceiling", async () => {
437
+ await initMcpCommerceClaim({
438
+ checkLicense: resolving({
439
+ tier: "pro",
440
+ features: [...BOTH_PLUS_COMMERCE],
441
+ }),
442
+ });
443
+
444
+ const fake = jest.fn<CheckLicenseSeam>(
445
+ () =>
446
+ new Promise<LicenseShape>((resolve) => {
447
+ setTimeout(
448
+ () =>
449
+ resolve({
450
+ tier: "pro",
451
+ features: [...BOTH_PLUS_COMMERCE],
452
+ } as LicenseShape),
453
+ 1.5 * MCP_CLAIM_REFRESH_MS,
454
+ );
455
+ }),
456
+ );
457
+ startMcpClaimRefresh({ checkLicense: fake });
458
+
459
+ for (let i = 0; i < 8; i++) {
460
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
461
+ expect([...mcpHeldFeatures()].sort()).toEqual(
462
+ [...BOTH_PLUS_COMMERCE].sort(),
463
+ );
464
+ expect(mcpCommerceClaim()).toBe("claimed");
465
+ }
466
+ });
467
+
468
+ it("the existing single-flight probe stays true at N = 3 (#7156)", async () => {
469
+ const fake = jest.fn(neverResolving());
470
+ startMcpClaimRefresh({ checkLicense: fake });
471
+
472
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS * 3);
473
+
474
+ expect(fake).toHaveBeenCalledTimes(1);
475
+ });
476
+ });
477
+
478
+ describe("test-only writers throw outside a test run (#7156 AC-3, L2)", () => {
479
+ const ORIGINAL_NODE_ENV = process.env["NODE_ENV"];
480
+
481
+ afterEach(() => {
482
+ process.env["NODE_ENV"] = "test";
483
+ resetMcpCommerceClaim();
484
+ if (ORIGINAL_NODE_ENV === undefined) {
485
+ delete process.env["NODE_ENV"];
486
+ } else {
487
+ process.env["NODE_ENV"] = ORIGINAL_NODE_ENV;
488
+ }
489
+ });
490
+
491
+ async function establishNonInitialState(): Promise<void> {
492
+ await initMcpCommerceClaim({
493
+ checkLicense: resolving({
494
+ tier: "pro",
495
+ features: ["scheduling", "commerce"],
496
+ }),
497
+ });
498
+ __setMcpFeatureGateForTest("refuse");
499
+ }
500
+
501
+ it.each(["production", undefined] as const)(
502
+ "throws without changing state when NODE_ENV is %p",
503
+ async (nodeEnv) => {
504
+ await establishNonInitialState();
505
+ const preClaim = mcpCommerceClaim();
506
+ const preFeatures = new Set(mcpHeldFeatures());
507
+ const preGate = mcpFeatureGateOverride();
508
+
509
+ if (nodeEnv === undefined) {
510
+ delete process.env["NODE_ENV"];
511
+ } else {
512
+ process.env["NODE_ENV"] = nodeEnv;
513
+ }
514
+
515
+ expect(() => __setMcpCommerceClaimForTest("unclaimed")).toThrow(
516
+ /__setMcpCommerceClaimForTest/,
517
+ );
518
+ expect(() => __setMcpHeldFeaturesForTest([])).toThrow(
519
+ /__setMcpHeldFeaturesForTest/,
520
+ );
521
+ expect(() => __setMcpFeatureGateForTest("warn")).toThrow(
522
+ /__setMcpFeatureGateForTest/,
523
+ );
524
+ expect(() => resetMcpCommerceClaim()).toThrow(/resetMcpCommerceClaim/);
525
+
526
+ expect(mcpCommerceClaim()).toBe(preClaim);
527
+ expect(mcpHeldFeatures()).toEqual(preFeatures);
528
+ expect(mcpFeatureGateOverride()).toBe(preGate);
529
+ },
530
+ );
531
+ });
@@ -12,10 +12,21 @@
12
12
  * Reverting the `shouldRegister` module filter in `../src/server.ts` (while
13
13
  * leaving the `OSS_DISABLED_TOOLS` check alone) makes this test fail RED —
14
14
  * see #5088 S1 verification notes.
15
+ *
16
+ * #7136: the module filter stays independent of the licence-feature gate —
17
+ * the matrix below also runs in refuse mode with every feature held. The
18
+ * catalog enumerations pin `featureGate: "warn"`, so they list every
19
+ * module-tagged tool whichever way `MCP_FEATURE_GATE_MODE` is set and can
20
+ * never iterate nothing.
15
21
  */
16
22
 
17
23
  import { getMcpToolRegistrations } from "@working-theory/metadata";
18
24
 
25
+ import {
26
+ __setMcpFeatureGateForTest,
27
+ __setMcpHeldFeaturesForTest,
28
+ resetMcpCommerceClaim,
29
+ } from "../src/lib/commerce-claim.js";
19
30
  import { createMcpServer, listRegisteredTools } from "../src/server.js";
20
31
 
21
32
  // `content-write` left this list in #5853: its three tools (create_page /
@@ -26,6 +37,11 @@ import { createMcpServer, listRegisteredTools } from "../src/server.js";
26
37
  // (D8), which this MCP-side matrix has never covered.
27
38
  const PAID_MODULES = ["scheduling", "signal-collectors"] as const;
28
39
 
40
+ /** Every module-tagged tool in the catalog, whatever the gate mode. */
41
+ function catalogRegistrations() {
42
+ return getMcpToolRegistrations({ featureGate: "warn" });
43
+ }
44
+
29
45
  function withEnabledModules<T>(enabled: string, fn: () => T): T {
30
46
  const prior = process.env.AILK_ENABLED_MODULES;
31
47
  process.env.AILK_ENABLED_MODULES = enabled;
@@ -50,10 +66,11 @@ describe("MCP tool registration — module exclusion (S1, #5088)", () => {
50
66
  // the catalog without a matching entry here would silently go
51
67
  // unverified.
52
68
  const taggedModules = new Set(
53
- getMcpToolRegistrations()
69
+ catalogRegistrations()
54
70
  .map((reg) => reg.module)
55
71
  .filter((m): m is string => Boolean(m)),
56
72
  );
73
+ expect(taggedModules.size).toBeGreaterThan(0);
57
74
  for (const m of taggedModules) {
58
75
  expect(PAID_MODULES).toContain(m);
59
76
  }
@@ -65,7 +82,7 @@ describe("MCP tool registration — module exclusion (S1, #5088)", () => {
65
82
  const enabledModules = PAID_MODULES.filter((m) => m !== excluded).join(
66
83
  ",",
67
84
  );
68
- const excludedToolNames = getMcpToolRegistrations()
85
+ const excludedToolNames = catalogRegistrations()
69
86
  .filter((reg) => reg.module === excluded)
70
87
  .map((reg) => reg.name);
71
88
  // Sanity check the fixture: every module this test parametrizes over
@@ -101,11 +118,58 @@ describe("MCP tool registration — module exclusion (S1, #5088)", () => {
101
118
  listRegisteredTools().map((t) => t.name),
102
119
  ),
103
120
  );
104
- const taggedNames = getMcpToolRegistrations()
121
+ const taggedNames = catalogRegistrations()
105
122
  .filter((reg) => reg.module)
106
123
  .map((reg) => reg.name);
124
+ expect(taggedNames.length).toBeGreaterThan(0);
107
125
  for (const name of taggedNames) {
108
126
  expect(registeredNames).toContain(name);
109
127
  }
110
128
  });
111
129
  });
130
+
131
+ describe("module exclusion is independent of the licence-feature gate (#7136)", () => {
132
+ beforeEach(() => {
133
+ __setMcpFeatureGateForTest("refuse");
134
+ __setMcpHeldFeaturesForTest(["scheduling", "flows-deliverables"]);
135
+ });
136
+
137
+ afterEach(() => {
138
+ resetMcpCommerceClaim();
139
+ delete process.env.AILK_ENABLED_MODULES;
140
+ });
141
+
142
+ it.each(PAID_MODULES)(
143
+ "refuse mode, every feature held: excluding '%s' still registers none of its tools",
144
+ (excluded) => {
145
+ const enabledModules = PAID_MODULES.filter((m) => m !== excluded).join(
146
+ ",",
147
+ );
148
+ const excludedToolNames = catalogRegistrations()
149
+ .filter((reg) => reg.module === excluded)
150
+ .map((reg) => reg.name);
151
+ expect(excludedToolNames.length).toBeGreaterThan(0);
152
+
153
+ const registeredNames = withEnabledModules(enabledModules, () =>
154
+ listRegisteredTools().map((t) => t.name),
155
+ );
156
+ for (const name of excludedToolNames) {
157
+ expect(registeredNames).not.toContain(name);
158
+ }
159
+ },
160
+ );
161
+
162
+ it("refuse mode, every feature held, both modules enabled: all five register", () => {
163
+ const featureTools = catalogRegistrations()
164
+ .filter((reg) => reg.feature)
165
+ .map((reg) => reg.name);
166
+ expect(featureTools).toHaveLength(5);
167
+
168
+ const registeredNames = withEnabledModules(PAID_MODULES.join(","), () =>
169
+ listRegisteredTools().map((t) => t.name),
170
+ );
171
+ for (const name of featureTools) {
172
+ expect(registeredNames).toContain(name);
173
+ }
174
+ });
175
+ });