create-ailk 0.17.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 (36) hide show
  1. package/dist/lib/env-catalog.js +5 -9
  2. package/dist/topology.d.ts +8 -0
  3. package/dist/topology.js +24 -2
  4. package/package.json +1 -1
  5. package/templates/apps/mcp/CLAUDE.md +51 -5
  6. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  7. package/templates/apps/mcp/__tests__/boot.test.ts +203 -0
  8. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  9. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  10. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  11. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  12. package/templates/apps/mcp/src/app.ts +32 -0
  13. package/templates/apps/mcp/src/boot.ts +185 -0
  14. package/templates/apps/mcp/src/http-transport.ts +39 -15
  15. package/templates/apps/mcp/src/index.ts +17 -126
  16. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  17. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  18. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  19. package/templates/apps/mcp/src/server.ts +10 -2
  20. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  21. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  22. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  23. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  24. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  25. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  26. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  27. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  28. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  29. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  30. package/templates/apps/mcp/tsconfig.json +2 -0
  31. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  32. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +18 -4
  34. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  35. package/templates/apps/web/lib/site-brand.tsx +22 -6
  36. package/templates/apps/web/lib/topology.generated.ts +3 -0
@@ -24,7 +24,7 @@ export const ENV_CATALOG = {
24
24
  secret: false,
25
25
  },
26
26
  AILK_CONTENT_ROOT: {
27
- description: "Overrides the on-disk content root; defaults to <repo-root>/content.",
27
+ description: "Overrides the on-disk content root (absolute, or relative to the working directory). Unset, the MCP server tries <cwd>/content, then <cwd>/../../content; the web app defaults to <repo-root>/content.",
28
28
  secret: false,
29
29
  },
