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
@@ -11,6 +11,10 @@
11
11
  * (#5971, superseding S15a / issue #610). The describe block at the end of
12
12
  * this file pins the registration half from the bootstrap's own side.
13
13
  *
14
+ * #7136: the five licence-feature-tagged tools — registration in both gate
15
+ * modes, the once-per-boot warning (`reportUnlicensedProTools`), and its
16
+ * place in `index.ts`'s boot order.
17
+ *
14
18
  * Strategy: createMcpServer() returns the McpServer instance before connecting
15
19
  * to a transport. Tests inspect server._registeredTools (plain object in SDK
16
20
  * v1.x — no public API exists for listing tools without a live transport).
@@ -18,11 +22,20 @@
18
22
  * casts below. Pinned to @modelcontextprotocol/sdk ^1.29.0.
19
23
  */
20
24
 
25
+ import { readFileSync } from "node:fs";
26
+ import { join } from "node:path";
27
+
21
28
  import {
22
29
  __setMcpCommerceClaimForTest,
30
+ __setMcpFeatureGateForTest,
31
+ __setMcpHeldFeaturesForTest,
23
32
  resetMcpCommerceClaim,
24
33
  } from "../src/lib/commerce-claim.js";
25
- import { createMcpServer, listRegisteredTools } from "../src/server.js";
34
+ import {
35
+ createMcpServer,
36
+ listRegisteredTools,
37
+ reportUnlicensedProTools,
38
+ } from "../src/server.js";
26
39
 
27
40
  describe("createMcpServer", () => {
28
41
  it("creates an McpServer without throwing", () => {
@@ -166,3 +179,151 @@ describe("start_purchase registration — gated on the commerce claim (#5971)",
166
179
  expect(keyed).toEqual([...keyless, "start_purchase"].sort());
167
180
  });
168
181
  });
182
+
183
+ describe("the five licence-feature tools — one-release warning cycle (#7136)", () => {
184
+ const FLOW_PAIR = ["get_flow", "submit_flow_step"];
185
+ const BOOKING_TRIO = [
186
+ "list_availability",
187
+ "get_event_meta",
188
+ "create_booking",
189
+ ];
190
+ const FIVE = [...FLOW_PAIR, ...BOOKING_TRIO];
191
+ const FEATURE_OF: Record<string, string> = {
192
+ get_flow: "flows-deliverables",
193
+ submit_flow_step: "flows-deliverables",
194
+ list_availability: "scheduling",
195
+ get_event_meta: "scheduling",
196
+ create_booking: "scheduling",
197
+ };
198
+
199
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
200
+
201
+ beforeEach(() => {
202
+ process.env.AILK_ENABLED_MODULES = "scheduling,signal-collectors";
203
+ });
204
+
205
+ afterEach(() => {
206
+ resetMcpCommerceClaim();
207
+ jest.restoreAllMocks();
208
+ });
209
+
210
+ afterAll(() => {
211
+ if (priorEnabledModules === undefined) {
212
+ delete process.env.AILK_ENABLED_MODULES;
213
+ } else {
214
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
215
+ }
216
+ });
217
+
218
+ function registeredKeys(): string[] {
219
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #506/#507: mirrors the registration assertions above
220
+ return Object.keys((createMcpServer() as any)._registeredTools);
221
+ }
222
+
223
+ it("warn mode, no feature held: all five still register and serve", () => {
224
+ __setMcpHeldFeaturesForTest([]);
225
+
226
+ const listed = listRegisteredTools().map((t) => t.name);
227
+ const registered = registeredKeys();
228
+ for (const name of FIVE) {
229
+ expect(listed).toContain(name);
230
+ expect(registered).toContain(name);
231
+ }
232
+ });
233
+
234
+ it("warn mode, no feature held: one warning per tool, naming the tool, its feature and the next release", () => {
235
+ __setMcpHeldFeaturesForTest([]);
236
+ const warn = jest.fn();
237
+
238
+ const names = reportUnlicensedProTools(warn);
239
+
240
+ expect(names.slice().sort()).toEqual(FIVE.slice().sort());
241
+ expect(warn).toHaveBeenCalledTimes(5);
242
+ for (const name of FIVE) {
243
+ const message = warn.mock.calls
244
+ .map(([m]) => m as string)
245
+ .find((m) => m.includes(`"${name}"`));
246
+ expect(message).toBeDefined();
247
+ expect(message).toContain(`"${FEATURE_OF[name]}"`);
248
+ expect(message).toContain("next release stops serving it");
249
+ }
250
+ });
251
+
252
+ it("warns only for tools whose feature is unheld", () => {
253
+ const warn = jest.fn();
254
+
255
+ __setMcpHeldFeaturesForTest(["scheduling"]);
256
+ expect(reportUnlicensedProTools(warn).sort()).toEqual(
257
+ FLOW_PAIR.slice().sort(),
258
+ );
259
+ expect(warn).toHaveBeenCalledTimes(2);
260
+
261
+ warn.mockClear();
262
+ __setMcpHeldFeaturesForTest(["scheduling", "flows-deliverables"]);
263
+ expect(reportUnlicensedProTools(warn)).toEqual([]);
264
+ expect(warn).not.toHaveBeenCalled();
265
+ });
266
+
267
+ it("never warns about a tool whose module is off", () => {
268
+ process.env.AILK_ENABLED_MODULES = "signal-collectors";
269
+ __setMcpHeldFeaturesForTest([]);
270
+ const warn = jest.fn();
271
+
272
+ expect(reportUnlicensedProTools(warn).sort()).toEqual(
273
+ FLOW_PAIR.slice().sort(),
274
+ );
275
+ expect(warn).toHaveBeenCalledTimes(2);
276
+ });
277
+
278
+ it("refuse mode, no feature held: none of the five registers, and nothing is warned", () => {
279
+ __setMcpFeatureGateForTest("refuse");
280
+ __setMcpHeldFeaturesForTest([]);
281
+ const warn = jest.fn();
282
+
283
+ expect(reportUnlicensedProTools(warn)).toEqual([]);
284
+ expect(warn).not.toHaveBeenCalled();
285
+ const listed = listRegisteredTools().map((t) => t.name);
286
+ const registered = registeredKeys();
287
+ for (const name of FIVE) {
288
+ expect(listed).not.toContain(name);
289
+ expect(registered).not.toContain(name);
290
+ }
291
+ });
292
+
293
+ it("refuse mode registers exactly the tools whose feature is held", () => {
294
+ __setMcpFeatureGateForTest("refuse");
295
+ __setMcpHeldFeaturesForTest(["scheduling"]);
296
+
297
+ const registered = registeredKeys();
298
+ for (const name of BOOKING_TRIO) expect(registered).toContain(name);
299
+ for (const name of FLOW_PAIR) expect(registered).not.toContain(name);
300
+ });
301
+
302
+ it("building a server never warns — the warning is once per boot, not per HTTP session", () => {
303
+ __setMcpHeldFeaturesForTest([]);
304
+ const consoleWarn = jest
305
+ .spyOn(console, "warn")
306
+ .mockImplementation(() => undefined);
307
+
308
+ createMcpServer();
309
+ createMcpServer();
310
+
311
+ expect(consoleWarn).not.toHaveBeenCalled();
312
+ });
313
+
314
+ it("index.ts reports after the claim resolves and before either transport starts", () => {
315
+ const index = readFileSync(join(__dirname, "../src/index.ts"), "utf8");
316
+ const init = index.indexOf("await initMcpCommerceClaim()");
317
+ const report = index.indexOf("reportUnlicensedProTools(");
318
+ const transport = index.indexOf('if (transport === "http")');
319
+
320
+ expect(init).toBeGreaterThan(-1);
321
+ expect(report).toBeGreaterThan(init);
322
+ expect(transport).toBeGreaterThan(report);
323
+ });
324
+
325
+ it("server.ts never writes to stdout — the stdio transport's JSON-RPC channel", () => {
326
+ const server = readFileSync(join(__dirname, "../src/server.ts"), "utf8");
327
+ expect(server).not.toContain("console.log");
328
+ });
329
+ });
@@ -21,7 +21,10 @@
21
21
  * DATABASE_URL= (required only when CONTENT_ADAPTER=database)
22
22
  */
23
23
 
24
- import { initMcpCommerceClaim } from "./lib/commerce-claim.js";
24
+ import {
25
+ initMcpCommerceClaim,
26
+ startMcpClaimRefresh,
27
+ } from "./lib/commerce-claim.js";
25
28
  import { registerLicenseKeyStore } from "./lib/license-key-store.js";
26
29
  import { initMcpPublicWorkspace } from "./lib/public-workspace.js";
27
30
 
@@ -55,10 +58,17 @@ try {
55
58
  // it. Never throws; without DATABASE_URL the file store stays in place.
56
59
  await registerLicenseKeyStore();
57
60
 
58
- // Resolves the deployment's `commerce` add-on claim ONCE, before either
61
+ // Resolves the deployment's `commerce` add-on claim at boot, before either
59
62
  // transport branch — the sibling of the workspace resolution above and the
60
63
  // same #5876 idiom (#5971 spec D2). Without this await the memoised claim is
61
- // never resolved and `start_purchase` is invisible on a KEYED site too.
64
+ // never resolved and `start_purchase` is invisible on a KEYED site too. The
65
+ // same single licence read now also resolves the licence features the
66
+ // deployment holds, which gate the five `feature`-tagged tools (#7136).
67
+ // The re-read loop below runs every `MCP_CLAIM_REFRESH_MS` thereafter
68
+ // (#7156), so the server's origin agrees with the manifest route's origin
69
+ // response within that bound; the route answers
70
+ // `cache-control: public, max-age=86400`, so a downstream cache may serve an
71
+ // older manifest for up to 24 hours.
62
72
  //
63
73
  // A REJECTION is caught for the same reason: a licence read that fails must
64
74
  // not take the other 19 tools down. `initMcpCommerceClaim()` already resolves
@@ -74,6 +84,28 @@ try {
74
84
  );
75
85
  }
76
86
 
87
+ // Starts the bounded re-read (#7156 spec D1), after the boot read resolves so
88
+ // the first re-read never races it. Never throws; the timer is unref'd.
89
+ startMcpClaimRefresh();
90
+
91
+ // The one-release warning cycle (#7136): once per process, whichever
92
+ // transport follows, one stderr line per tool served without its licence
93
+ // feature. After the claim resolves, or it would warn for every tool.
94
+ //
95
+ // A throw here must not stop the server (#7156 spec D2, L1) — only the call
96
+ // is guarded, not the import: if `./server.js` fails to load, both transport
97
+ // branches load it again and the server could not start anyway.
98
+ const { reportUnlicensedProTools } = await import("./server.js");
99
+ try {
100
+ reportUnlicensedProTools();
101
+ } catch (err) {
102
+ console.error(
103
+ "[mcp] reportUnlicensedProTools() failed — the licence boot warning was " +
104
+ "not written; every tool is served as registered:",
105
+ err,
106
+ );
107
+ }
108
+
77
109
  const transport = process.env["MCP_TRANSPORT"] ?? "stdio";
78
110
 
79
111
  if (transport === "http") {
@@ -1,6 +1,11 @@
1
1
  /**
2
- * The deployment's `commerce` add-on claim, resolved ONCE at boot and read
3
- * synchronously thereafter (#5971 spec D2).
2
+ * The deployment's licence claims, resolved at boot and re-read every
3
+ * `MCP_CLAIM_REFRESH_MS` (#7156 spec D1), read synchronously thereafter: the
4
+ * `commerce` add-on claim (#5971 spec D2) and,
5
+ * from the same single `checkLicense()` read, the set of licence features the
6
+ * deployment holds (#7136 spec D3) — which gates the five `feature`-tagged
7
+ * catalog tools under `MCP_FEATURE_GATE_MODE`. The claim is the deployment's
8
+ * own: no tool argument, request header or session feeds it.
4
9
  *
5
10
  * Why memoised: `createMcpServer()` and `listRegisteredTools()`
6
11
  * (`apps/mcp/src/server.ts`) are synchronous and must stay that way —
@@ -20,17 +25,18 @@
20
25
  * `unclaimed` at the boundary, and this gate needs neither the stored-JWT read
21
26
  * nor the env-presence read the API twin carries.
22
27
  *
23
- * Fail closed (NFR-3): the initial state is `"unclaimed"`, and every path that
24
- * cannot POSITIVELY establish the claim — an unresolved boot, a thrown
25
- * `checkLicense()`, a revoked licence, an expired or unreadable one — leaves
26
- * it that way.
28
+ * Fail closed (NFR-3): the initial state is `"unclaimed"` with no feature
29
+ * held, and every path that cannot POSITIVELY establish the claim — an
30
+ * unresolved boot, a thrown `checkLicense()`, a revoked licence, an expired
31
+ * or unreadable one — leaves it that way.
27
32
  *
28
33
  * `apps/*` may not import `apps/*`, so the small duplication of the API-side
29
34
  * gate is the same shape `apps/mcp/src/config/modules.ts` already documents
30
35
  * for `isModuleEnabled`.
31
36
  */
32
37
 
33
- import { checkLicense } from "@working-theory/license";
38
+ import { checkLicense, type ProFeature } from "@working-theory/license";
39
+ import type { McpCatalogOptions } from "@working-theory/metadata";
34
40
 
35
41
  /** The licence `features[]` claim that turns the commerce add-on on — the
36
42
  * same literal `apps/api/src/lib/require-commerce-claim.ts` reads. */
@@ -41,17 +47,50 @@ export type McpCommerceClaim = "claimed" | "unclaimed";
41
47
 
42
48
  let claim: McpCommerceClaim = "unclaimed";
43
49
 
50
+ const NO_FEATURES: ReadonlySet<ProFeature> = new Set();
51
+
52
+ let heldFeatures: ReadonlySet<ProFeature> = NO_FEATURES;
53
+
54
+ let featureGateOverride: McpCatalogOptions["featureGate"];
55
+
44
56
  export interface InitMcpCommerceClaimOptions {
45
57
  /** Injection seam for tests — when supplied, used in place of the real
46
58
  * `checkLicense()` so no suite reaches a licence server (NFR-6). */
47
59
  checkLicense?: typeof checkLicense;
48
60
  }
49
61
 
62
+ /** One resolved read: the commerce claim and the held feature set it
63
+ * implies, from a single `checkLicense()` call. Never throws — the mapping
64
+ * both {@link initMcpCommerceClaim} and {@link startMcpClaimRefresh}'s loop
65
+ * share (#7156 spec D1): a revoked or unreadable licence resolves to the
66
+ * fail-closed `"unclaimed"` with no feature, rather than propagating. */
67
+ async function readMcpClaims(
68
+ options: InitMcpCommerceClaimOptions,
69
+ ): Promise<{ claim: McpCommerceClaim; features: ReadonlySet<ProFeature> }> {
70
+ const read = options.checkLicense ?? checkLicense;
71
+ try {
72
+ const result = await read();
73
+ // Revoked paid licences keep verifying until JWT exp (up to 24h after the
74
+ // server reports `revoked`), so a revoked licence holds no feature. See
75
+ // packages/license/src/types.ts § LicenseResult, ADR 0014.
76
+ const features =
77
+ result.revoked !== true ? new Set(result.features) : NO_FEATURES;
78
+ const resolvedClaim: McpCommerceClaim = features.has(COMMERCE_FEATURE)
79
+ ? "claimed"
80
+ : "unclaimed";
81
+ return { claim: resolvedClaim, features };
82
+ } catch {
83
+ return { claim: "unclaimed", features: NO_FEATURES };
84
+ }
85
+ }
86
+
50
87
  /**
51
- * Resolves the deployment's commerce claim, once, and memoises it for
52
- * {@link mcpCommerceClaim}'s synchronous read. Safe to call more than once
53
- * (each call re-resolves and re-memoises); production calls it exactly once,
54
- * at bootstrap (`index.ts`).
88
+ * Resolves the deployment's commerce claim and held licence features from
89
+ * ONE `checkLicense()` call, and memoises both for {@link mcpCommerceClaim}'s
90
+ * and {@link mcpHeldFeatures}'s synchronous reads. Safe to call more than
91
+ * once (each call re-resolves and re-memoises); production calls it once at
92
+ * bootstrap, and {@link startMcpClaimRefresh} re-reads through the same
93
+ * mapping thereafter.
55
94
  *
56
95
  * Never throws: a licence read that fails resolves to the fail-closed
57
96
  * `"unclaimed"` rather than propagating.
@@ -59,20 +98,112 @@ export interface InitMcpCommerceClaimOptions {
59
98
  export async function initMcpCommerceClaim(
60
99
  options: InitMcpCommerceClaimOptions = {},
61
100
  ): Promise<McpCommerceClaim> {
62
- const read = options.checkLicense ?? checkLicense;
63
- try {
64
- const result = await read();
65
- // Revoked paid licences keep verifying until JWT exp (up to 24h after the
66
- // server reports `revoked`) — the same window `lib/require-paid-tier.ts`
67
- // closes. See packages/license/src/types.ts § LicenseResult, ADR 0014.
68
- claim =
69
- result.revoked !== true && result.features.includes(COMMERCE_FEATURE)
70
- ? "claimed"
71
- : "unclaimed";
72
- } catch {
101
+ const result = await readMcpClaims(options);
102
+ claim = result.claim;
103
+ heldFeatures = result.features;
104
+ return claim;
105
+ }
106
+
107
+ /** How often the running server re-reads the deployment's licence claim
108
+ * (#7156). The server re-reads on this bound, so it agrees with the manifest
109
+ * route's origin response within it; the route answers
110
+ * `cache-control: public, max-age=86400`, so a downstream cache may serve an
111
+ * older manifest for up to 24 hours. A code constant, not an env var.
112
+ *
113
+ * The call-time half of the refuse-flip gate is `isServedNow()` in
114
+ * `server.ts` (#7315): it re-applies this memo at every claim-tagged call, so
115
+ * a session opened before a re-read still refuses within this bound. */
116
+ export const MCP_CLAIM_REFRESH_MS = 5 * 60 * 1000;
117
+
118
+ /** How many consecutive re-read intervals may find the previous read still
119
+ * unfinished before the memo falls back to no claim (#7315 L1). With
120
+ * `MCP_CLAIM_REFRESH_MS` at 5 minutes, a claim is served at most 20 minutes
121
+ * after the last read that finished. A code constant, not an env var. */
122
+ export const MCP_CLAIM_MAX_MISSED_REREADS = 3;
123
+
124
+ /** The running re-read loop's timer, if one is active — module-global so
125
+ * {@link resetMcpCommerceClaim} can stop it. */
126
+ let refreshTimer: ReturnType<typeof setInterval> | undefined;
127
+
128
+ /** Bumped by every {@link startMcpClaimRefresh} and {@link
129
+ * resetMcpCommerceClaim} call. Each loop's closure captures its own
130
+ * generation at start; a tick whose generation has gone stale (a later
131
+ * start, or a reset) discards its result instead of writing it. */
132
+ let refreshGeneration = 0;
133
+
134
+ /** Stops the running re-read loop, if any, and bumps the generation so any
135
+ * read already in flight from it is discarded on settling. */
136
+ function stopMcpClaimRefresh(): void {
137
+ if (refreshTimer !== undefined) {
138
+ clearInterval(refreshTimer);
139
+ refreshTimer = undefined;
140
+ }
141
+ refreshGeneration++;
142
+ }
143
+
144
+ /**
145
+ * Starts the bounded re-read: every `MCP_CLAIM_REFRESH_MS`, one licence read,
146
+ * skipped while the previous one is still pending. At most one loop per
147
+ * process — a second call replaces the first. The timer is unref'd, so it
148
+ * never holds a process open. Never throws.
149
+ *
150
+ * A tick that finds the previous read still unfinished increments a `missed`
151
+ * count and returns, as before, UNLESS that count has reached
152
+ * {@link MCP_CLAIM_MAX_MISSED_REREADS}: at the ceiling the memo falls back to
153
+ * no claim and a fresh read starts on the same tick (#7315 L1). Each read
154
+ * captures its own sequence number; a read writes the memo, and clears the
155
+ * in-flight flag, only while it is still the most recently started read — so
156
+ * a read the ceiling abandons can never write a stale result once a fresh
157
+ * read has replaced it.
158
+ */
159
+ export function startMcpClaimRefresh(
160
+ options: InitMcpCommerceClaimOptions = {},
161
+ ): void {
162
+ stopMcpClaimRefresh();
163
+ const generation = refreshGeneration;
164
+ let inFlight = false;
165
+ let missed = 0;
166
+ let readSeq = 0;
167
+
168
+ function startRead(): void {
169
+ inFlight = true;
170
+ missed = 0;
171
+ const seq = ++readSeq;
172
+ void readMcpClaims(options)
173
+ .then((result) => {
174
+ if (generation === refreshGeneration && seq === readSeq) {
175
+ claim = result.claim;
176
+ heldFeatures = result.features;
177
+ }
178
+ })
179
+ .finally(() => {
180
+ if (seq === readSeq) {
181
+ inFlight = false;
182
+ }
183
+ });
184
+ }
185
+
186
+ const timer = setInterval(() => {
187
+ if (generation !== refreshGeneration) return;
188
+ if (!inFlight) {
189
+ startRead();
190
+ return;
191
+ }
192
+ if (++missed < MCP_CLAIM_MAX_MISSED_REREADS) return;
73
193
  claim = "unclaimed";
194
+ heldFeatures = NO_FEATURES;
195
+ startRead();
196
+ }, MCP_CLAIM_REFRESH_MS);
197
+ timer.unref();
198
+ refreshTimer = timer;
199
+ }
200
+
201
+ /** Test-only writers are inert outside a test run (#7156 L2): each throws,
202
+ * before touching any state, unless NODE_ENV is "test" — which Jest sets. */
203
+ function assertTestOnly(name: string): void {
204
+ if (process.env["NODE_ENV"] !== "test") {
205
+ throw new Error(`${name} is test-only and cannot run outside a test`);
74
206
  }
75
- return claim;
76
207
  }
77
208
 
78
209
  /** The memoised claim, read synchronously by both gates in `server.ts` and
@@ -85,10 +216,48 @@ export function mcpCommerceClaim(): McpCommerceClaim {
85
216
  * licence read entirely. The seam every claim-state matrix in
86
217
  * `apps/mcp/__tests__` drives both halves through. */
87
218
  export function __setMcpCommerceClaimForTest(next: McpCommerceClaim): void {
219
+ assertTestOnly("__setMcpCommerceClaimForTest");
88
220
  claim = next;
89
221
  }
90
222
 
91
- /** Test-only reset — returns to the module's initial, fail-closed state. */
223
+ /** The memoised licence features this deployment holds (#7136), read
224
+ * synchronously by `server.ts`'s catalog options and boot warning. */
225
+ export function mcpHeldFeatures(): ReadonlySet<ProFeature> {
226
+ return heldFeatures;
227
+ }
228
+
229
+ /** Test-only injector — sets the memoised feature set directly, bypassing
230
+ * the licence read entirely. */
231
+ export function __setMcpHeldFeaturesForTest(
232
+ features: readonly ProFeature[],
233
+ ): void {
234
+ assertTestOnly("__setMcpHeldFeaturesForTest");
235
+ heldFeatures = new Set(features);
236
+ }
237
+
238
+ /** The test-set gate mode, or `undefined` — meaning `MCP_FEATURE_GATE_MODE`
239
+ * decides. Production never sets it. */
240
+ export function mcpFeatureGateOverride(): McpCatalogOptions["featureGate"] {
241
+ return featureGateOverride;
242
+ }
243
+
244
+ /** Test-only injector — overrides `MCP_FEATURE_GATE_MODE` for this module's
245
+ * readers, so a suite can drive refuse mode without flipping the constant. */
246
+ export function __setMcpFeatureGateForTest(
247
+ mode: McpCatalogOptions["featureGate"],
248
+ ): void {
249
+ assertTestOnly("__setMcpFeatureGateForTest");
250
+ featureGateOverride = mode;
251
+ }
252
+
253
+ /** Test-only reset — returns to the module's initial, fail-closed state: no
254
+ * commerce claim, no feature held, no gate-mode override — and stops the
255
+ * re-read loop (#7156 D1), so a suite's `afterEach(resetMcpCommerceClaim)`
256
+ * cannot leak one. */
92
257
  export function resetMcpCommerceClaim(): void {
258
+ assertTestOnly("resetMcpCommerceClaim");
259
+ stopMcpClaimRefresh();
93
260
  claim = "unclaimed";
261
+ heldFeatures = NO_FEATURES;
262
+ featureGateOverride = undefined;
94
263
  }
@@ -16,10 +16,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
16
  import {
17
17
  getMcpToolRegistrations,
18
18
  OSS_DISABLED_TOOLS,
19
+ type McpCatalogOptions,
20
+ type McpToolRegistration,
19
21
  } from "@working-theory/metadata";
20
22
 
21
23
  import { isModuleEnabled } from "./config/modules.js";
22
- import { mcpCommerceClaim } from "./lib/commerce-claim.js";
24
+ import {
25
+ mcpCommerceClaim,
26
+ mcpFeatureGateOverride,
27
+ mcpHeldFeatures,
28
+ } from "./lib/commerce-claim.js";
29
+ import { notFoundError } from "./lib/errors.js";
23
30
  import { ailkAudit } from "./tools/audit.js";
24
31
  import { ailkCaptureLead } from "./tools/capture_lead.js";
25
32
  import { ailkCreateProject } from "./tools/create_project.js";
@@ -179,8 +186,14 @@ export interface RegisteredToolDescriptor {
179
186
  * repo, and "keyless site" / "keyed site" name the LICENCE, not an env
180
187
  * allowlist.
181
188
  *
182
- * The claim is the boot-memoised `mcpCommerceClaim()`, so this stays
183
- * synchronous — see `lib/commerce-claim.ts`.
189
+ * The claim is the memoised claim, re-read every `MCP_CLAIM_REFRESH_MS`; this
190
+ * stays synchronous — see `lib/commerce-claim.ts`. {@link isServedNow}
191
+ * re-applies this filter at call time.
192
+ *
193
+ * The LICENCE-FEATURE gate (#7136) is not repeated here: it applies upstream,
194
+ * in `getMcpToolRegistrations()`, through {@link catalogOptionsForThisBoot} —
195
+ * only the catalog knows `MCP_FEATURE_GATE_MODE`. `catalog-drift.test.ts`
196
+ * pins that upstream filter from this side in every claim state.
184
197
  */
185
198
  function shouldRegister(reg: {
186
199
  name: string;
@@ -194,13 +207,62 @@ function shouldRegister(reg: {
194
207
  return true;
195
208
  }
196
209
 
210
+ /**
211
+ * Call-time gate (#7315). True when a server built NOW would register this
212
+ * tool — the exact same predicate {@link listRegisteredTools} computes, so
213
+ * the call-time answer and a fresh session's `tools/list` cannot disagree by
214
+ * construction. A session keeps the tool list it registered at `initialize`;
215
+ * this makes each listed entry refuse within `MCP_CLAIM_REFRESH_MS` of a
216
+ * re-read that drops its claim.
217
+ */
218
+ function isServedNow(reg: McpToolRegistration): boolean {
219
+ return listRegisteredTools().some((current) => current.name === reg.name);
220
+ }
221
+
197
222
  /**
198
223
  * The {@link McpCatalogOptions} this boot's claims imply — passed to
199
224
  * `getMcpToolRegistrations()` so the upstream catalog filter and
200
225
  * {@link shouldRegister} agree by construction rather than by coincidence.
226
+ * Carries the held licence features (#7136); the gate mode is the catalog's
227
+ * `MCP_FEATURE_GATE_MODE` unless a test has overridden it.
228
+ */
229
+ function catalogOptionsForThisBoot(): McpCatalogOptions {
230
+ const featureGate = mcpFeatureGateOverride();
231
+ return {
232
+ addons: { commerce: mcpCommerceClaim() === "claimed" },
233
+ features: [...mcpHeldFeatures()],
234
+ ...(featureGate ? { featureGate } : {}),
235
+ };
236
+ }
237
+
238
+ /**
239
+ * The one-release warning cycle's boot report (#7136): for every tool this
240
+ * boot serves whose licence `feature` the deployment does not hold, calls
241
+ * `warn` once, naming the tool and the feature, and returns their names.
242
+ *
243
+ * Called ONCE per process from `index.ts`, after the claim resolves — never
244
+ * from {@link createMcpServer}, which runs once per HTTP session. In refuse
245
+ * mode it finds nothing: the upstream catalog filter has already dropped
246
+ * every such tool, so it needs no mode check of its own. Defaults to
247
+ * `console.warn` — stderr, never stdout, which is the stdio transport's
248
+ * JSON-RPC channel. The message carries two catalog literals and nothing
249
+ * read from the licence.
201
250
  */
202
- function catalogOptionsForThisBoot(): { addons: { commerce: boolean } } {
203
- return { addons: { commerce: mcpCommerceClaim() === "claimed" } };
251
+ export function reportUnlicensedProTools(
252
+ warn: (message: string) => void = console.warn,
253
+ ): string[] {
254
+ const held = mcpHeldFeatures();
255
+ const unlicensed = getMcpToolRegistrations(catalogOptionsForThisBoot())
256
+ .filter(shouldRegister)
257
+ .filter((reg) => reg.feature !== undefined && !held.has(reg.feature));
258
+ for (const reg of unlicensed) {
259
+ warn(
260
+ `[mcp] licence feature "${reg.feature}" is not held by this deployment — ` +
261
+ `tool "${reg.name}" is served in this release only; the next release ` +
262
+ `stops serving it unless the licence includes "${reg.feature}".`,
263
+ );
264
+ }
265
+ return unlicensed.map((reg) => reg.name);
204
266
  }
205
267
 
206
268
  /**
@@ -246,6 +308,16 @@ export function createMcpServer(): McpServer {
246
308
  throw new Error(`MCP catalog drift: no handler for tool '${reg.name}'`);
247
309
  }
248
310
  server.tool(reg.name, reg.description, reg.inputShape, async (input) => {
311
+ if (
312
+ (reg.feature !== undefined || reg.addon !== undefined) &&
313
+ !isServedNow(reg)
314
+ ) {
315
+ return {
316
+ content: [
317
+ { type: "text", text: JSON.stringify(notFoundError("Not found")) },
318
+ ],
319
+ };
320
+ }
249
321
  const handler = await resolveHandler(reg.name);
250
322
  const result = await handler(input);
251
323
  return {
@@ -92,7 +92,7 @@ function commerceDisabledBody(): ErrorBody {
92
92
  *
93
93
  * Returns a typed error BODY rather than throwing on every failure an agent
94
94
  * can act on (FR-2) — MCP tools surface errors as their result body, the
95
- * pattern `lib/require-paid-tier.ts` already establishes.
95
+ * pattern `lib/errors.ts`'s error bodies establish.
96
96
  */
97
97
  export async function ailkPurchase(
98
98
  input: PurchaseMcpInput,
@@ -14,10 +14,13 @@
14
14
  * endpoint when MCP_PUBLIC_HTTP_URL is configured.
15
15
  * Issue #4157 — `name` resolves from SITE_NAME (env → _site.mdx → neutral
16
16
  * default) instead of a hardcoded vendor-brand literal.
17
+ * Issue #7136 — the five `feature`-tagged tools are gated on the licence
18
+ * features the route passes in (`heldFeatures`), as registration is.
17
19
  */
18
20
 
19
21
  import {
20
22
  getMcpToolCatalog,
23
+ type McpCatalogOptions,
21
24
  type McpToolCatalogEntry,
22
25
  } from "@working-theory/metadata";
23
26
 
@@ -88,6 +91,16 @@ export interface BuildMcpManifestOptions {
88
91
  * claim and passes it in. Omitted ⇒ false, the fail-closed default.
89
92
  */
90
93
  commerceClaimed?: boolean;
94
+ /**
95
+ * The licence features this deployment holds (#7136). In refuse mode a
96
+ * `feature`-tagged tool is advertised only when its feature is held — the
97
+ * same gate the MCP server's registration applies. Injected, like
98
+ * `commerceClaimed`. Omitted ⇒ none, the fail-closed default.
99
+ */
100
+ heldFeatures?: readonly string[];
101
+ /** Overrides `MCP_FEATURE_GATE_MODE` — a test seam; `route.ts` never
102
+ * passes it. */
103
+ featureGate?: McpCatalogOptions["featureGate"];
91
104
  }
92
105
 
93
106
  /** Path the MCP server is mounted at, relative to baseUrl. */
@@ -121,6 +134,8 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
121
134
  mcpEndpoint: `${opts.baseUrl}${MCP_ENDPOINT_PATH}`,
122
135
  tools: getMcpToolCatalog({
123
136
  addons: { commerce: opts.commerceClaimed === true },
137
+ features: opts.heldFeatures ?? [],
138
+ ...(opts.featureGate ? { featureGate: opts.featureGate } : {}),
124
139
  }),
125
140
  transport: {
126
141
  type: "http",