create-ailk 0.9.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 (104) hide show
  1. package/README.md +10 -5
  2. package/dist/cli.js +17 -2
  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/lib/module-license-gate.d.ts +29 -23
  10. package/dist/lib/module-license-gate.js +98 -0
  11. package/dist/module-architecture.d.ts +9 -0
  12. package/dist/module-architecture.js +46 -14
  13. package/dist/parse-args.d.ts +2 -0
  14. package/dist/parse-args.js +7 -0
  15. package/dist/programmatic.js +12 -4
  16. package/dist/starter-grid.d.ts +4 -67
  17. package/dist/starter-grid.js +6 -84
  18. package/dist/surfaces.js +2 -3
  19. package/dist/template-manifest.d.ts +1 -1
  20. package/dist/template-manifest.js +5 -5
  21. package/package.json +6 -4
  22. package/release-scope.json +25 -0
  23. package/site-templates/designs/minimalism/sample/README.md +2 -2
  24. package/site-templates/designs/minimalism/sample/content/en/HomePage/home.mdx +1 -1
  25. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +4 -3
  26. package/templates/.claude/agents/web.md +10 -8
  27. package/templates/.claude/rules/architecture.md +10 -7
  28. package/templates/.claude/settings.json +12 -0
  29. package/templates/.env.example +24 -3
  30. package/templates/CLAUDE.md +38 -22
  31. package/templates/README.md +12 -12
  32. package/templates/apps/api/jest.config.cjs +1 -0
  33. package/templates/apps/api/package.json +1 -0
  34. package/templates/apps/mcp/CLAUDE.md +23 -10
  35. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +160 -17
  36. package/templates/apps/mcp/__tests__/index.test.ts +118 -1
  37. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +425 -5
  38. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +67 -3
  39. package/templates/apps/mcp/__tests__/server.test.ts +162 -1
  40. package/templates/apps/mcp/src/index.ts +35 -3
  41. package/templates/apps/mcp/src/lib/commerce-claim.ts +193 -24
  42. package/templates/apps/mcp/src/server.ts +77 -5
  43. package/templates/apps/mcp/src/tools/purchase.ts +1 -1
  44. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +15 -0
  45. package/templates/apps/web/app/.well-known/mcp.json/route.ts +10 -3
  46. package/templates/apps/web/src/lib/commerce-claim.ts +15 -6
  47. package/templates/content/_site.mdx +23 -4
  48. package/templates/database/CHANGELOG.md +7 -0
  49. package/templates/database/package.json +1 -1
  50. package/templates/package.json +2 -0
  51. package/versions.json +34 -33
  52. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  53. package/dist/modules/commerce/0.6.1.tar.gz +0 -0
  54. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  55. package/dist/modules/docs-blog/0.6.1.tar.gz +0 -0
  56. package/dist/modules/marketing-site/0.6.1.tar.gz +0 -0
  57. package/dist/modules/minimalism-site/0.6.1.tar.gz +0 -0
  58. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  59. package/dist/modules/multi-tenant/0.6.1.tar.gz +0 -0
  60. package/dist/modules/saas-product/0.6.1.tar.gz +0 -0
  61. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  62. package/dist/modules/scheduling/0.6.1.tar.gz +0 -0
  63. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  64. package/dist/modules/signal-collectors/0.6.1.tar.gz +0 -0
  65. package/dist/modules/simple-service/0.6.1.tar.gz +0 -0
  66. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  67. package/dist/modules/workspace/0.6.1.tar.gz +0 -0
  68. package/site-templates/aliases/minimalism-site.TEMPLATE-MANIFEST.json +0 -538
  69. package/templates/.claude/skills/README.md +0 -37
  70. package/templates/.claude/skills/add-auth/SKILL.md +0 -261
  71. package/templates/.claude/skills/add-auth/references/env-vars.md +0 -98
  72. package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +0 -172
  73. package/templates/.claude/skills/add-page/SKILL.md +0 -260
  74. package/templates/.claude/skills/add-page/references/page-types.md +0 -124
  75. package/templates/.claude/skills/add-page/scripts/site-pages.mjs +0 -87
  76. package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +0 -99
  77. package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +0 -18
  78. package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +0 -43
  79. package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +0 -52
  80. package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +0 -38
  81. package/templates/.claude/skills/check-site/SKILL.md +0 -235
  82. package/templates/.claude/skills/check-site/references/common-errors.md +0 -113
  83. package/templates/.claude/skills/check-site/references/findings-glossary.md +0 -140
  84. package/templates/.claude/skills/check-site/references/severity-levels.md +0 -114
  85. package/templates/.claude/skills/fill-page/SKILL.md +0 -199
  86. package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +0 -80
  87. package/templates/.claude/skills/fill-page/references/section-sources.md +0 -41
  88. package/templates/.claude/skills/setup-database/SKILL.md +0 -156
  89. package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +0 -154
  90. package/templates/.claude/skills/setup-deploy/SKILL.md +0 -275
  91. package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +0 -192
  92. package/templates/.claude/skills/setup-deploy/references/runbook-template.md +0 -204
  93. package/templates/.claude/skills/setup-deploy/references/vercel-json-template.md +0 -98
  94. package/templates/.claude/skills/update-page/SKILL.md +0 -117
  95. package/templates/.claude/skills/update-page/scripts/update-page.ts +0 -640
  96. package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +0 -31
  97. package/templates/.claude/skills/upgrade-plan/SKILL.md +0 -121
  98. package/templates/.claude/skills/upgrade-plan/references/options.md +0 -24
  99. package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +0 -527
  100. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +0 -8
  101. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +0 -12
  102. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +0 -12
  103. package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +0 -75
  104. package/templates/apps/mcp/src/lib/require-paid-tier.ts +0 -44
