create-ailk 0.17.0 → 0.19.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 (193) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. package/templates/database/__tests__/build.test.ts +0 -131
@@ -7,15 +7,22 @@
7
7
  * - abuse heuristics — UA deny list, honeypot, email validation, daily cap
8
8
  *
9
9
  * Decision references:
10
- * Decision 1 — same binary, env-var transport selection (apps/mcp/src/index.ts)
10
+ * Decision 1 — same binary, env-var transport selection (apps/mcp/src/index.ts;
11
+ * the Vercel entry, apps/mcp/src/app.ts, serves HTTP only — #8070)
11
12
  * Decision 2 — configurable path, default /mcp, standalone root-mounted
12
13
  * Decision 3 — @fastify/rate-limit per-IP token bucket
13
14
  * Decision 4 — in-process abuse heuristics for write tools
14
15
  * Decision 5 — @fastify/cors open reads, allowlist writes
15
16
  *
16
- * The server is stateless: each initialize request creates a fresh
17
- * StreamableHTTPServerTransport + McpServer pair. Session IDs are not
18
- * persisted across restarts (acceptable for OSS; paid layer adds durability).
17
+ * Two modes, chosen by `config.stateless` (#8070 D1; the entries read it from
18
+ * `MCP_HTTP_STATELESS` through `readStatelessEnv`, src/boot.ts):
19
+ * - stateful (the long-running entry's default): an `initialize` creates a
20
+ * StreamableHTTPServerTransport + McpServer pair kept in an in-memory map
21
+ * keyed by its Mcp-Session-Id. Sessions live in ONE process's memory and
22
+ * are not persisted across restarts, so this mode needs a single instance.
23
+ * - stateless (the Vercel entry's default): every POST gets a fresh
24
+ * transport + server and no session ID; GET and DELETE answer 400. Any
25
+ * instance can answer any request.
19
26
  *
20
27
  * The anonymous `config.path` never reads `Authorization`. Beside it,
21
28
  * `${config.path}/workspace` serves the signed-in workspace tools (#3591 D15):
@@ -44,6 +51,7 @@ import {
44
51
  AbuseReasonCode,
45
52
  type AbuseConfig,
46
53
  } from "./abuse/index.js";
54
+ import { trustedCallerAddress } from "./request-context.js";
47
55
  import { createMcpServer } from "./server.js";
48
56
  import { createWorkspaceMcpServer } from "./workspace-server.js";
49
57
 
@@ -78,6 +86,13 @@ export const ABUSE_REFUSAL_STATUS: Record<AbuseReasonCode, 403 | 422> = {
78
86
  * worth money to abuse, so it takes the write branch — the origin allow-list,
79
87
  * the write CORS posture, and `abuseGuard.validate()` — rather than the read
80
88
  * branch's UA check alone.
89
+ *
90
+ * `create_booking` and `submit_flow_step` joined in #8071: each forwards
91
+ * caller-chosen data to the hub — a real booking on the seller's calendar
92
+ * that emails the attendee an invite, and a flow step whose completing call
93
+ * can create a Lead — so they take the same branch, each on its own daily
94
+ * cap. `__tests__/write-tool-guard.test.ts` fails when a catalog tool that
95
+ * sends a non-GET request to the hub is missing from this set.
81
96
  */
82
97
  // Any name added here MUST be enforced through the batch-aware path in
83
98
  // writeToolCalls() below — a request body can be a JSON-RPC ARRAY, and a
@@ -89,6 +104,8 @@ export const WRITE_TOOLS = new Set([
89
104
  "subscribe_newsletter",
90
105
  "request_callback",
91
106
  "start_purchase",
107
+ "create_booking",
108
+ "submit_flow_step",
92
109
  ]);
93
110
 
94
111
  export interface HttpTransportCorsConfig {
@@ -118,9 +135,10 @@ export interface HttpTransportConfig {
118
135
  abuse: AbuseConfig;
119
136
  /**
120
137
  * When true, use stateless transport mode: no session IDs, every request
121
- * creates a fresh McpServer + transport. Useful for testing and simple
122
- * deployments that don't need resumable sessions.
123
- * Default: false (stateful).
138
+ * creates a fresh McpServer + transport. Needed on a host that runs several
139
+ * instances (the Vercel entry), and used by tests.
140
+ * Default: false (stateful). The entries set it from `MCP_HTTP_STATELESS`
141
+ * (`readStatelessEnv`, src/boot.ts — #8070 D1).
124
142
  */
125
143
  stateless?: boolean;
126
144
  }
@@ -274,24 +292,25 @@ function registerWorkspaceRoute(
274
292
  }
275
293
 
276
294
  /**
277
- * Build and return a configured Fastify instance that exposes the MCP HTTP
278
- * transport at `config.path`. The instance is not yet listening — callers
279
- * call `.listen()` or `.inject()` (tests).
295
+ * Register the MCP HTTP transport at `config.path` on an existing Fastify
296
+ * instance: CORS, the per-IP rate limit, the abuse guard, the anonymous MCP
297
+ * route and the signed-in workspace route (#8070 D2). The caller owns the
298
+ * instance — and so its options, such as the Vercel entry's `trustProxy`
299
+ * (src/serverless-app.ts).
280
300
  *
301
+ * @param app - The Fastify instance to register on (not yet listening)
281
302
  * @param config - HTTP transport configuration
282
- * @returns Configured Fastify instance (ready to listen or inject)
283
303
  */
284
- export async function buildHttpTransportApp(
304
+ export async function registerHttpTransport(
305
+ app: FastifyInstance,
285
306
  config: HttpTransportConfig,
286
- ): Promise<FastifyInstance> {
307
+ ): Promise<void> {
287
308
  const mcpPath = config.path ?? "/mcp";
288
309
  const abuseGuard = new AbuseGuard(config.abuse);
289
310
 
290
311
  // Active transports keyed by session ID (stateful mode only).
291
312
  const transports = new Map<string, StreamableHTTPServerTransport>();
292
313
 
293
- const app = Fastify({ logger: false });
294
-
295
314
  // ── CORS ──────────────────────────────────────────────────────────────────
296
315
  // Read tools: open (origin: true — reflect the caller's Origin).
297
316
  // Write tools: enforced per-request in the preHandler below.
@@ -373,83 +392,93 @@ export async function buildHttpTransportApp(
373
392
  }
374
393
  }
375
394
  },
376
- handler: async (request, reply) => {
377
- const body = request.body as unknown;
378
- const sessionId = request.headers["mcp-session-id"] as string | undefined;
379
-
380
- try {
381
- let transport: StreamableHTTPServerTransport;
382
-
383
- if (config.stateless) {
384
- // Stateless mode: each request gets a fresh transport + server.
385
- // enableJsonResponse: true ensures plain JSON responses (no SSE).
386
- // sessionIdGenerator is intentionally omitted (stateless = no sessions).
387
- transport = new StreamableHTTPServerTransport({
388
- enableJsonResponse: true,
389
- });
390
- const server = createMcpServer();
391
- // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
392
- // as `(() => void) | undefined`, which conflicts with Transport.onclose
393
- // under exactOptionalPropertyTypes: true. The runtime behaviour is
394
- // identical; the cast is safe.
395
- await server.connect(transport as unknown as Transport);
396
- try {
397
- await transport.handleRequest(request.raw, reply.raw, body);
398
- } finally {
399
- // Close the per-request transport + server to release internal
400
- // handles. Without this, the MCP SDK's transport leaves open
401
- // sockets that surface as `socket.destroySoon is not a function`
402
- // during Jest teardown on Node 20.x (@hono/node-server compat).
403
- await transport.close().catch(() => undefined);
404
- await server.close().catch(() => undefined);
395
+ handler: async (request, reply) =>
396
+ // The lead-write tools read this address (#8073) through
397
+ // `visitorForwardHeadersForThisCall()` while the SDK dispatches their
398
+ // call, somewhere inside `transport.handleRequest()` below — the whole
399
+ // handler body runs inside the store so every path reaches it.
400
+ trustedCallerAddress.run(request.ip, async () => {
401
+ const body = request.body as unknown;
402
+ const sessionId = request.headers["mcp-session-id"] as
403
+ | string
404
+ | undefined;
405
+
406
+ try {
407
+ let transport: StreamableHTTPServerTransport;
408
+
409
+ if (config.stateless) {
410
+ // Stateless mode: each request gets a fresh transport + server.
411
+ // enableJsonResponse: true ensures plain JSON responses (no SSE).
412
+ // sessionIdGenerator is intentionally omitted (stateless = no sessions).
413
+ transport = new StreamableHTTPServerTransport({
414
+ enableJsonResponse: true,
415
+ });
416
+ const server = createMcpServer();
417
+ // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
418
+ // as `(() => void) | undefined`, which conflicts with Transport.onclose
419
+ // under exactOptionalPropertyTypes: true. The runtime behaviour is
420
+ // identical; the cast is safe.
421
+ await server.connect(transport as unknown as Transport);
422
+ try {
423
+ await transport.handleRequest(request.raw, reply.raw, body);
424
+ } finally {
425
+ // Close the per-request transport + server to release internal
426
+ // handles. Without this, the MCP SDK's transport leaves open
427
+ // sockets that surface as `socket.destroySoon is not a function`
428
+ // during Jest teardown on Node 20.x (@hono/node-server compat).
429
+ await transport.close().catch(() => undefined);
430
+ await server.close().catch(() => undefined);
431
+ }
432
+ return;
405
433
  }
406
- return;
407
- }
408
-
409
- if (sessionId && transports.has(sessionId)) {
410
- // Existing session — reuse transport
411
- transport = transports.get(sessionId)!;
412
- } else if (!sessionId && isInitializeRequest(body)) {
413
- // New initialization — create a stateful transport + fresh server
414
- transport = new StreamableHTTPServerTransport({
415
- sessionIdGenerator: () => randomUUID(),
416
- onsessioninitialized: (sid) => {
417
- transports.set(sid, transport);
418
- },
419
- });
420
434
 
421
- transport.onclose = () => {
422
- const sid = transport.sessionId;
423
- if (sid) transports.delete(sid);
424
- };
425
-
426
- const server = createMcpServer();
427
- // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
428
- // as `(() => void) | undefined`, which conflicts with Transport.onclose
429
- // under exactOptionalPropertyTypes: true. The runtime behaviour is
430
- // identical; the cast is safe.
431
- await server.connect(transport as unknown as Transport);
432
- } else {
433
- reply.code(400).send({
434
- jsonrpc: "2.0",
435
- error: { code: -32000, message: "Bad Request: no valid session" },
436
- id: null,
437
- });
438
- return;
439
- }
435
+ if (sessionId && transports.has(sessionId)) {
436
+ // Existing session — reuse transport
437
+ transport = transports.get(sessionId)!;
438
+ } else if (!sessionId && isInitializeRequest(body)) {
439
+ // New initialization — create a stateful transport + fresh server
440
+ transport = new StreamableHTTPServerTransport({
441
+ sessionIdGenerator: () => randomUUID(),
442
+ onsessioninitialized: (sid) => {
443
+ transports.set(sid, transport);
444
+ },
445
+ });
446
+
447
+ transport.onclose = () => {
448
+ const sid = transport.sessionId;
449
+ if (sid) transports.delete(sid);
450
+ };
451
+
452
+ const server = createMcpServer();
453
+ // Cast needed: SDK's StreamableHTTPServerTransport.onclose is typed
454
+ // as `(() => void) | undefined`, which conflicts with Transport.onclose
455
+ // under exactOptionalPropertyTypes: true. The runtime behaviour is
456
+ // identical; the cast is safe.
457
+ await server.connect(transport as unknown as Transport);
458
+ } else {
459
+ reply.code(400).send({
460
+ jsonrpc: "2.0",
461
+ error: {
462
+ code: -32000,
463
+ message: "Bad Request: no valid session",
464
+ },
465
+ id: null,
466
+ });
467
+ return;
468
+ }
440
469
 
441
- await transport.handleRequest(request.raw, reply.raw, body);
442
- } catch (err) {
443
- if (!reply.sent) {
444
- reply.code(500).send({
445
- jsonrpc: "2.0",
446
- error: { code: -32603, message: "Internal server error" },
447
- id: null,
448
- });
470
+ await transport.handleRequest(request.raw, reply.raw, body);
471
+ } catch (err) {
472
+ if (!reply.sent) {
473
+ reply.code(500).send({
474
+ jsonrpc: "2.0",
475
+ error: { code: -32603, message: "Internal server error" },
476
+ id: null,
477
+ });
478
+ }
479
+ throw err;
449
480
  }
450
- throw err;
451
- }
452
- },
481
+ }),
453
482
  });
454
483
 
455
484
  // DELETE — session termination
@@ -465,7 +494,22 @@ export async function buildHttpTransportApp(
465
494
 
466
495
  // ── Signed-in workspace route (#3591 D15) ─────────────────────────────────
467
496
  registerWorkspaceRoute(app, `${mcpPath}/workspace`, abuseGuard);
497
+ }
468
498
 
499
+ /**
500
+ * Build and return a configured Fastify instance that exposes the MCP HTTP
501
+ * transport at `config.path`. The instance is not yet listening — callers
502
+ * call `.listen()` or `.inject()` (tests). Sets no `trustProxy`, so
503
+ * `request.ip` is the socket's address.
504
+ *
505
+ * @param config - HTTP transport configuration
506
+ * @returns Configured Fastify instance (ready to listen or inject)
507
+ */
508
+ export async function buildHttpTransportApp(
509
+ config: HttpTransportConfig,
510
+ ): Promise<FastifyInstance> {
511
+ const app = Fastify({ logger: false });
512
+ await registerHttpTransport(app, config);
469
513
  return app;
470
514
  }
471
515
 
@@ -9,155 +9,48 @@
9
9
  * HTTP transport configuration (all optional, shown with defaults):
10
10
  * MCP_HTTP_PORT=8080
11
11
  * MCP_HTTP_PATH=/mcp
12
+ * MCP_HTTP_STATELESS=false (true = no sessions; any instance answers)
12
13
  * MCP_RATE_LIMIT_READ_PER_MIN=60
13
14
  * MCP_RATE_LIMIT_WRITE_PER_MIN=10
14
15
  * MCP_CORS_WRITE_ALLOWED_ORIGINS= (empty = no cross-origin writes)
15
16
  * MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP=50
16
17
  * MCP_ABUSE_START_PURCHASE_DAILY_CAP=20
18
+ * MCP_ABUSE_CREATE_BOOKING_DAILY_CAP=20
19
+ * MCP_ABUSE_SUBMIT_FLOW_STEP_DAILY_CAP=50
17
20
  * MCP_ABUSE_BLOCKED_USER_AGENTS= (comma-separated substrings)
18
21
  *
19
22
  * Content adapter (all optional; `CONTENT_ADAPTER` default: mdx):
20
23
  * CONTENT_ADAPTER=mdx|projection|database
21
24
  * DATABASE_URL= (required only when CONTENT_ADAPTER=database)
25
+ *
26
+ * The boot sequence and the HTTP env reading live in `boot.ts`, shared with
27
+ * the Vercel entry `app.ts` (#8070 D2).
22
28
  */
23
29
 
24
30
  import {
25
- initMcpCommerceClaim,
26
- startMcpClaimRefresh,
27
- } from "./lib/commerce-claim.js";
28
- import {
29
- initPublicWorkspace,
30
- registerLicenseKeyStore,
31
- } from "./lib/topology.generated.js";
32
-
33
- // Resolves the MCP server's own workspace ONCE, before either transport
34
- // branch below — a no-op unless CONTENT_ADAPTER=database (#5876 spec D4/D6).
35
- // This is the one `await` that covers both arms: `getMcpContentAdapter()`
36
- // (content-adapter.ts) stays synchronous and reads the memoised result.
37
- //
38
- // A REJECTION here (a transient DB error — unreachable at boot, a migration
39
- // in progress, a bad DATABASE_URL) is caught rather than left to crash the
40
- // process: every OTHER tool (lead capture, scheduling, …) has nothing to do
41
- // with the content adapter and must keep serving. The module's un-resolved
42
- // `{ kind: "not-required" }` state is left in place on a caught failure —
43
- // the SAME fail-closed shape an ambiguous workspace count already produces
44
- // (D5) — so content tools serve their empty/not-found shape rather than the
45
- // whole server going dark over one adapter's connectivity.
46
- try {
47
- await initPublicWorkspace();
48
- } catch (err) {
49
- console.error(
50
- "[mcp] initPublicWorkspace() failed — content tools will serve their " +
51
- "fail-closed empty shape under CONTENT_ADAPTER=database until this " +
52
- "resolves; every other tool is unaffected:",
53
- err,
54
- );
55
- }
56
-
57
- // Registers the renewed licence key's durable home (#6677 DD-6) BEFORE the
58
- // commerce claim below memoises the licence: with a database, the key a
59
- // sibling app renewed lives in one SiteSetting row, and the claim must read
60
- // it. Never throws; without DATABASE_URL the file store stays in place.
61
- await registerLicenseKeyStore();
62
-
63
- // Resolves the deployment's `commerce` add-on claim at boot, before either
64
- // transport branch — the sibling of the workspace resolution above and the
65
- // same #5876 idiom (#5971 spec D2). Without this await the memoised claim is
66
- // never resolved and `start_purchase` is invisible on a KEYED site too. The
67
- // same single licence read now also resolves the licence features the
68
- // deployment holds, which gate the five `feature`-tagged tools (#7136).
69
- // The re-read loop below runs every `MCP_CLAIM_REFRESH_MS` thereafter
70
- // (#7156), so the server's origin agrees with the manifest route's origin
71
- // response within that bound; the route answers
72
- // `cache-control: public, max-age=86400`, so a downstream cache may serve an
73
- // older manifest for up to 24 hours.
74
- //
75
- // A REJECTION is caught for the same reason: a licence read that fails must
76
- // not take the other 19 tools down. `initMcpCommerceClaim()` already resolves
77
- // to the fail-closed "unclaimed" internally, so this catch is belt-and-braces
78
- // for anything thrown outside its own try.
79
- try {
80
- await initMcpCommerceClaim();
81
- } catch (err) {
82
- console.error(
83
- "[mcp] initMcpCommerceClaim() failed — start_purchase stays unregistered " +
84
- "(fail-closed) until this resolves; every other tool is unaffected:",
85
- err,
86
- );
87
- }
31
+ bootMcp,
32
+ httpTransportConfigFromEnv,
33
+ readStatelessEnv,
34
+ } from "./boot.js";
88
35
 
89
- // Starts the bounded re-read (#7156 spec D1), after the boot read resolves so
90
- // the first re-read never races it. Never throws; the timer is unref'd.
91
- startMcpClaimRefresh();
92
-
93
- // The one-release warning cycle (#7136): once per process, whichever
94
- // transport follows, one stderr line per tool served without its licence
95
- // feature. After the claim resolves, or it would warn for every tool.
96
- //
97
- // A throw here must not stop the server (#7156 spec D2, L1) — only the call
98
- // is guarded, not the import: if `./server.js` fails to load, both transport
99
- // branches load it again and the server could not start anyway.
100
- const { reportUnlicensedProTools } = await import("./server.js");
101
- try {
102
- reportUnlicensedProTools();
103
- } catch (err) {
104
- console.error(
105
- "[mcp] reportUnlicensedProTools() failed — the licence boot warning was " +
106
- "not written; every tool is served as registered:",
107
- err,
108
- );
109
- }
36
+ // The workspace, the licence-key store, the commerce claim, its re-read loop
37
+ // and the licence boot warning — before either transport branch below.
38
+ await bootMcp();
110
39
 
111
40
  const transport = process.env["MCP_TRANSPORT"] ?? "stdio";
112
41
 
113
42
  if (transport === "http") {
114
43
  const { startHttpTransport } = await import("./http-transport.js");
115
44
 
116
- const port = parseInt(process.env["MCP_HTTP_PORT"] ?? "8080", 10);
117
- const path = process.env["MCP_HTTP_PATH"] ?? "/mcp";
118
- const readPerMin = parseInt(
119
- process.env["MCP_RATE_LIMIT_READ_PER_MIN"] ?? "60",
120
- 10,
121
- );
122
- const writePerMin = parseInt(
123
- process.env["MCP_RATE_LIMIT_WRITE_PER_MIN"] ?? "10",
124
- 10,
125
- );
126
- const writeOriginsRaw = process.env["MCP_CORS_WRITE_ALLOWED_ORIGINS"] ?? "";
127
- const writeAllowedOrigins = writeOriginsRaw
128
- ? writeOriginsRaw.split(",").map((o) => o.trim())
129
- : [];
130
- // Both caps go through readPositiveIntEnv rather than a bare parseInt: a
131
- // BLANK env var would otherwise yield NaN and silently disable the cap
132
- // (#5971 security review round 2).
133
- const {
134
- readPositiveIntEnv,
135
- DEFAULT_CAPTURE_LEAD_DAILY_CAP,
136
- DEFAULT_START_PURCHASE_DAILY_CAP,
137
- } = await import("./abuse/index.js");
138
- const captureLeadDailyCapPerIp = readPositiveIntEnv(
139
- "MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
140
- DEFAULT_CAPTURE_LEAD_DAILY_CAP,
141
- );
142
- const startPurchaseDailyCapPerIp = readPositiveIntEnv(
143
- "MCP_ABUSE_START_PURCHASE_DAILY_CAP",
144
- DEFAULT_START_PURCHASE_DAILY_CAP,
145
- );
146
- const blockedUserAgents = process.env["MCP_ABUSE_BLOCKED_USER_AGENTS"] ?? "";
147
-
45
+ const config = httpTransportConfigFromEnv();
148
46
  await startHttpTransport({
149
- port,
150
- path,
151
- rateLimits: { readPerMin, writePerMin },
152
- cors: { writeAllowedOrigins },
153
- abuse: {
154
- captureLeadDailyCapPerIp,
155
- startPurchaseDailyCapPerIp,
156
- blockedUserAgents,
157
- },
47
+ ...config,
48
+ stateless: readStatelessEnv(false),
158
49
  });
159
50
 
160
- console.log(`MCP HTTP transport listening on port ${port} at ${path}`);
51
+ console.log(
52
+ `MCP HTTP transport listening on port ${config.port} at ${config.path}`,
53
+ );
161
54
  } else {
162
55
  // Default: stdio transport
163
56
  const { StdioServerTransport } =
@@ -20,8 +20,6 @@
20
20
  * Session/scope: none — read-only filesystem access via content adapter.
21
21
  */
22
22
 
23
- import { join } from "node:path";
24
-
25
23
  import {
26
24
  READINESS_SCAN_TYPES,
27
25
  chassisSectionIds,
@@ -44,7 +42,10 @@ import {
44
42
  pageTypeRegistry,
45
43
  } from "@working-theory/schema";
46
44
 
47
- import { getMcpContentAdapter } from "./content-adapter.js";
45
+ import {
46
+ getMcpContentAdapter,
47
+ mcpContentRoot,
48
+ } from "./content-adapter.js";
48
49
  import { resolveLocale } from "./resolve-locale.js";
49
50
  import { readStripeTaxSignal } from "./stripe-tax-signal.js";
50
51
 
@@ -221,10 +222,10 @@ async function scanSitePages(
221
222
  * Builds an `AuditInput` for a single page.
222
223
  *
223
224
  * Accepts an optional `contentRoot` override for testing. When omitted,
224
- * resolves the content root relative to cwd (supports ESM runtime).
225
+ * resolves it through `mcpContentRoot()` (#8070 D3).
225
226
  *
226
227
  * `projectRoot` is the matching seam for the project.yaml the Stripe-Tax signal
227
- * is read from (#5949 D9); it likewise defaults to the process cwd.
228
+ * is read from (#5949 D9); it defaults to the process cwd.
228
229
  */
229
230
  export async function buildSinglePageAuditInput(
230
231
  input: SinglePageInput,
@@ -244,7 +245,7 @@ export async function buildSinglePageAuditInput(
244
245
  throw new Error("Either slug or url is required.");
245
246
  }
246
247
 
247
- const root = contentRoot ?? join(process.cwd(), "content");
248
+ const root = contentRoot ?? mcpContentRoot();
248
249
  const locale = resolveLocale(input.locale);
249
250
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
250
251
 
@@ -15,7 +15,7 @@
15
15
  * `projection` paths below are byte-identical to before this issue — no
16
16
  * `workspaceId`, no `await import()`, no workspace read (D6, NFR-1/NFR-2). On
17
17
  * `database`, this seam reads `mcpPublicWorkspace()` — the resolution
18
- * `apps/mcp/src/index.ts` already awaited once at bootstrap
18
+ * `bootMcp()` (`apps/mcp/src/boot.ts`) already awaited once at bootstrap
19
19
  * (`public-workspace.ts`, D4) — and forwards `workspaceId` + `prisma` to the
20
20
  * resolver on a resolved deployment; every other arm (unresolved, or a tool
21
21
  * invoked before bootstrap resolved) returns the fail-closed
@@ -27,10 +27,12 @@
27
27
  *
28
28
  * The `contentRoot` argument is the test-override seam every tool already
29
29
  * exposes as its optional second parameter — tests inject a fixture root
30
- * through it, and that keeps working unchanged.
30
+ * through it, and that keeps working unchanged. Absent, every tool and
31
+ * `audit-single-page` take {@link mcpContentRoot} (#8070 D3).
31
32
  */
32
33
 
33
- import { join } from "node:path";
34
+ import { existsSync } from "node:fs";
35
+ import { join, resolve } from "node:path";
34
36
 
35
37
  import {
36
38
  resolveContentAdapter,
@@ -46,12 +48,39 @@ import {
46
48
  import { publicWorkspace } from "./topology.generated.js";
47
49
 
48
50
  export interface McpContentAdapterOptions {
49
- /** Filesystem content root. Defaults to `<cwd>/content` — the tools' prior default. */
51
+ /** Filesystem content root. Defaults to {@link mcpContentRoot}. */
50
52
  contentRoot?: string;
51
53
  /** Locale already resolved against the project allowlist (`resolveLocale()`). */
52
54
  locale?: string;
53
55
  }
54
56
 
57
+ /**
58
+ * The MCP server's content root — the ONE place apps/mcp derives it (#8070 D3).
59
+ *
60
+ * In order: `AILK_CONTENT_ROOT` when set and non-empty (absolute, or resolved
61
+ * against the working directory); else `<cwd>/content` when it exists (the
62
+ * server started from the repo root); else `<cwd>/../../content` when it
63
+ * exists (the server started from `apps/mcp` — a Vercel project whose root
64
+ * directory is `apps/mcp`); else `<cwd>/content`, the prior default, so a
65
+ * missing root keeps serving the same empty result it always did.
66
+ *
67
+ * Read on every call, never cached: it is two `existsSync` probes, and a
68
+ * cached value would pin the first working directory a test or a host used.
69
+ */
70
+ export function mcpContentRoot(): string {
71
+ const cwd = process.cwd();
72
+ const override = process.env["AILK_CONTENT_ROOT"]?.trim();
73
+ if (override) return resolve(cwd, override);
74
+
75
+ const local = join(cwd, "content");
76
+ if (existsSync(local)) return local;
77
+
78
+ const repo = join(cwd, "..", "..", "content");
79
+ if (existsSync(repo)) return repo;
80
+
81
+ return local;
82
+ }
83
+
55
84
  /**
56
85
  * Returns the `ContentAdapter` the environment selects, rooted at the caller's
57
86
  * content root. The resolver memoises per distinct option set, so this is safe
@@ -60,7 +89,7 @@ export interface McpContentAdapterOptions {
60
89
  export function getMcpContentAdapter(
61
90
  options: McpContentAdapterOptions = {},
62
91
  ): ContentAdapter {
63
- const contentRoot = options.contentRoot ?? join(process.cwd(), "content");
92
+ const contentRoot = options.contentRoot ?? mcpContentRoot();
64
93
  const defaultLocale = options.locale;
65
94
 
66
95
  if (resolvedAdapterKind() !== "database") {
@@ -4,6 +4,8 @@
4
4
  // every scaffold rewrites it. A hub resolves its workspace through the
5
5
  // database; a spoke (`--topology spoke`) has none, so its content tools
6
6
  // read the spoke's own MDX and its writes go to the hub at AILK_API_URL.
7
+ // UNSERVED_MCP_TOOLS names the tools this server does not register on this
8
+ // topology (#8070).
7
9
  import {
8
10
  initMcpPublicWorkspace,
9
11
  mcpPublicWorkspace,
@@ -14,6 +16,7 @@ export { registerLicenseKeyStore } from "./license-key-store.js";
14
16
  export type { McpWorkspaceResolution };
15
17
 
16
18
  export const TOPOLOGY: "hub" | "spoke" = "hub";
19
+ export const UNSERVED_MCP_TOOLS: readonly string[] = [];
17
20
 
18
21
  export async function initPublicWorkspace(): Promise<void> {
19
22
  await initMcpPublicWorkspace();
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-request context for @working-theory/mcp, shared by the HTTP transport
3
+ * and the MCP lead-write tools (#8073).
4
+ *
5
+ * Deliberately dependency-free beyond `@working-theory/api-client` (already
6
+ * loaded by every tool): `http-transport.ts` imports Fastify and the full
7
+ * HTTP stack, and `src/index.ts`'s stdio branch dynamic-imports `server.js`
8
+ * specifically to avoid loading that stack for a local stdio caller. A tool
9
+ * importing `trustedCallerAddress` straight from `http-transport.ts` would
10
+ * pull Fastify into every stdio boot and, worse, close an import cycle
11
+ * (`http-transport.ts` → `server.ts` → `tools/*.ts` → `http-transport.ts`).
12
+ * This module sits below both: `http-transport.ts` populates the store,
13
+ * the tools read it, neither imports the other.
14
+ */
15
+
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+
18
+ import { visitorForwardHeaders } from "@working-theory/api-client";
19
+
20
+ /**
21
+ * The caller address the transport trusted for the POST request currently
22
+ * being handled — `request.ip`, Fastify's own `trustProxy`-resolved value
23
+ * (the Vercel entry trusts exactly one hop, #8070 D5), never the raw,
24
+ * caller-forgeable leftmost `X-Forwarded-For` entry. Populated by
25
+ * `http-transport.ts` for the duration of one `transport.handleRequest()`
26
+ * call and read by the three MCP lead-write tools (#8073) so they can
27
+ * forward it to the hub without the tool gaining an identity argument.
28
+ * `undefined` outside an HTTP request — stdio has no client address to
29
+ * forward.
30
+ */
31
+ export const trustedCallerAddress = new AsyncLocalStorage<string>();
32
+
33
+ /**
34
+ * The two visitor-forwarding headers (`x-ailk-visitor-ip`,
35
+ * `x-ailk-visitor-forward-key`) for the request this call is running inside,
36
+ * or `undefined` when there is no trusted address for this call (not an HTTP
37
+ * request), no `AILK_VISITOR_FORWARD_KEY`, or `visitorForwardHeaders` itself
38
+ * declines (an unsafe transport, an unsafe key value). Read by the lead-write
39
+ * tools right before they call the hub (#8073).
40
+ */
41
+ export function visitorForwardHeadersForThisCall():
42
+ | Record<string, string>
43
+ | undefined {
44
+ const address = trustedCallerAddress.getStore();
45
+ if (!address) return undefined;
46
+ return visitorForwardHeaders(
47
+ {
48
+ get: (name: string) => (name === "x-forwarded-for" ? address : null),
49
+ },
50
+ process.env.AILK_VISITOR_FORWARD_KEY,
51
+ );
52
+ }