create-ailk 0.16.0 → 0.18.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 (42) hide show
  1. package/dist/lib/env-catalog.js +5 -9
  2. package/dist/spoke-checkout.d.ts +27 -2
  3. package/dist/spoke-checkout.js +207 -4
  4. package/dist/topology.d.ts +8 -0
  5. package/dist/topology.js +29 -5
  6. package/package.json +3 -3
  7. package/templates/apps/mcp/CLAUDE.md +51 -5
  8. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  9. package/templates/apps/mcp/__tests__/boot.test.ts +203 -0
  10. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  11. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  12. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  13. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  14. package/templates/apps/mcp/src/app.ts +32 -0
  15. package/templates/apps/mcp/src/boot.ts +185 -0
  16. package/templates/apps/mcp/src/http-transport.ts +39 -15
  17. package/templates/apps/mcp/src/index.ts +17 -126
  18. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  19. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  20. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  21. package/templates/apps/mcp/src/server.ts +10 -2
  22. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  23. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  24. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  25. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  26. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  27. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  28. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  29. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  30. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  31. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  32. package/templates/apps/mcp/tsconfig.json +2 -0
  33. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  34. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  35. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +18 -4
  36. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +46 -28
  37. package/templates/apps/web/lib/__tests__/relay.test.ts +13 -7
  38. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  39. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  40. package/templates/apps/web/lib/site-brand.tsx +22 -6
  41. package/templates/apps/web/lib/topology.generated.ts +3 -0
  42. package/versions.json +1 -1
@@ -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):
@@ -118,9 +125,10 @@ export interface HttpTransportConfig {
118
125
  abuse: AbuseConfig;
119
126
  /**
120
127
  * 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).
128
+ * creates a fresh McpServer + transport. Needed on a host that runs several
129
+ * instances (the Vercel entry), and used by tests.
130
+ * Default: false (stateful). The entries set it from `MCP_HTTP_STATELESS`
131
+ * (`readStatelessEnv`, src/boot.ts — #8070 D1).
124
132
  */
125
133
  stateless?: boolean;
126
134
  }
@@ -274,24 +282,25 @@ function registerWorkspaceRoute(
274
282
  }
275
283
 
276
284
  /**
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).
285
+ * Register the MCP HTTP transport at `config.path` on an existing Fastify
286
+ * instance: CORS, the per-IP rate limit, the abuse guard, the anonymous MCP
287
+ * route and the signed-in workspace route (#8070 D2). The caller owns the
288
+ * instance — and so its options, such as the Vercel entry's `trustProxy`
289
+ * (src/serverless-app.ts).
280
290
  *
291
+ * @param app - The Fastify instance to register on (not yet listening)
281
292
  * @param config - HTTP transport configuration
282
- * @returns Configured Fastify instance (ready to listen or inject)
283
293
  */