@@ -21,7 +21,7 @@ apps/api/ Fastify HTTP API — lead capture, checkout, webhooks
21
21
  apps/mcp/ MCP server — agent-callable tools
22
22
  database/ Prisma schema + migrations
23
23
  content/ Site settings (_site.mdx) and MDX pages (en/<TypeName>/)
24
- .claude/ Rules, skills and agent definitions for Claude Code
24
+ .claude/ Rules, the agent definition and settings for Claude Code
25
25
  tools/ This repo's own checks (architecture, CLAUDE.md drift)
26
26
  ```
27
27
 
@@ -67,7 +67,7 @@ your module set; `pnpm architecture` enforces it, and a hook in
67
67
  | Diff hygiene when editing existing files | `.claude/rules/preserve-existing-style.md` |
68
68
  | Code style, testing, security | `CONVENTIONS.md` |
69
69
  | Environment variables and what they unlock | `.env.example` |
70
- | Deploy configuration | `apps/api/vercel.json`; the `setup-deploy` skill |
70
+ | Deploy configuration | `apps/api/vercel.json`; the `/ailk:setup-deploy` skill |
71
71
  | Verification before push | `pnpm preflight` (see § Checks) |
72
72
 
73
73
  <!-- /ailk:template id="resource-map" -->
@@ -76,18 +76,34 @@ your module set; `pnpm architecture` enforces it, and a hook in
76
76
 
77
77
  ## Skills
78
78
 
79
- In `.claude/skills/`; Claude Code finds them at session start.
79
+ The AI Launch Kit skills come from the `ailk` Claude Code plugin, not from
80
+ this repo. `.claude/settings.json` names its marketplace and enables it, so
81
+ Claude Code loads it once you trust this folder. To install it by hand, run
82
+ these two lines in a Claude Code session:
80
83
 
81
- | Skill | Use when |
82
- | ---------------- | ----------------------------------------------------------------- |
83
- | `add-page` | You need a new page, or extra JSON-LD on an existing one |
84
- | `update-page` | A page should live at a different URL, or needs a new title |
85
- | `fill-page` | A page still carries `{placeholder}` copy |
86
- | `check-site` | You want the site's citation-readiness score and findings |
87
- | `setup-database` | Leads should land in a database inbox at `/leads` |
88
- | `setup-deploy` | You are ready to deploy to Vercel |
89
- | `add-auth` | Visitors need to sign in (magic link, Google, GitHub) |
90
- | `upgrade-plan` | You are weighing Free against a paid level or the commerce add-on |
84
+ ```
85
+ /plugin marketplace add working-theory-labs/claude-plugins
86
+ /plugin install ailk@working-theory
87
+ ```
88
+
89
+ | Skill | Use when |
90
+ | ---------------------- | ----------------------------------------------------------------- |
91
+ | `/ailk:add-page` | You need a new page, or extra JSON-LD on an existing one |
92
+ | `/ailk:update-page` | A page should live at a different URL, or needs a new title |
93
+ | `/ailk:fill-page` | A page still carries `{placeholder}` copy |
94
+ | `/ailk:check-site` | You want the site's citation-readiness score and findings |
95
+ | `/ailk:setup-database` | Leads should land in a database inbox at `/leads` |
96
+ | `/ailk:setup-deploy` | You are ready to deploy to Vercel |
97
+ | `/ailk:add-auth` | Visitors need to sign in (email, Google, GitHub) |
98
+ | `/ailk:upgrade-plan` | You are weighing Free against a paid level or the commerce add-on |
99
+
100
+ A repo scaffolded by an earlier create-ailk also carries its own older
101
+ copies of these skills. Once the plugin is installed, delete those copies;
102
+ keep scaffold-commerce, which the commerce module delivers.
103
+
104
+ For design work, the third-party impeccable plugin is optional:
105
+ `/plugin marketplace add pbakaus/impeccable`, then
106
+ `/plugin install impeccable@impeccable`.
91
107
 
92
108
  <!-- /ailk:template id="skills" -->
93
109
 
@@ -95,15 +111,15 @@ In `.claude/skills/`; Claude Code finds them at session start.
95
111
 
96
112
  ## Checks
97
113
 
98
- | Command | Catches |
99
- | ---------------------- | -------------------------------------------------------- |
100
- | `pnpm preflight` | The next four and `build`, not `test`. Run before a push |
101
- | `pnpm architecture` | An import that crosses a boundary in `architecture.yaml` |
102
- | `pnpm claude-md:check` | A hand-edit inside a template-owned section of this file |
103
- | `pnpm type-check` | Type errors in every workspace |
104
- | `pnpm lint` | Lint errors in every workspace |
105
- | `pnpm test` | Failing unit tests |
106
- | `check-site` skill | Missing or invalid JSON-LD, weak citation-readiness |
114
+ | Command | Catches |
115
+ | ------------------------ | -------------------------------------------------------- |
116
+ | `pnpm preflight` | The next four and `build`, not `test`. Run before a push |
117
+ | `pnpm architecture` | An import that crosses a boundary in `architecture.yaml` |
118
+ | `pnpm claude-md:check` | A hand-edit inside a template-owned section of this file |
119
+ | `pnpm type-check` | Type errors in every workspace |
120
+ | `pnpm lint` | Lint errors in every workspace |
121
+ | `pnpm test` | Failing unit tests |
122
+ | `/ailk:check-site` skill | Missing or invalid JSON-LD, weak citation-readiness |
107
123
 
108
124
  <!-- /ailk:template id="checks" -->
109
125
 
@@ -73,7 +73,7 @@ A page marked `--shape organization-product` is in your site when you scaffolded
73
73
 
74
74
  To customize the home page, edit [`content/en/HomePage/home.mdx`](./content/en/HomePage/home.mdx). Save and reload the dev server. No build step.
75
75
 
76
- To add a new page, ask Claude Code to run the `add-page` skill — it writes the page's content file and nav link in one confirm-before-write pass, and adds a missing route with `create-ailk add-route` first. See [Skills](#skills).
76
+ To add a new page, ask Claude Code to run the `/ailk:add-page` skill — it writes the page's content file and nav link in one confirm-before-write pass, and adds a missing route with `create-ailk add-route` first. See [Skills](#skills).
77
77
 
78
78
  > `content/_site.mdx` stays at the top level (not under `content/en/`) — site identity is locale-blind.
79
79
 
@@ -200,20 +200,20 @@ Change the ranges by hand. Below 1.0 a caret range (`^0.26.0`) never crosses a m
200
200
 
201
201
  ## Skills
202
202
 
203
- This project ships Claude Code skills in `.claude/skills/`. Claude Code discovers them at session start — ask for one by name.
203
+ The AI Launch Kit skills come from the `ailk` Claude Code plugin; `.claude/settings.json` names it, and CLAUDE.md § Skills has the two install lines.
204
204
 
205
205
  <!-- generated:begin skills -->
206
206
 
207
- | Skill | What it does |
208
- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
209
- | `add-page` | Add a page this site can serve — its content file, structured data and nav link — adding a missing route with `create-ailk add-route` first. Also documents the `schema:` frontmatter override for any page. |
210
- | `update-page` | Rename a page — give it a new URL and/or a new title — in one plan-then-confirm pass: its content files, its own route folder, exact internal links and the manifest row move together, and the old URL gets a permanent redirect. |
211
- | `add-auth` | Activate auth providers — magic-link via Resend, Google OAuth, GitHub OAuth. |
212
- | `setup-deploy` | Emit deploy configuration — `vercel.json`, `.env.example`, and `RUNBOOK.md` — for a Vercel deploy. Asks where leads go (email or a database) and warns when neither is set. |
213
- | `setup-database` | Take the site from no database to a working lead inbox: provision Postgres, create the tables, pin the owner, and verify one test message lands in `/leads`. |
214
- | `check-site` | Run `ailk-audit --json` and explain the score and findings in plain language. |
215
- | `fill-page` | Fill one page's section content from a category profile: labelled sample content (tier 1) or your own first draft from a short intake (tier 2). |
216
- | `upgrade-plan` | Compare Free, Pro, Pro Plus, Agency and the commerce add-on side by side, with the prices the public pricing page publishes right now, and recommend one option for this site with a reason. |
207
+ | Skill | What it does |
208
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
209
+ | `/ailk:add-auth` | Activate the site's sign-in providers (email-and-password, Google OAuth, GitHub OAuth) and write the env vars for the ones you pick. |
210
+ | `/ailk:add-page` | Add a new page — its content file, its structured data and its nav link — in one confirm-before-write pass. |
211
+ | `/ailk:check-site` | Run `ailk-audit` on the site and explain the score and findings in plain language. It never edits files. |
212
+ | `/ailk:fill-page` | Fill one existing page's section content from a category profile: labelled sample content, or your own first draft from a short intake. |
213
+ | `/ailk:setup-database` | Take the site from no database to a working lead inbox, and prove it with one test message that must appear in the inbox. |
214
+ | `/ailk:setup-deploy` | Emit deploy configuration — `vercel.json` and `RUNBOOK.md`, plus an env checklist read from the site's own `.env.example`. |
215
+ | `/ailk:update-page` | Rename a page — its URL, its title, or both — in one plan-then-confirm pass, with a permanent redirect from the old URL. |
216
+ | `/ailk:upgrade-plan` | Explain the AI Launch Kit options side by side, recommend one option for this site, and link the public pricing page for prices. |
217
217
 
218
218
  <!-- generated:end -->
219
219
 
@@ -64,6 +64,7 @@ module.exports = {
64
64
  '^@working-theory/schema-pack-registry$': '<rootDir>/../../packages/schema-pack-registry/src/index.ts',
65
65
  '^@working-theory/schema-validator$': '<rootDir>/../../packages/schema-validator/src/index.ts',
66
66
  '^@working-theory/stripe-client$': '<rootDir>/../../packages/stripe-client/src/index.ts',
67
+ '^@working-theory/metadata$': '<rootDir>/../../packages/metadata/src/index.ts',
67
68
  '^@working-theory/validation$': '<rootDir>/../../packages/validation/src/index.ts',
68
69
  '^(\\.{1,2}/.*)\\.js$': '$1',
69
70
  },
@@ -60,6 +60,7 @@
60
60
  "@types/node": "^22.20.0",
61
61
  "@types/pg": "^8.20.0",
62
62
  "@vercel/node": "^5.8.22",
63
+ "@working-theory/metadata": "workspace:*",
63
64
  "jest": "^30.4.2",
64
65
  "pdfjs-dist": "^6.3.289",
65
66
  "prisma": "^7.8.0",
@@ -25,8 +25,8 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
25
25
  **Read (9):** `list_pages`, `read_content`, `search_content`, `get_site_info`, `get_business_hours`, `get_locations`, `get_offerings`, `get_pricing`, `get_faq`
26
26
  **Conversion-write (3):** `capture_lead`, `subscribe_newsletter`, `request_callback`
27
27
  **Maintenance (2):** `audit`, `create_project`
28
- **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366): identity-blind and free of any `requirePaidTier()` call like every tool in this list, but registered only when the signal-collectors module is present, the same shape Booking below already has
29
- **Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`)
28
+ **Flow (2):** `get_flow`, `submit_flow_step` — module-gated (`module: "signal-collectors"`, D-48, #5366) and licence-feature-tagged (`feature: "flows-deliverables"`, #7136): identity-blind like every tool in this list, registered only when the signal-collectors module is present, and gated on the deployment's `flows-deliverables` licence feature under `MCP_FEATURE_GATE_MODE`, the same shape Booking below has
29
+ **Booking (3):** `list_availability`, `get_event_meta`, `create_booking` — module-gated (`module: "scheduling"`) and licence-feature-tagged (`feature: "scheduling"`, #7136), gated on the deployment's `scheduling` licence feature under `MCP_FEATURE_GATE_MODE`
30
30
 
31
31
  ### Why the parity test counts 20 and a keyless server registers 19
32
32
 
@@ -41,11 +41,21 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
41
41
  `addon: "commerce"`, so it is the one entry whose presence depends on the
42
42
  licence — 19 without the claim, 20 with it.
43
43
  - **19 free anonymous tools**: since #5853 retired the three content-write
44
- tools, NO registered tool is gated at call time by `requirePaidTier()`, so
45
- every registered tool is free and anonymous. The five carrying `tier: "pro"`
44
+ tools, every registered tool remains anonymous — call-time gating checks a
45
+ licence claim, never an identity. The five carrying `tier: "pro"`
46
46
  (`get_flow`, `submit_flow_step`, `list_availability`, `get_event_meta`,
47
- `create_booking`) are MODULE-gated at registration, not payment-gated at
48
- call time — the shape the Flow and Booking rows above describe.
47
+ `create_booking`) are gated at REGISTRATION **and at call time** (#7315):
48
+ each needs its module, and each catalog row names the licence `feature:`
49
+ that entitles it. `start_purchase` is gated at call time by both this
50
+ server-wide gate and its own Gate 2 in `tools/purchase.ts`.
51
+ `MCP_FEATURE_GATE_MODE` (`packages/metadata`) decides what an unheld
52
+ feature does. In `"warn"` mode (this release) the five register on module
53
+ presence alone, and `index.ts` logs one boot warning (stderr) per tool
54
+ served without its feature. In `"refuse"` mode (the next release) they
55
+ also need the deployment's licence to hold the feature, and
56
+ `/.well-known/mcp.json` drops them in step. The claim is resolved at boot
57
+ and re-read every `MCP_CLAIM_REFRESH_MS` through `checkLicense()`
58
+ (`lib/commerce-claim.ts`) and fails closed.
49
59
  `index.ts` is the transport entry point and exports no tools — registration
50
60
  is entirely via `createMcpServer`.
51
61
 
@@ -69,7 +79,8 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
69
79
  `commerce` licence claim, per its `addon: "commerce"` catalog marker. A
70
80
  keyless site advertises it in neither the discovery manifest nor the running
71
81
  server's tool list, and calling it by name there returns the module-disabled
72
- `not_found` body. The claim is resolved once at boot
82
+ `not_found` body. The claim is resolved at boot and re-read every
83
+ `MCP_CLAIM_REFRESH_MS`
73
84
  (`src/lib/commerce-claim.ts`, awaited in `src/index.ts`) and read
74
85
  synchronously by both the registration filter and the handler's own
75
86
  re-check. Calls are rate-limited per IP by the AbuseGuard
@@ -111,14 +122,16 @@ Example (lead routing):
111
122
  ## What NOT to do
112
123
 
113
124
  - Don't add identity arguments to OSS tools.
114
- - Don't expose a Pro tool without its `requirePaidTier()` gate.
125
+ - Don't add a paid tool without a `feature:` marker on its catalog row — registration and the manifest gate on the deployment's claim, never on a caller-supplied key or argument.
115
126
  - Don't import `@working-theory/*` from `apps/mcp`.
116
127
  - Don't add a tool without the matching `apps/api` endpoint.
117
128
  - Don't put business logic in tool handlers — they're adapters.
118
129
 
119
- Last updated: 2026-09-17 — #5971: `start_purchase` re-enabled behind the `commerce` licence claim. `OSS_DISABLED_TOOLS` is now empty; the registered count is 19 on a keyless deployment and 20 on a commerce-claimed one; the tool's input field is `productCode` (was `priceId`), matching its HTTP twin; a per-IP daily cap (`MCP_ABUSE_START_PURCHASE_DAILY_CAP`) covers it.
130
+ Last updated: 2026-09-29 — #7136: the five `tier: "pro"` tools name their licence `feature:` (`scheduling`, `flows-deliverables`) and gate on the deployment's own claim under `MCP_FEATURE_GATE_MODE` — `"warn"` this release (served, one boot warning each), `"refuse"` the next. The unused call-time paid-tier guard is deleted.
120
131
 
121
- Prior: 2026-07-09 — corrected the tool-count explanation (18 catalog → 17 registered → 14 free anonymous) and the Pro-tool gating (registered + `requirePaidTier()`-gated, not `OSS_DISABLED_TOOLS`-disabled).
132
+ Prior: 2026-09-17 — #5971: `start_purchase` re-enabled behind the `commerce` licence claim. `OSS_DISABLED_TOOLS` is now empty; the registered count is 19 on a keyless deployment and 20 on a commerce-claimed one; the tool's input field is `productCode` (was `priceId`), matching its HTTP twin; a per-IP daily cap (`MCP_ABUSE_START_PURCHASE_DAILY_CAP`) covers it.
133
+
134
+ Prior: 2026-07-09 — corrected the tool-count explanation (18 catalog → 17 registered → 14 free anonymous) and the Pro-tool gating (registered + gated by the call-time paid-tier guard, not `OSS_DISABLED_TOOLS`-disabled).
122
135
 
123
136
  Last updated: 2026-09-17 — doc-sync: tool counts "23 catalog → 22 registered → 19 free anonymous" → "20 in TOOLS → 19 registered → 19 free anonymous", and the Pro-tools section rewritten to record that `create_page`/`update_page`/`delete_page` were RETIRED by #5853 rather than disabled (D7) — derived from `packages/metadata/src/mcp-tool-catalog.ts` and verified against `apps/mcp/src/server.ts`.
124
137
 
@@ -13,6 +13,10 @@
13
13
  * `start_purchase` visible, so comparing a claimed server against an
14
14
  * unclaimed catalog (or vice versa) would compare unlike surfaces and
15
15
  * report drift that isn't there — or, worse, miss drift that is.
16
+ * - …and under every licence-feature state × gate mode (#7136): 2 commerce
17
+ * states × {warn, refuse} × 4 held-feature sets = 16. In refuse mode a
18
+ * feature's tools are in both surfaces iff the feature is held; in warn
19
+ * mode all five are in both.
16
20
  *
17
21
  * What this does NOT assert:
18
22
  * - inputSchema equality — the server registers Zod schemas; the catalog
@@ -21,14 +25,35 @@
21
25
  */
22
26
 
23
27
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
24
- import { getMcpToolCatalog } from "@working-theory/metadata";
28
+ import {
29
+ getMcpToolCatalog,
30
+ type McpCatalogOptions,
31
+ type McpToolCatalogEntry,
32
+ } from "@working-theory/metadata";
25
33
 
26
34
  import {
35
+ MCP_CLAIM_REFRESH_MS,
27
36
  __setMcpCommerceClaimForTest,
37
+ __setMcpFeatureGateForTest,
38
+ __setMcpHeldFeaturesForTest,
39
+ initMcpCommerceClaim,
28
40
  resetMcpCommerceClaim,
41
+ startMcpClaimRefresh,
29
42
  type McpCommerceClaim,
30
43
  } from "../src/lib/commerce-claim.js";
31
- import { createMcpServer } from "../src/server.js";
44
+ import { createMcpServer, listRegisteredTools } from "../src/server.js";
45
+
46
+ /** The injected licence reader — same seam `commerce-claim.test.ts` types. */
47
+ type CheckLicenseSeam = NonNullable<
48
+ NonNullable<Parameters<typeof initMcpCommerceClaim>[0]>["checkLicense"]
49
+ >;
50
+
51
+ type LicenseShape = Awaited<ReturnType<CheckLicenseSeam>>;
52
+
53
+ /** A licence reader that resolves to the given (partial) result. */
54
+ function resolving(result: Partial<LicenseShape>): CheckLicenseSeam {
55
+ return () => Promise.resolve(result as LicenseShape);
56
+ }
32
57
 
33
58
  interface RegisteredTool {
34
59
  description?: string;
@@ -47,29 +72,70 @@ const PAID_MODULES = "scheduling,content-write,signal-collectors";
47
72
  * @modelcontextprotocol/sdk ^1.29.0 — if the SDK renames the field, change it
48
73
  * here once rather than at each call site.
49
74
  */
50
- function registeredToolsOf(
51
- server: McpServer,
52
- ): Record<string, RegisteredTool> {
75
+ function registeredToolsOf(server: McpServer): Record<string, RegisteredTool> {
53
76
  return (
54
77
  // eslint-disable-next-line no-restricted-syntax -- #506/#507: the SDK exposes no public accessor; the double assertion is confined to this one helper
55
- server as unknown as { _registeredTools: Record<string, RegisteredTool> }
56
- )._registeredTools;
78
+ (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
79
+ ._registeredTools
80
+ );
57
81
  }
58
82
 
83
+ type GateMode = NonNullable<McpCatalogOptions["featureGate"]>;
84
+
85
+ type McpFeature = NonNullable<McpToolCatalogEntry["feature"]>;
86
+
87
+ /** The five licence-feature tools, by the feature that entitles them. */
88
+ const FEATURE_TOOLS: Record<McpFeature, readonly string[]> = {
89
+ scheduling: ["list_availability", "get_event_meta", "create_booking"],
90
+ "flows-deliverables": ["get_flow", "submit_flow_step"],
91
+ };
92
+
93
+ const HELD_SETS: readonly (readonly McpFeature[])[] = [
94
+ [],
95
+ ["scheduling"],
96
+ ["flows-deliverables"],
97
+ ["scheduling", "flows-deliverables"],
98
+ ];
99
+
100
+ /** Every claim state: commerce × gate mode × held features (16). */
101
+ const CLAIM_STATES = (["unclaimed", "claimed"] as const).flatMap((claim) =>
102
+ (["warn", "refuse"] as const).flatMap((mode) =>
103
+ HELD_SETS.map(
104
+ (held) =>
105
+ [claim, mode, held] as [
106
+ McpCommerceClaim,
107
+ GateMode,
108
+ readonly McpFeature[],
109
+ ],
110
+ ),
111
+ ),
112
+ );
113
+
59
114
  /** The catalog options a given claim state implies — the same mapping
60
115
  * `server.ts` applies internally, so both surfaces are read like for like. */
61
- function catalogOptionsFor(claim: McpCommerceClaim) {
62
- return { addons: { commerce: claim === "claimed" } };
116
+ function catalogOptionsFor(
117
+ claim: McpCommerceClaim,
118
+ mode: GateMode,
119
+ held: readonly McpFeature[],
120
+ ): McpCatalogOptions {
121
+ return {
122
+ addons: { commerce: claim === "claimed" },
123
+ features: held,
124
+ featureGate: mode,
125
+ };
63
126
  }
64
127
 
65
- describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
66
- "MCP catalog ↔ server drift — commerce claim %s",
67
- (claim) => {
128
+ describe.each(CLAIM_STATES)(
129
+ "MCP catalog ↔ server drift — commerce %s, mode %s, held %j",
130
+ (claim, mode, held) => {
68
131
  const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
132
+ const options = catalogOptionsFor(claim, mode, held);
69
133
 
70
134
  beforeEach(() => {
71
135
  process.env.AILK_ENABLED_MODULES = PAID_MODULES;
72
136
  __setMcpCommerceClaimForTest(claim);
137
+ __setMcpFeatureGateForTest(mode);
138
+ __setMcpHeldFeaturesForTest(held);
73
139
  });
74
140
 
75
141
  afterEach(() => {
@@ -87,7 +153,7 @@ describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
87
153
  it("registered tool names equal catalog tool names", () => {
88
154
  const registered = registeredToolsOf(createMcpServer());
89
155
  const registeredNames = Object.keys(registered).sort();
90
- const catalogNames = getMcpToolCatalog(catalogOptionsFor(claim))
156
+ const catalogNames = getMcpToolCatalog(options)
91
157
  .map((t) => t.name)
92
158
  .sort();
93
159
  expect(registeredNames).toEqual(catalogNames);
@@ -95,7 +161,7 @@ describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
95
161
 
96
162
  it("registered tool descriptions equal catalog descriptions", () => {
97
163
  const registered = registeredToolsOf(createMcpServer());
98
- for (const entry of getMcpToolCatalog(catalogOptionsFor(claim))) {
164
+ for (const entry of getMcpToolCatalog(options)) {
99
165
  expect(registered[entry.name]).toBeDefined();
100
166
  expect(registered[entry.name]!.description).toBe(entry.description);
101
167
  }
@@ -107,10 +173,87 @@ describe.each<McpCommerceClaim>(["unclaimed", "claimed"])(
107
173
 
108
174
  expect("start_purchase" in registered).toBe(expected);
109
175
  expect(
110
- getMcpToolCatalog(catalogOptionsFor(claim)).some(
111
- (t) => t.name === "start_purchase",
112
- ),
176
+ getMcpToolCatalog(options).some((t) => t.name === "start_purchase"),
113
177
  ).toBe(expected);
114
178
  });
179
+
180
+ it("each feature's tools appear in both surfaces exactly when the gate lets them", () => {
181
+ const registered = registeredToolsOf(createMcpServer());
182
+ const catalogNames = getMcpToolCatalog(options).map((t) => t.name);
183
+
184
+ for (const [feature, tools] of Object.entries(FEATURE_TOOLS)) {
185
+ const expected =
186
+ mode === "warn" || held.includes(feature as McpFeature);
187
+ for (const name of tools) {
188
+ expect(name in registered).toBe(expected);
189
+ expect(catalogNames.includes(name)).toBe(expected);
190
+ }
191
+ }
192
+ });
115
193
  },
116
194
  );
195
+
196
+ describe("MCP catalog ↔ server agreement after a bounded re-read (#7156 AC-1)", () => {
197
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
198
+
199
+ beforeEach(() => {
200
+ process.env.AILK_ENABLED_MODULES = PAID_MODULES;
201
+ jest.useFakeTimers();
202
+ });
203
+
204
+ afterEach(() => {
205
+ resetMcpCommerceClaim();
206
+ jest.useRealTimers();
207
+ if (priorEnabledModules === undefined) {
208
+ delete process.env.AILK_ENABLED_MODULES;
209
+ } else {
210
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
211
+ }
212
+ });
213
+
214
+ it("a new server and listRegisteredTools() match the catalog once the re-read resolves", async () => {
215
+ __setMcpFeatureGateForTest("refuse");
216
+ await initMcpCommerceClaim({
217
+ checkLicense: resolving({
218
+ tier: "pro",
219
+ features: ["scheduling", "flows-deliverables", "commerce"],
220
+ }),
221
+ });
222
+
223
+ const bootOptions: McpCatalogOptions = {
224
+ addons: { commerce: true },
225
+ features: ["scheduling", "flows-deliverables"],
226
+ featureGate: "refuse",
227
+ };
228
+ const bootNames = Object.keys(registeredToolsOf(createMcpServer())).sort();
229
+ expect(bootNames).toEqual(
230
+ getMcpToolCatalog(bootOptions)
231
+ .map((t) => t.name)
232
+ .sort(),
233
+ );
234
+ expect(bootNames).toHaveLength(20);
235
+
236
+ startMcpClaimRefresh({ checkLicense: resolving({ features: [] }) });
237
+ await jest.advanceTimersByTimeAsync(MCP_CLAIM_REFRESH_MS);
238
+
239
+ const refreshedOptions: McpCatalogOptions = {
240
+ addons: { commerce: false },
241
+ features: [],
242
+ featureGate: "refuse",
243
+ };
244
+ const catalogNames = getMcpToolCatalog(refreshedOptions)
245
+ .map((t) => t.name)
246
+ .sort();
247
+ expect(catalogNames).toHaveLength(14);
248
+
249
+ const serverNames = Object.keys(
250
+ registeredToolsOf(createMcpServer()),
251
+ ).sort();
252
+ expect(serverNames).toEqual(catalogNames);
253
+
254
+ const listedNames = listRegisteredTools()
255
+ .map((t) => t.name)
256
+ .sort();
257
+ expect(listedNames).toEqual(catalogNames);
258
+ });
259
+ });
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Structural ordering guard for apps/mcp's bootstrap (#5876 spec D4, AC-3).
2
+ * Structural ordering guard for apps/mcp's bootstrap (#5876 spec D4, AC-3;
3
+ * #7156 AC-1, AC-2).
3
4
  *
4
5
  * `apps/mcp/src/index.ts` has real side effects at import time (starts a
5
6
  * transport, opens a stdio/HTTP listener) — it cannot safely be imported by
@@ -8,6 +9,9 @@
8
9
  */
9
10
  import { readFileSync } from "node:fs";
10
11
  import { resolve } from "node:path";
12
+ import vm from "node:vm";
13
+
14
+ import * as ts from "typescript";
11
15
 
12
16
  describe("apps/mcp/src/index.ts — bootstrap ordering", () => {
13
17
  const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
@@ -37,3 +41,116 @@ describe("apps/mcp/src/index.ts — bootstrap ordering", () => {
37
41
  expect(initIdx).toBeLessThan(catchIdx);
38
42
  });
39
43
  });
44
+
45
+ describe("apps/mcp/src/index.ts — the claim refresh loop starts in production (#7156 AC-1)", () => {
46
+ const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
47
+
48
+ it("starts after the boot read and before the transport branch", () => {
49
+ const initIdx = src.indexOf("await initMcpCommerceClaim()");
50
+ const callMatches = [...src.matchAll(/^startMcpClaimRefresh\(\);$/gm)];
51
+ const transportBranchIdx = src.indexOf('if (transport === "http")');
52
+
53
+ expect(initIdx).toBeGreaterThan(-1);
54
+ expect(callMatches).toHaveLength(1);
55
+ expect(transportBranchIdx).toBeGreaterThan(-1);
56
+
57
+ const callIdx = callMatches[0]!.index!;
58
+ expect(initIdx).toBeLessThan(callIdx);
59
+ expect(callIdx).toBeLessThan(transportBranchIdx);
60
+ });
61
+ });
62
+
63
+ describe("apps/mcp/src/index.ts — the boot warning cannot stop the server (#7156 AC-2, D2)", () => {
64
+ const src = readFileSync(resolve(__dirname, "../src/index.ts"), "utf-8");
65
+
66
+ /**
67
+ * The exact behavioural slice AC-2 specifies: from the last `try {` before
68
+ * the `reportUnlicensedProTools();` call statement, up to `const transport
69
+ * =`. Transpiled and run in a fresh vm context so a real throw from the
70
+ * stubbed function proves the guard, not a mock of the guard itself.
71
+ */
72
+ function guardedSlice(): string {
73
+ const callMatches = [
74
+ ...src.matchAll(/^\s*reportUnlicensedProTools\(\);$/gm),
75
+ ];
76
+ expect(callMatches).toHaveLength(1);
77
+ const callIdx = callMatches[0]!.index!;
78
+ const tryIdx = src.lastIndexOf("try {", callIdx);
79
+ const transportIdx = src.indexOf("const transport =");
80
+
81
+ expect(tryIdx).toBeGreaterThan(-1);
82
+ expect(transportIdx).toBeGreaterThan(callIdx);
83
+
84
+ const slice = src.slice(tryIdx, transportIdx);
85
+ // The extracted try block must be the one that immediately wraps the
86
+ // call, not an earlier try{}/await pair (code review, #7156): the two
87
+ // other try blocks in this file each await a boot call, so a regressed
88
+ // boundary would carry "await" into the slice. Asserting that here
89
+ // turns a boundary regression into a clear failure instead of an opaque
90
+ // vm ReferenceError from the un-injected awaited function.
91
+ expect(slice).not.toContain("await");
92
+
93
+ return slice;
94
+ }
95
+
96
+ function runGuardedSlice(
97
+ reportUnlicensedProTools: () => unknown,
98
+ errorSpy: jest.Mock,
99
+ ): void {
100
+ const slice = guardedSlice();
101
+ const { outputText } = ts.transpileModule(slice, {
102
+ compilerOptions: {
103
+ module: ts.ModuleKind.CommonJS,
104
+ target: ts.ScriptTarget.ES2020,
105
+ },
106
+ });
107
+ vm.runInNewContext(outputText, {
108
+ reportUnlicensedProTools,
109
+ console: { error: errorSpy },
110
+ });
111
+ }
112
+
113
+ it("a throw is logged to stderr and does not escape", () => {
114
+ const errorSpy = jest.fn();
115
+
116
+ expect(() =>
117
+ runGuardedSlice(() => {
118
+ throw new Error("boom");
119
+ }, errorSpy),
120
+ ).not.toThrow();
121
+
122
+ expect(errorSpy).toHaveBeenCalledTimes(1);
123
+ const call = errorSpy.mock.calls[0]!;
124
+ expect(String(call[0])).toContain("reportUnlicensedProTools");
125
+ const lastArg = call[call.length - 1];
126
+ expect(lastArg).toBeInstanceOf(Error);
127
+ expect((lastArg as Error).message).toBe("boom");
128
+ });
129
+
130
+ it("a successful call is not logged", () => {
131
+ const errorSpy = jest.fn();
132
+ const report = jest.fn(() => []);
133
+
134
+ runGuardedSlice(report, errorSpy);
135
+
136
+ expect(report).toHaveBeenCalledTimes(1);
137
+ expect(errorSpy).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("the call statement occurs exactly once, before the transport branch", () => {
141
+ const callMatches = [
142
+ ...src.matchAll(/^\s*reportUnlicensedProTools\(\);$/gm),
143
+ ];
144
+ const transportBranchIdx = src.indexOf('if (transport === "http")');
145
+
146
+ expect(callMatches).toHaveLength(1);
147
+ expect(callMatches[0]!.index!).toBeLessThan(transportBranchIdx);
148
+ });
149
+
150
+ it("logs to stderr only — the guarded slice never writes stdout", () => {
151
+ const slice = guardedSlice();
152
+
153
+ expect(slice).toContain("console.error");
154
+ expect(slice).not.toContain("console.log");
155
+ });
156
+ });