30
30
  AILK_ENABLED_MODULES: {
@@ -143,16 +143,12 @@ export const ENV_CATALOG = {
143
143
  description: "The port the MCP HTTP transport binds; defaults to 8080.",
144
144
  secret: false,
145
145
  },
146
- MCP_PUBLIC_HTTP_URL: {
147
- description: "The public URL of the MCP HTTP transport, advertised by /.well-known/mcp.json.",
148
- secret: false,
149
- },
150
- MCP_RATE_LIMIT_READ_PER_MIN: {
151
- description: "Maximum MCP read-tool requests per minute per IP; defaults to 60.",
146
+ MCP_HTTP_STATELESS: {
147
+ description: 'Set to "true" or "1" for an MCP HTTP transport with no sessions, which any instance can answer; "false" or "0" keeps sessions in one process. Unset means stateless on Vercel (src/app.ts) and stateful on a port (src/index.ts).',
152
148
  secret: false,
153
149
  },
154
- MCP_RATE_LIMIT_WRITE_PER_MIN: {
155
- description: "Maximum MCP write-tool requests per minute per IP; defaults to 10.",
150
+ MCP_PUBLIC_HTTP_URL: {
151
+ description: "The public URL of the MCP HTTP transport, advertised by /.well-known/mcp.json.",
156
152
  secret: false,
157
153
  },
158
154
  MCP_TRANSPORT: {
@@ -122,6 +122,14 @@ export declare function isSpokeExcluded(path: string, opts: IsSpokeExcludedOptio
122
122
  * install passes no remap.
123
123
  */
124
124
  export declare function spokeDestination(destination: string): string;
125
+ /**
126
+ * The MCP tools a spoke does not serve (#8070 D7). `create_project` spawns
127
+ * `packages/create-ailk/dist/cli.js` from the MCP host's working directory,
128
+ * which a spoke does not carry. Both seams carry the list, so the MCP
129
+ * server's registration and the web discovery manifest agree; a hub's is
130
+ * empty.
131
+ */
132
+ export declare const SPOKE_UNSERVED_MCP_TOOLS: readonly string[];
125
133
  /**
126
134
  * `apps/web/lib/topology.generated.ts` for `topology` (D5) and, on a spoke,
127
135
  * its sign-in mode (#7894 D8). The hub render loads `auth.ts` only when a
package/dist/topology.js CHANGED
@@ -269,13 +269,30 @@ const WEB_SEAM_HEADER = `// topology.generated.ts — how this site reads a sess
269
269
  // every scaffold rewrites it. A hub reads the session through Better Auth.
270
270
  // A spoke (\`--topology spoke\`) has no database of its own: by default it
271
271
  // has no sign-in, so its render reads no session and imports nothing; with
272
- // \`--hub-sign-in\` (#7894) it reads the sealed hub-session cookie.`;
272
+ // \`--hub-sign-in\` (#7894) it reads the sealed hub-session cookie.
273
+ // UNSERVED_MCP_TOOLS names the MCP tools /.well-known/mcp.json leaves out
274
+ // on this topology (#8070).`;
273
275
  const MCP_SEAM_HEADER = `// topology.generated.ts — how this MCP server resolves its workspace store.
274
276
  //
275
277
  // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
276
278
  // every scaffold rewrites it. A hub resolves its workspace through the
277
279
  // database; a spoke (\`--topology spoke\`) has none, so its content tools
278
- // read the spoke's own MDX and its writes go to the hub at AILK_API_URL.`;
280
+ // read the spoke's own MDX and its writes go to the hub at AILK_API_URL.
281
+ // UNSERVED_MCP_TOOLS names the tools this server does not register on this
282
+ // topology (#8070).`;
283
+ /**
284
+ * The MCP tools a spoke does not serve (#8070 D7). `create_project` spawns
285
+ * `packages/create-ailk/dist/cli.js` from the MCP host's working directory,
286
+ * which a spoke does not carry. Both seams carry the list, so the MCP
287
+ * server's registration and the web discovery manifest agree; a hub's is
288
+ * empty.
289
+ */
290
+ export const SPOKE_UNSERVED_MCP_TOOLS = ["create_project"];
291
+ /** The `UNSERVED_MCP_TOOLS` export every web and mcp seam render carries. */
292
+ function unservedToolsExport(topology) {
293
+ const list = topology === "spoke" ? SPOKE_UNSERVED_MCP_TOOLS : [];
294
+ return `export const UNSERVED_MCP_TOOLS: readonly string[] = ${JSON.stringify(list)};`;
295
+ }
279
296
  const SITE_SESSION_TYPE = `export type SiteSession = {
280
297
  user: {
281
298
  name?: string | null;
@@ -304,6 +321,7 @@ export function renderWebSeam(topology, signIn = "off") {
304
321
  return `${WEB_SEAM_HEADER}
305
322
 
306
323
  export const TOPOLOGY: "hub" | "spoke" = "hub";
324
+ ${unservedToolsExport("hub")}
307
325
  ${signInExports({ enabled: true, provider: "site", signInPath: "/sign-in", signUpPath: "/sign-up", signOutEndpoint: null })}
308
326
 
309
327
  ${SITE_SESSION_TYPE}
@@ -324,6 +342,7 @@ export async function registerSiteStores(): Promise<void> {
324
342
  import { readHubSession } from "./hub-session/session";
325
343
 
326
344
  export const TOPOLOGY: "hub" | "spoke" = "spoke";
345
+ ${unservedToolsExport("spoke")}
327
346
  ${signInExports({ enabled: true, provider: "hub", signInPath: "/account", signUpPath: null, signOutEndpoint: "/api/hub-session/sign-out" })}
328
347
 
329
348
  ${SITE_SESSION_TYPE}
@@ -340,6 +359,7 @@ export function registerSiteStores(): Promise<void> {
340
359
  return `${WEB_SEAM_HEADER}
341
360
 
342
361
  export const TOPOLOGY: "hub" | "spoke" = "spoke";
362
+ ${unservedToolsExport("spoke")}
343
363
  ${signInExports({ enabled: false, provider: "none", signInPath: null, signUpPath: null, signOutEndpoint: null })}
344
364
 
345
365
  ${SITE_SESSION_TYPE}
@@ -504,6 +524,7 @@ export { registerLicenseKeyStore } from "./license-key-store.js";
504
524
  export type { McpWorkspaceResolution };
505
525
 
506
526
  export const TOPOLOGY: "hub" | "spoke" = "hub";
527
+ ${unservedToolsExport("hub")}
507
528
 
508
529
  export async function initPublicWorkspace(): Promise<void> {
509
530
  await initMcpPublicWorkspace();
@@ -523,6 +544,7 @@ export type McpWorkspaceResolution =
523
544
  | { kind: "unresolved"; reason: "ambiguous" };
524
545
 
525
546
  export const TOPOLOGY: "hub" | "spoke" = "spoke";
547
+ ${unservedToolsExport("spoke")}
526
548
 
527
549
  export function initPublicWorkspace(): Promise<void> {
528
550
  return Promise.resolve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -8,6 +8,50 @@ Inherits root `CLAUDE.md`. This file adds MCP-specific rules.
8
8
  - Transport: stdio at launch
9
9
  - Discovery: `/.well-known/mcp.json` published from `apps/web`
10
10
 
11
+ ## Two entrypoints, one boot (#8070)
12
+
13
+ - **`src/index.ts`** — run locally: stdio (the default), or HTTP on `MCP_HTTP_PORT` with
14
+ `MCP_TRANSPORT=http`.
15
+ - **`src/app.ts`** — the Vercel entry. A Vercel project whose root directory is `apps/mcp`
16
+ detects it (it imports Fastify) and calls its default export, `serverlessHandler(app)`: a
17
+ `(req, res)` function, the shape Vercel's Node runtime runs — a bare Fastify instance is not
18
+ one and answers every request with a 500. It never listens. There is **no `vercel.json`**: a
19
+ `functions` pattern for `src/app.ts` is rejected for the `fastify` builder, so the site's
20
+ `content/` reaches the function by the builder's file tracing, and the 30-second request cap
21
+ is `export const config = { maxDuration: 30 }` in `app.ts` (a literal object — the builder
22
+ reads it by static analysis). `tsconfig.json` names `module` and `moduleResolution` itself:
23
+ Vercel's type-check edits the entry's nearest `tsconfig.json` as raw JSON before `extends`,
24
+ and without its own `module` it switches `strict` off, which fails 11 narrowing sites.
25
+ - Both call `bootMcp()` in `src/boot.ts` before building a transport, and read the HTTP
26
+ configuration through `httpTransportConfigFromEnv()` there. Add a boot step to `bootMcp()`,
27
+ never to one entry. `src/app.ts` stays thin; what a test needs is in `src/serverless-app.ts`.
28
+ - **`MCP_HTTP_STATELESS`** picks the transport mode: `true`/`1` stateless (no sessions; any
29
+ instance answers), `false`/`0` stateful (sessions in one process's memory). Unset, the
30
+ Vercel entry is stateless and `src/index.ts` is stateful. Any other value fails the boot.
31
+ The Vercel entry needs stateless: a session kept in one instance is lost on the next.
32
+ - **Content root.** Every content tool and `audit` take their root from `mcpContentRoot()`
33
+ (`lib/content-adapter.ts`): `AILK_CONTENT_ROOT`, else `<cwd>/content`, else
34
+ `<cwd>/../../content`. Never build a content path from `process.cwd()` in a tool.
35
+ - **`create_project`** is not served on a spoke: the generated seam's `UNSERVED_MCP_TOOLS`
36
+ keeps it out of registration, and the web manifest drops the same list.
37
+
38
+ ### What a spoke's MCP host on Vercel enforces
39
+
40
+ - **The address is the caller's, behind exactly one proxy.** The Vercel entry trusts one hop
41
+ (`trustOneHop`, `src/serverless-app.ts`), so the limits key on the address Vercel's edge
42
+ saw and a caller's own `X-Forwarded-For` cannot move the key. This holds when the MCP host
43
+ name points straight at the Vercel project; a second proxy in front keys every caller on
44
+ that proxy. It is a function, not `trustProxy: 1`, because Fastify 5.12 treats a numeric
45
+ hop count as trusting nothing. The long-running entry trusts no proxy.
46
+ - **Per address, per instance.** 60 requests a minute across reads and writes (the write rate
47
+ is parsed but not enforced); `capture_lead` 50 a day and `start_purchase` 20 a day.
48
+ `subscribe_newsletter` and `request_callback` have no daily cap. Each warm instance counts
49
+ on its own and a cold start resets the counts, so a caller reaching N instances gets up to
50
+ N times each limit.
51
+ - **At the hub, one shared bucket.** The MCP lead tools forward no visitor address, so the
52
+ hub's lead limiter counts every MCP lead write from a spoke against the MCP host's egress
53
+ address (#8073).
54
+
11
55
  ## Identity-blind by design (hard rule)
12
56
 
13
57
  This rule governs the anonymous surface, `/mcp` (and stdio) — every tool the catalog returns with
@@ -56,14 +100,14 @@ Every tool maps 1:1 to an `apps/api` endpoint. When you change a tool, change th
56
100
  server-wide gate and its own Gate 2 in `tools/purchase.ts`.
57
101
  `MCP_FEATURE_GATE_MODE` (`packages/metadata`) decides what an unheld
58
102
  feature does. In `"warn"` mode (this release) the five register on module
59
- presence alone, and `index.ts` logs one boot warning (stderr) per tool
103
+ presence alone, and the boot (`bootMcp()`) logs one warning (stderr) per tool
60
104
  served without its feature. In `"refuse"` mode (the next release) they
61
105
  also need the deployment's licence to hold the feature, and
62
106
  `/.well-known/mcp.json` drops them in step. The claim is resolved at boot
63
107
  and re-read every `MCP_CLAIM_REFRESH_MS` through `checkLicense()`
64
108
  (`lib/commerce-claim.ts`) and fails closed.
65
- `index.ts` is the transport entry point and exports no tools — registration
66
- is entirely via `createMcpServer`.
109
+ `index.ts` and `app.ts` are the transport entry points and export no tools —
110
+ registration is entirely via `createMcpServer`.
67
111
 
68
112
  ## The signed-in workspace surface (`/mcp/workspace`, #3591)
69
113
 
@@ -118,7 +162,7 @@ here. Spec: `docs/specs/3591-tenant-scoped-agent-auth-secure-spec.md` D13–D16.
118
162
  server's tool list, and calling it by name there returns the module-disabled
119
163
  `not_found` body. The claim is resolved at boot and re-read every
120
164
  `MCP_CLAIM_REFRESH_MS`
121
- (`src/lib/commerce-claim.ts`, awaited in `src/index.ts`) and read
165
+ (`src/lib/commerce-claim.ts`, awaited in `bootMcp()`, `src/boot.ts`) and read
122
166
  synchronously by both the registration filter and the handler's own
123
167
  re-check. Calls are rate-limited per IP by the AbuseGuard
124
168
  (`MCP_ABUSE_START_PURCHASE_DAILY_CAP`, default 20, on its own tracker).
@@ -164,7 +208,9 @@ Example (lead routing):
164
208
  - Don't add a tool without the matching `apps/api` endpoint.
165
209
  - Don't put business logic in tool handlers — they're adapters.
166
210
 
167
- Last updated: 2026-10-04 — #3591 PR-E (#7684): the identity-blind rule is scoped to the anonymous `/mcp`, and the new § The signed-in workspace surface records `/mcp/workspace` — POST-only and stateless, an agent key per request forwarded to each tool's apps/api twin, eleven `surface: "workspace"` catalog rows that are parity `twin`s and never in the discovery manifest — verified against `apps/mcp/src/http-transport.ts`, `apps/mcp/src/workspace-server.ts` and `packages/metadata/src/mcp-tool-catalog.ts`.
211
+ Last updated: 2026-10-10 — #8070 (amendment 2): the Vercel entry exports a handler, no `vercel.json`; § Two entrypoints, one boot — the Vercel entry `src/app.ts`, the shared `src/boot.ts`, `MCP_HTTP_STATELESS`, `mcpContentRoot()`, `create_project` withheld on a spoke, and what a spoke's MCP host enforces — verified against `apps/mcp/src/{app,boot,serverless-app,index,http-transport,server}.ts` and `lib/content-adapter.ts`.
212
+
213
+ Prior: 2026-10-04 — #3591 PR-E (#7684): the identity-blind rule is scoped to the anonymous `/mcp`, and the new § The signed-in workspace surface records `/mcp/workspace` — POST-only and stateless, an agent key per request forwarded to each tool's apps/api twin, eleven `surface: "workspace"` catalog rows that are parity `twin`s and never in the discovery manifest — verified against `apps/mcp/src/http-transport.ts`, `apps/mcp/src/workspace-server.ts` and `packages/metadata/src/mcp-tool-catalog.ts`.
168
214
 
169
215
  Prior: 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.
170
216
 
@@ -0,0 +1,325 @@
1
+ /**
2
+ * The Vercel entry (#8070 D2, D5).
3
+ *
4
+ * `src/app.ts` awaits the boot at module level, which ts-jest's CommonJS
5
+ * compile cannot load — so, like `index.test.ts`, its shape is checked as
6
+ * source text, and its behaviour is exercised through `serverless-app.ts`,
7
+ * the part it delegates to.
8
+ *
9
+ * Acceptance criteria verified here (spec docs/specs/8070-spoke-mcp-vercel-backend-spec.md):
10
+ * - AC-4 (amendment 2): app.ts imports Fastify, caps a request with
11
+ * `export const config = { maxDuration: 30 }`, builds the app from
12
+ * SERVERLESS_FASTIFY_OPTIONS, configures it, awaits `app.ready()`, default-exports
13
+ * `serverlessHandler(app)` (the `(req, res)` function Vercel's Node runtime
14
+ * calls — a Fastify instance is not a shape it accepts) and never listens; the
15
+ * options are `{ logger: false, trustProxy: trustOneHop }` — the spec's
16
+ * `trustProxy: 1` is a no-op on Fastify 5.12 (pinned below), so the same
17
+ * one-hop trust is expressed as a function; an `http.createServer` serving
18
+ * `serverlessHandler(app)` answers `initialize` and a session-less `tools/list`
19
+ * with MCP_HTTP_STATELESS unset.
20
+ * - AC-5: on the serverless app the daily cap keys on the LAST
21
+ * X-Forwarded-For hop; the long-running app ignores the header.
22
+ * - AC-13: tsconfig.json names `module` and `moduleResolution`, and there is
23
+ * no vercel.json.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { createServer, type Server } from "node:http";
28
+ import type { AddressInfo } from "node:net";
29
+ import { resolve } from "node:path";
30
+
31
+ import Fastify, { type FastifyInstance } from "fastify";
32
+
33
+ import { buildHttpTransportApp } from "../src/http-transport.js";
34
+ import {
35
+ configureServerlessApp,
36
+ SERVERLESS_FASTIFY_OPTIONS,
37
+ serverlessHandler,
38
+ trustOneHop,
39
+ } from "../src/serverless-app.js";
40
+
41
+ // The hub call capture_lead makes after the guard is stubbed, as
42
+ // http-transport.test.ts stubs checkout: only the guard's verdict is asserted.
43
+ jest.mock("@working-theory/api-client", () => {
44
+ const actual = jest.requireActual("@working-theory/api-client");
45
+ return {
46
+ ...actual,
47
+ apiClient: {
48
+ ...actual.apiClient,
49
+ leads: {
50
+ ...actual.apiClient.leads,
51
+ submit: jest.fn().mockResolvedValue({
52
+ ok: true,
53
+ body: {
54
+ id: "lead_00000000-0000-7000-8000-000000008070",
55
+ persisted: "db",
56
+ },
57
+ }),
58
+ },
59
+ },
60
+ };
61
+ });
62
+
63
+ const VARS = [
64
+ "MCP_HTTP_STATELESS",
65
+ "MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
66
+ "MCP_CORS_WRITE_ALLOWED_ORIGINS",
67
+ ] as const;
68
+
69
+ const saved: Partial<Record<(typeof VARS)[number], string | undefined>> = {};
70
+
71
+ beforeEach(() => {
72
+ for (const name of VARS) {
73
+ saved[name] = process.env[name];
74
+ delete process.env[name];
75
+ }
76
+ });
77
+
78
+ afterEach(() => {
79
+ for (const name of VARS) {
80
+ const value = saved[name];
81
+ if (value === undefined) delete process.env[name];
82
+ else process.env[name] = value;
83
+ }
84
+ });
85
+
86
+ async function mcpPost(
87
+ port: number,
88
+ payload: object,
89
+ headers: Record<string, string> = {},
90
+ ): Promise<Response> {
91
+ return fetch(`http://127.0.0.1:${port}/mcp`, {
92
+ method: "POST",
93
+ body: JSON.stringify(payload),
94
+ headers: {
95
+ "content-type": "application/json",
96
+ accept: "application/json, text/event-stream",
97
+ ...headers,
98
+ },
99
+ });
100
+ }
101
+
102
+ async function listen(app: FastifyInstance): Promise<number> {
103
+ await app.listen({ host: "127.0.0.1", port: 0 });
104
+ return (app.server.address() as AddressInfo).port;
105
+ }
106
+
107
+ /** The app src/app.ts builds, minus the boot and the default export. */
108
+ async function serverlessApp(): Promise<FastifyInstance> {
109
+ const app = Fastify(SERVERLESS_FASTIFY_OPTIONS);
110
+ await configureServerlessApp(app);
111
+ await app.ready();
112
+ return app;
113
+ }
114
+
115
+ /** Serve the app through the exact function Vercel's Node runtime calls. */
116
+ async function serveHandler(
117
+ app: FastifyInstance,
118
+ ): Promise<{ server: Server; port: number }> {
119
+ const server = createServer(serverlessHandler(app));
120
+ await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
121
+ return { server, port: (server.address() as AddressInfo).port };
122
+ }
123
+
124
+ async function stop(server: Server | undefined): Promise<void> {
125
+ if (!server) return;
126
+ await new Promise<void>((done) => server.close(() => done()));
127
+ }
128
+
129
+ function captureLead(id: number, name: string): object {
130
+ return {
131
+ jsonrpc: "2.0",
132
+ id,
133
+ method: "tools/call",
134
+ params: {
135
+ name: "capture_lead",
136
+ arguments: {
137
+ name,
138
+ email: `${name.toLowerCase()}@example.com`,
139
+ message: "Interested in your services.",
140
+ },
141
+ },
142
+ };
143
+ }
144
+
145
+ describe("AC-4 — src/app.ts, read as source text", () => {
146
+ const src = readFileSync(resolve(__dirname, "../src/app.ts"), "utf-8");
147
+
148
+ it("imports Fastify by value, so Vercel's Node-backend detection finds it", () => {
149
+ expect(src).toContain('import Fastify from "fastify"');
150
+ });
151
+
152
+ it("awaits the shared boot, builds Fastify from the serverless options and configures it", () => {
153
+ expect(src).toContain("await bootMcp()");
154
+ expect(src).toContain("Fastify(SERVERLESS_FASTIFY_OPTIONS)");
155
+ expect(src).toContain("configureServerlessApp(app)");
156
+ });
157
+
158
+ it("boots before it builds the app, and readies it before it exports", () => {
159
+ expect(src.indexOf("await bootMcp()")).toBeLessThan(
160
+ src.indexOf("Fastify(SERVERLESS_FASTIFY_OPTIONS)"),
161
+ );
162
+ expect(src).toContain("await app.ready()");
163
+ expect(src.indexOf("configureServerlessApp(app)")).toBeLessThan(
164
+ src.indexOf("await app.ready()"),
165
+ );
166
+ });
167
+
168
+ it("caps one request at 30 s with a literal exported config Vercel reads statically", () => {
169
+ expect(src).toContain("export const config = { maxDuration: 30 }");
170
+ });
171
+
172
+ it("default-exports the handler, not the app, and never listens", () => {
173
+ expect(src).toContain("export default serverlessHandler(app)");
174
+ expect(src).not.toContain("export default app;");
175
+ expect(src).not.toContain("listen(");
176
+ });
177
+ });
178
+
179
+ describe("AC-13 — Vercel's type-check keeps strict", () => {
180
+ it("tsconfig.json names module and moduleResolution, so @vercel/node's fixConfig leaves strict alone", () => {
181
+ const tsconfig = JSON.parse(
182
+ readFileSync(resolve(__dirname, "../tsconfig.json"), "utf-8"),
183
+ ) as { compilerOptions: Record<string, unknown> };
184
+ expect(tsconfig.compilerOptions["module"]).toBe("ESNext");
185
+ expect(tsconfig.compilerOptions["moduleResolution"]).toBe("bundler");
186
+ });
187
+
188
+ it("there is no vercel.json (its functions pattern is rejected for the fastify builder)", () => {
189
+ expect(existsSync(resolve(__dirname, "../vercel.json"))).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("AC-4 — the serverless options and the configured app", () => {
194
+ let app: FastifyInstance | undefined;
195
+ let server: Server | undefined;
196
+ let port = 0;
197
+
198
+ afterEach(async () => {
199
+ await stop(server);
200
+ await app?.close();
201
+ app = undefined;
202
+ server = undefined;
203
+ });
204
+
205
+ it("SERVERLESS_FASTIFY_OPTIONS is { logger: false, trustProxy: trustOneHop }", () => {
206
+ expect(SERVERLESS_FASTIFY_OPTIONS).toEqual({
207
+ logger: false,
208
+ trustProxy: trustOneHop,
209
+ });
210
+ });
211
+
212
+ it("trustOneHop trusts the immediate peer (hop 0) and nothing behind it, whatever its address", () => {
213
+ expect(trustOneHop("127.0.0.1", 0)).toBe(true);
214
+ expect(trustOneHop("10.0.0.1", 0)).toBe(true);
215
+ expect(trustOneHop("9.9.9.9", 1)).toBe(false);
216
+ expect(trustOneHop("1.1.1.1", 2)).toBe(false);
217
+ });
218
+
219
+ it("why not trustProxy: 1 — this Fastify treats a numeric hop count as trust-nothing", async () => {
220
+ const numeric = Fastify({ logger: false, trustProxy: 1 });
221
+ numeric.get("/ip", async (request) => ({ ip: request.ip }));
222
+ const res = await numeric.inject({
223
+ url: "/ip",
224
+ remoteAddress: "127.0.0.1",
225
+ headers: { "x-forwarded-for": "3.3.3.3, 8.8.8.8" },
226
+ });
227
+ await numeric.close();
228
+
229
+ // Every caller would key on the edge's address.
230
+ expect(res.json()).toEqual({ ip: "127.0.0.1" });
231
+ });
232
+
233
+ it("serverlessHandler(app) is a (req, res) function", async () => {
234
+ app = await serverlessApp();
235
+ expect(typeof serverlessHandler(app)).toBe("function");
236
+ });
237
+
238
+ it("with MCP_HTTP_STATELESS unset, an http.createServer serving serverlessHandler(app) answers initialize and then a session-less tools/list", async () => {
239
+ app = await serverlessApp();
240
+ ({ server, port } = await serveHandler(app));
241
+
242
+ const init = await mcpPost(port, {
243
+ jsonrpc: "2.0",
244
+ id: 1,
245
+ method: "initialize",
246
+ params: {
247
+ protocolVersion: "2025-03-26",
248
+ capabilities: {},
249
+ clientInfo: { name: "app-entry-test", version: "1.0.0" },
250
+ },
251
+ });
252
+ expect(init.status).toBe(200);
253
+ const initBody = (await init.json()) as {
254
+ result?: { serverInfo?: { name?: string } };
255
+ };
256
+ expect(initBody.result?.serverInfo).toMatchObject({ name: "ailk" });
257
+
258
+ const list = await mcpPost(port, {
259
+ jsonrpc: "2.0",
260
+ id: 2,
261
+ method: "tools/list",
262
+ params: {},
263
+ });
264
+ expect(list.status).toBe(200);
265
+ });
266
+ });
267
+
268
+ describe("AC-5 — the daily cap keys on the last X-Forwarded-For hop", () => {
269
+ let app: FastifyInstance | undefined;
270
+ let server: Server | undefined;
271
+
272
+ afterEach(async () => {
273
+ await stop(server);
274
+ await app?.close();
275
+ app = undefined;
276
+ server = undefined;
277
+ });
278
+
279
+ it("serverless app: two callers sharing a last hop are one caller; a different last hop is another", async () => {
280
+ process.env["MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP"] = "1";
281
+ // Set explicitly so the write-origin check is never what refuses a call.
282
+ process.env["MCP_CORS_WRITE_ALLOWED_ORIGINS"] = "*";
283
+ app = await serverlessApp();
284
+ const { server: served, port } = await serveHandler(app);
285
+ server = served;
286
+
287
+ const first = await mcpPost(port, captureLead(10, "First"), {
288
+ "x-forwarded-for": "1.1.1.1, 9.9.9.9",
289
+ });
290
+ expect(first.status).toBe(200);
291
+
292
+ const second = await mcpPost(port, captureLead(11, "Second"), {
293
+ "x-forwarded-for": "2.2.2.2, 9.9.9.9",
294
+ });
295
+ expect(second.status).toBe(403);
296
+ const refusal = (await second.json()) as { error?: string };
297
+ expect(refusal.error).toBe("daily capture_lead cap reached for this IP");
298
+
299
+ const third = await mcpPost(port, captureLead(12, "Third"), {
300
+ "x-forwarded-for": "3.3.3.3, 8.8.8.8",
301
+ });
302
+ expect(third.status).toBe(200);
303
+ });
304
+
305
+ it("long-running app (no trustProxy): the header is ignored — every call is the socket's address", async () => {
306
+ app = await buildHttpTransportApp({
307
+ path: "/mcp",
308
+ stateless: true,
309
+ rateLimits: { readPerMin: 60, writePerMin: 10 },
310
+ cors: { writeAllowedOrigins: ["*"] },
311
+ abuse: { captureLeadDailyCapPerIp: 1, blockedUserAgents: "" },
312
+ });
313
+ const port = await listen(app);
314
+
315
+ const first = await mcpPost(port, captureLead(20, "First"), {
316
+ "x-forwarded-for": "3.3.3.3",
317
+ });
318
+ expect(first.status).toBe(200);
319
+
320
+ const second = await mcpPost(port, captureLead(21, "Second"), {
321
+ "x-forwarded-for": "4.4.4.4",
322
+ });
323
+ expect(second.status).toBe(403);
324
+ });
325
+ });