284
- export async function buildHttpTransportApp(
294
+ export async function registerHttpTransport(
295
+ app: FastifyInstance,
285
296
  config: HttpTransportConfig,
286
- ): Promise<FastifyInstance> {
297
+ ): Promise<void> {
287
298
  const mcpPath = config.path ?? "/mcp";
288
299
  const abuseGuard = new AbuseGuard(config.abuse);
289
300
 
290
301
  // Active transports keyed by session ID (stateful mode only).
291
302
  const transports = new Map<string, StreamableHTTPServerTransport>();
292
303
 
293
- const app = Fastify({ logger: false });
294
-
295
304
  // ── CORS ──────────────────────────────────────────────────────────────────
296
305
  // Read tools: open (origin: true — reflect the caller's Origin).
297
306
  // Write tools: enforced per-request in the preHandler below.
@@ -465,7 +474,22 @@ export async function buildHttpTransportApp(
465
474
 
466
475
  // ── Signed-in workspace route (#3591 D15) ─────────────────────────────────
467
476
  registerWorkspaceRoute(app, `${mcpPath}/workspace`, abuseGuard);
477
+ }
468
478
 
479
+ /**
480
+ * Build and return a configured Fastify instance that exposes the MCP HTTP
481
+ * transport at `config.path`. The instance is not yet listening — callers
482
+ * call `.listen()` or `.inject()` (tests). Sets no `trustProxy`, so
483
+ * `request.ip` is the socket's address.
484
+ *
485
+ * @param config - HTTP transport configuration
486
+ * @returns Configured Fastify instance (ready to listen or inject)
487
+ */
488
+ export async function buildHttpTransportApp(
489
+ config: HttpTransportConfig,
490
+ ): Promise<FastifyInstance> {
491
+ const app = Fastify({ logger: false });
492
+ await registerHttpTransport(app, config);
469
493
  return app;
470
494
  }
471
495
 
@@ -9,6 +9,7 @@
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)
@@ -19,145 +20,35 @@
19
20
  * Content adapter (all optional; `CONTENT_ADAPTER` default: mdx):
20
21
  * CONTENT_ADAPTER=mdx|projection|database
21
22
  * DATABASE_URL= (required only when CONTENT_ADAPTER=database)
23
+ *
24
+ * The boot sequence and the HTTP env reading live in `boot.ts`, shared with
25
+ * the Vercel entry `app.ts` (#8070 D2).
22
26
  */
23
27
 
24
28
  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
- }
29
+ bootMcp,
30
+ httpTransportConfigFromEnv,
31
+ readStatelessEnv,
32
+ } from "./boot.js";
88
33
 
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
- }
34
+ // The workspace, the licence-key store, the commerce claim, its re-read loop
35
+ // and the licence boot warning — before either transport branch below.
36
+ await bootMcp();
110
37
 
111
38
  const transport = process.env["MCP_TRANSPORT"] ?? "stdio";
112
39
 
113
40
  if (transport === "http") {
114
41
  const { startHttpTransport } = await import("./http-transport.js");
115
42
 
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
-
43
+ const config = httpTransportConfigFromEnv();
148
44
  await startHttpTransport({
149
- port,
150
- path,
151
- rateLimits: { readPerMin, writePerMin },
152
- cors: { writeAllowedOrigins },
153
- abuse: {
154
- captureLeadDailyCapPerIp,
155
- startPurchaseDailyCapPerIp,
156
- blockedUserAgents,
157
- },
45
+ ...config,
46
+ stateless: readStatelessEnv(false),
158
47
  });
159
48
 
160
- console.log(`MCP HTTP transport listening on port ${port} at ${path}`);
49
+ console.log(
50
+ `MCP HTTP transport listening on port ${config.port} at ${config.path}`,
51
+ );
161
52
  } else {
162
53
  // Default: stdio transport
163
54
  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();
@@ -27,6 +27,7 @@ import {
27
27
  mcpHeldFeatures,
28
28
  } from "./lib/commerce-claim.js";
29
29
  import { notFoundError } from "./lib/errors.js";
30
+ import { UNSERVED_MCP_TOOLS } from "./lib/topology.generated.js";
30
31
  import { ailkAudit } from "./tools/audit.js";
31
32
  import { ailkCaptureLead } from "./tools/capture_lead.js";
32
33
  import { ailkCreateProject } from "./tools/create_project.js";
@@ -169,8 +170,12 @@ export interface RegisteredToolDescriptor {
169
170
 
170
171
  /**
171
172
  * Whether a catalog registration entry should register on this build/boot —
172
- * three filters:
173
+ * four filters:
173
174
  *
175
+ * 0. the topology filter (#8070 D7): a name in the generated seam's
176
+ * `UNSERVED_MCP_TOOLS` is never served — `create_project` on a spoke,
177
+ * which carries no create-ailk CLI for it to spawn. `[]` on a hub. The
178
+ * web manifest drops the same list, so the two surfaces agree;
174
179
  * 1. the OSS-disabled filter (static per-name set; empty since #5971);
175
180
  * 2. the module-presence filter (S1, #5088): a tool tagged with `module`
176
181
  * (e.g. `create_booking` carrying `module: "scheduling"`) registers only
@@ -200,6 +205,7 @@ function shouldRegister(reg: {
200
205
  module?: string;
201
206
  addon?: "commerce";
202
207
  }): boolean {
208
+ if (UNSERVED_MCP_TOOLS.includes(reg.name)) return false;
203
209
  if (OSS_DISABLED_TOOLS.has(reg.name)) return false;
204
210
  if (reg.module && !isModuleEnabled(reg.module)) return false;
205
211
  if (reg.addon === "commerce" && mcpCommerceClaim() !== "claimed")
@@ -240,7 +246,8 @@ function catalogOptionsForThisBoot(): McpCatalogOptions {
240
246
  * boot serves whose licence `feature` the deployment does not hold, calls
241
247
  * `warn` once, naming the tool and the feature, and returns their names.
242
248
  *
243
- * Called ONCE per process from `index.ts`, after the claim resolves — never
249
+ * Called ONCE per process from `bootMcp()` (`boot.ts`, which both `index.ts`
250
+ * and the Vercel entry `app.ts` await), after the claim resolves — never
244
251
  * from {@link createMcpServer}, which runs once per HTTP session. In refuse
245
252
  * mode it finds nothing: the upstream catalog filter has already dropped
246
253
  * every such tool, so it needs no mode check of its own. Defaults to
@@ -283,6 +290,7 @@ export function listRegisteredTools(): RegisteredToolDescriptor[] {
283
290
 
284
291
  /**
285
292
  * Creates a configured McpServer with all AILK tools registered, less any
293
+ * tool this topology does not serve (`UNSERVED_MCP_TOOLS`, #8070 D7), less any
286
294
  * tool name in {@link OSS_DISABLED_TOOLS}, less any tool whose `module` is
287
295
  * not enabled on this build (S1, #5088), and less any `addon`-tagged tool
288
296
  * whose claim this deployment does not hold (#5971) — see
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The testable part of the Vercel entry (#8070 D2, D5).
3
+ *
4
+ * `src/app.ts` is the thin file Vercel's Node-backend detection finds: it
5
+ * awaits the boot at module level and default-exports the
6
+ * {@link serverlessHandler} for the app. A module-level
7
+ * `await` does not compile under the package's ts-jest CommonJS preset, so
8
+ * everything app.ts does beyond that lives here, where a test can build the
9
+ * same app (`__tests__/app-entry.test.ts`).
10
+ */
11
+
12
+ import type { IncomingMessage, ServerResponse } from "node:http";
13
+
14
+ import type { FastifyInstance } from "fastify";
15
+
16
+ import { httpTransportConfigFromEnv, readStatelessEnv } from "./boot.js";
17
+ import { registerHttpTransport } from "./http-transport.js";
18
+
19
+ /**
20
+ * Trust exactly one proxy hop — the immediate peer — and nothing behind it
21
+ * (D5). Fastify then takes `request.ip` from the LAST `X-Forwarded-For`
22
+ * entry, the address Vercel's edge saw, and a value a caller writes earlier
23
+ * in the header cannot move it.
24
+ *
25
+ * A function, not `trustProxy: 1`: Fastify 5.12 treats a numeric hop count as
26
+ * trust-nothing ("hop-count-only trust cannot validate the immediate peer"),
27
+ * so `1` would silently key every caller on the edge's address. A hop-only
28
+ * function is safe here for the reason Fastify's warning names: a Vercel
29
+ * function cannot be reached except through Vercel's edge, so its immediate
30
+ * peer is always the edge. It would NOT be safe on a host a client can reach
31
+ * directly — which is why the long-running entry (`buildHttpTransportApp`)
32
+ * sets no `trustProxy` at all.
33
+ *
34
+ * Trusting hop 0 also makes `request.host` and `request.protocol` follow
35
+ * `X-Forwarded-Host` / `X-Forwarded-Proto`. Nothing in apps/mcp reads either
36
+ * today; anything that starts to must remember they come from the edge.
37
+ */
38
+ export function trustOneHop(_address: string, hop: number): boolean {
39
+ return hop === 0;
40
+ }
41
+
42
+ /**
43
+ * The Fastify options the Vercel entry builds its app with: no logger, and
44
+ * {@link trustOneHop} — so the per-IP rate limit and the daily caps key on
45
+ * the real caller. That holds for the topology the sites deploy: the MCP host
46
+ * name points straight at the Vercel project. A second proxy in front
47
+ * (Cloudflare in proxied mode, a rewrite from another Vercel project) would
48
+ * make every caller that proxy's address.
49
+ */
50
+ export const SERVERLESS_FASTIFY_OPTIONS = {
51
+ logger: false,
52
+ trustProxy: trustOneHop,
53
+ } as const;
54
+
55
+ /**
56
+ * Register the MCP HTTP transport on the Vercel entry's app, from the
57
+ * environment. Stateless unless `MCP_HTTP_STATELESS` says otherwise (D1): a
58
+ * stateful transport keeps its sessions in one instance's memory, which a
59
+ * multi-instance host cannot honour. The port is read and ignored — Vercel
60
+ * calls the exported {@link serverlessHandler} and nothing listens.
61
+ */
62
+ export async function configureServerlessApp(
63
+ app: FastifyInstance,
64
+ ): Promise<void> {
65
+ const { port: _port, ...config } = httpTransportConfigFromEnv();
66
+ await registerHttpTransport(app, {
67
+ ...config,
68
+ stateless: readStatelessEnv(true),
69
+ });
70
+ }
71
+
72
+ /**
73
+ * The `(req, res)` function the Vercel entry default-exports (#8070 D2,
74
+ * amendment 2). Vercel's Node runtime accepts a request function, an object of
75
+ * web handlers, or an `http.Server` the module starts listening — a Fastify
76
+ * instance is none of these, and exporting one answered every request with a
77
+ * 500. `app.server` is the `http.Server` Fastify builds without listening;
78
+ * emitting `request` on it is Fastify's own request path, so routing,
79
+ * `trustProxy` and every hook behave as on a listening server. The app must
80
+ * already be `ready()`.
81
+ */
82
+ export function serverlessHandler(
83
+ app: FastifyInstance,
84
+ ): (req: IncomingMessage, res: ServerResponse) => void {
85
+ return (req, res) => {
86
+ app.server.emit("request", req, res);
87
+ };
88
+ }
@@ -15,8 +15,6 @@
15
15
  * Session/scope: none — read-only public content.
16
16
  */
17
17
 
18
- import { join } from "node:path";
19
-
20
18
  import {
21
19
  getBusinessHoursInputSchema,
22
20
  getBusinessHoursOutputSchema,
@@ -24,7 +22,10 @@ import {
24
22
  type GetBusinessHoursOutput,
25
23
  } from "@working-theory/validation";
26
24
 
27
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
25
+ import {
26
+ getMcpContentAdapter,
27
+ mcpContentRoot,
28
+ } from "../lib/content-adapter.js";
28
29
  import { resolveLocale } from "../lib/resolve-locale.js";
29
30
 
30
31
  export { getBusinessHoursInputSchema, getBusinessHoursOutputSchema };
@@ -36,7 +37,7 @@ const DEFAULT_SLUG = "home";
36
37
  * get_business_hours handler.
37
38
  *
38
39
  * Accepts an optional `contentRoot` override for testing. When omitted,
39
- * resolves the content root relative to cwd (supports ESM runtime).
40
+ * resolves it through `mcpContentRoot()` (#8070 D3).
40
41
  */
41
42
  export async function ailkGetBusinessHours(
42
43
  input: GetBusinessHoursInput,
@@ -44,7 +45,7 @@ export async function ailkGetBusinessHours(
44
45
  ): Promise<GetBusinessHoursOutput> {
45
46
  getBusinessHoursInputSchema.parse(input);
46
47
 
47
- const root = contentRoot ?? join(process.cwd(), "content");
48
+ const root = contentRoot ?? mcpContentRoot();
48
49
  const locale = resolveLocale(input.locale);
49
50
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
50
51
 
@@ -15,8 +15,6 @@
15
15
  * Session/scope: none — read-only public content.
16
16
  */
17
17
 
18
- import { join } from "node:path";
19
-
20
18
  import {
21
19
  getFaqInputSchema,
22
20
  getFaqOutputSchema,
@@ -24,7 +22,10 @@ import {
24
22
  type GetFaqOutput,
25
23
  } from "@working-theory/validation";
26
24
 
27
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
25
+ import {
26
+ getMcpContentAdapter,
27
+ mcpContentRoot,
28
+ } from "../lib/content-adapter.js";
28
29
  import { resolveLocale } from "../lib/resolve-locale.js";
29
30
 
30
31
  export { getFaqInputSchema, getFaqOutputSchema };
@@ -36,7 +37,7 @@ const DEFAULT_SLUG = "faq";
36
37
  * get_faq handler.
37
38
  *
38
39
  * Accepts an optional `contentRoot` override for testing. When omitted,
39
- * resolves the content root relative to cwd (supports ESM runtime).
40
+ * resolves it through `mcpContentRoot()` (#8070 D3).
40
41
  */
41
42
  export async function ailkGetFaq(
42
43
  input: GetFaqInput,
@@ -44,7 +45,7 @@ export async function ailkGetFaq(
44
45
  ): Promise<GetFaqOutput> {
45
46
  getFaqInputSchema.parse(input);
46
47
 
47
- const root = contentRoot ?? join(process.cwd(), "content");
48
+ const root = contentRoot ?? mcpContentRoot();
48
49
  const locale = resolveLocale(input.locale);
49
50
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
50
51
 
@@ -15,8 +15,6 @@
15
15
  * Session/scope: none — read-only public content.
16
16
  */
17
17
 
18
- import { join } from "node:path";
19
-
20
18
  import { SCHEMA_ORG_TYPES } from "@working-theory/content-adapters";
21
19
  import {
22
20
  getLocationsInputSchema,
@@ -25,7 +23,10 @@ import {
25
23
  type GetLocationsOutput,
26
24
  } from "@working-theory/validation";
27
25
 
28
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
26
+ import {
27
+ getMcpContentAdapter,
28
+ mcpContentRoot,
29
+ } from "../lib/content-adapter.js";
29
30
  import { resolveLocale } from "../lib/resolve-locale.js";
30
31
 
31
32
  export { getLocationsInputSchema, getLocationsOutputSchema };
@@ -35,7 +36,7 @@ export type { GetLocationsInput, GetLocationsOutput };
35
36
  * get_locations handler.
36
37
  *
37
38
  * Accepts an optional `contentRoot` override for testing. When omitted,
38
- * resolves the content root relative to cwd (supports ESM runtime).
39
+ * resolves it through `mcpContentRoot()` (#8070 D3).
39
40
  */
40
41
  export async function ailkGetLocations(
41
42
  input: GetLocationsInput,
@@ -43,7 +44,7 @@ export async function ailkGetLocations(
43
44
  ): Promise<GetLocationsOutput> {
44
45
  getLocationsInputSchema.parse(input);
45
46
 
46
- const root = contentRoot ?? join(process.cwd(), "content");
47
+ const root = contentRoot ?? mcpContentRoot();
47
48
  const locale = resolveLocale(input.locale);
48
49
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
49
50