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
@@ -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
 
@@ -21,8 +21,6 @@
21
21
  * Session/scope: none — read-only public content.
22
22
  */
23
23
 
24
- import { join } from "node:path";
25
-
26
24
  import { type SchemaOrgType } from "@working-theory/content-adapters";
27
25
  import {
28
26
  getOfferingsInputSchema,
@@ -31,7 +29,10 @@ import {
31
29
  type GetOfferingsOutput,
32
30
  } from "@working-theory/validation";
33
31
 
34
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
32
+ import {
33
+ getMcpContentAdapter,
34
+ mcpContentRoot,
35
+ } from "../lib/content-adapter.js";
35
36
  import { resolveLocale } from "../lib/resolve-locale.js";
36
37
 
37
38
  export { getOfferingsInputSchema, getOfferingsOutputSchema };
@@ -41,7 +42,7 @@ export type { GetOfferingsInput, GetOfferingsOutput };
41
42
  * get_offerings handler.
42
43
  *
43
44
  * Accepts an optional `contentRoot` override for testing. When omitted,
44
- * resolves the content root relative to cwd (supports ESM runtime).
45
+ * resolves it through `mcpContentRoot()` (#8070 D3).
45
46
  */
46
47
  export async function ailkGetOfferings(
47
48
  input: GetOfferingsInput,
@@ -49,7 +50,7 @@ export async function ailkGetOfferings(
49
50
  ): Promise<GetOfferingsOutput> {
50
51
  getOfferingsInputSchema.parse(input);
51
52
 
52
- const root = contentRoot ?? join(process.cwd(), "content");
53
+ const root = contentRoot ?? mcpContentRoot();
53
54
  const locale = resolveLocale(input.locale);
54
55
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
55
56
 
@@ -21,8 +21,6 @@
21
21
  * Session/scope: none — read-only public content.
22
22
  */
23
23
 
24
- import { join } from "node:path";
25
-
26
24
  import {
27
25
  getPricingInputSchema,
28
26
  getPricingOutputSchema,
@@ -30,7 +28,10 @@ import {
30
28
  type GetPricingOutput,
31
29
  } from "@working-theory/validation";
32
30
 
33
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
31
+ import {
32
+ getMcpContentAdapter,
33
+ mcpContentRoot,
34
+ } from "../lib/content-adapter.js";
34
35
  import { resolveLocale } from "../lib/resolve-locale.js";
35
36
 
36
37
  export { getPricingInputSchema, getPricingOutputSchema };
@@ -42,7 +43,7 @@ const DEFAULT_SLUG = "pricing";
42
43
  * get_pricing handler.
43
44
  *
44
45
  * Accepts an optional `contentRoot` override for testing. When omitted,
45
- * resolves the content root relative to cwd (supports ESM runtime).
46
+ * resolves it through `mcpContentRoot()` (#8070 D3).
46
47
  */
47
48
  export async function ailkGetPricing(
48
49
  input: GetPricingInput,
@@ -50,7 +51,7 @@ export async function ailkGetPricing(
50
51
  ): Promise<GetPricingOutput> {
51
52
  getPricingInputSchema.parse(input);
52
53
 
53
- const root = contentRoot ?? join(process.cwd(), "content");
54
+ const root = contentRoot ?? mcpContentRoot();
54
55
  const locale = resolveLocale(input.locale);
55
56
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
56
57
 
@@ -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
  getSiteInfoInputSchema,
22
20
  getSiteInfoOutputSchema,
@@ -24,7 +22,10 @@ import {
24
22
  type GetSiteInfoOutput,
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 { getSiteInfoInputSchema, getSiteInfoOutputSchema };
@@ -36,7 +37,7 @@ const DEFAULT_SLUG = "home";
36
37
  * get_site_info 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 ailkGetSiteInfo(
42
43
  input: GetSiteInfoInput,
@@ -44,7 +45,7 @@ export async function ailkGetSiteInfo(
44
45
  ): Promise<GetSiteInfoOutput> {
45
46
  getSiteInfoInputSchema.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
  SCHEMA_ORG_TYPES,
22
20
  type SchemaOrgType,
@@ -28,7 +26,10 @@ import {
28
26
  type ListPagesOutput,
29
27
  } from "@working-theory/validation";
30
28
 
31
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
29
+ import {
30
+ getMcpContentAdapter,
31
+ mcpContentRoot,
32
+ } from "../lib/content-adapter.js";
32
33
  import { resolveLocale } from "../lib/resolve-locale.js";
33
34
 
34
35
  export { listPagesInputSchema, listPagesOutputSchema };
@@ -38,8 +39,8 @@ export type { ListPagesInput, ListPagesOutput };
38
39
  * list_pages handler.
39
40
  *
40
41
  * Accepts an optional `contentRoot` override for testing (avoids coupling
41
- * unit tests to the deployed filesystem layout). When omitted, resolves the
42
- * content root relative to this file at call time (supports ESM runtime).
42
+ * unit tests to the deployed filesystem layout). When omitted, resolves it
43
+ * through `mcpContentRoot()` (#8070 D3).
43
44
  */
44
45
  export async function ailkListPages(
45
46
  input: ListPagesInput,
@@ -47,8 +48,8 @@ export async function ailkListPages(
47
48
  ): Promise<ListPagesOutput> {
48
49
  listPagesInputSchema.parse(input);
49
50
 
50
- // Default: resolve content/ relative to cwd (works in ESM and jest/commonjs).
51
- const root = contentRoot ?? join(process.cwd(), "content");
51
+ // Default: the one content-root resolver (#8070 D3).
52
+ const root = contentRoot ?? mcpContentRoot();
52
53
  const locale = resolveLocale(input.locale);
53
54
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
54
55
 
@@ -20,8 +20,6 @@
20
20
  * Session/scope: none — read-only public content.
21
21
  */
22
22
 
23
- import { join } from "node:path";
24
-
25
23
  import {
26
24
  readContentInputSchema,
27
25
  readContentOutputSchema,
@@ -29,7 +27,10 @@ import {
29
27
  type ReadContentOutput,
30
28
  } from "@working-theory/validation";
31
29
 
32
- import { getMcpMdxContentAdapter } from "../lib/content-adapter.js";
30
+ import {
31
+ getMcpMdxContentAdapter,
32
+ mcpContentRoot,
33
+ } from "../lib/content-adapter.js";
33
34
  import { resolveLocale } from "../lib/resolve-locale.js";
34
35
 
35
36
  export { readContentInputSchema, readContentOutputSchema };
@@ -39,8 +40,7 @@ export type { ReadContentInput, ReadContentOutput };
39
40
  * read_content handler.
40
41
  *
41
42
  * Accepts an optional `contentRoot` override for testing. When omitted,
42
- * resolves the content root relative to this file at call time (supports
43
- * ESM runtime).
43
+ * resolves it through `mcpContentRoot()` (#8070 D3).
44
44
  */
45
45
  export async function ailkReadContent(
46
46
  input: ReadContentInput,
@@ -48,8 +48,8 @@ export async function ailkReadContent(
48
48
  ): Promise<ReadContentOutput> {
49
49
  readContentInputSchema.parse(input);
50
50
 
51
- // Default: resolve content/ relative to cwd (works in ESM and jest/commonjs).
52
- const root = contentRoot ?? join(process.cwd(), "content");
51
+ // Default: the one content-root resolver (#8070 D3).
52
+ const root = contentRoot ?? mcpContentRoot();
53
53
  const locale = resolveLocale(input.locale);
54
54
 
55
55
  const adapter = getMcpMdxContentAdapter({ contentRoot: root, locale });
@@ -20,8 +20,6 @@
20
20
  * Session/scope: none — read-only public content.
21
21
  */
22
22
 
23
- import { join } from "node:path";
24
-
25
23
  import {
26
24
  searchContentInputSchema,
27
25
  searchContentOutputSchema,
@@ -29,7 +27,10 @@ import {
29
27
  type SearchContentOutput,
30
28
  } from "@working-theory/validation";
31
29
 
32
- import { getMcpContentAdapter } from "../lib/content-adapter.js";
30
+ import {
31
+ getMcpContentAdapter,
32
+ mcpContentRoot,
33
+ } from "../lib/content-adapter.js";
33
34
  import { resolveLocale } from "../lib/resolve-locale.js";
34
35
 
35
36
  export { searchContentInputSchema, searchContentOutputSchema };
@@ -41,7 +42,7 @@ const DEFAULT_LIMIT = 10;
41
42
  * search_content handler.
42
43
  *
43
44
  * Accepts an optional `contentRoot` override for testing. When omitted,
44
- * resolves the content root relative to cwd (supports ESM runtime).
45
+ * resolves it through `mcpContentRoot()` (#8070 D3).
45
46
  */
46
47
  export async function ailkSearchContent(
47
48
  input: SearchContentInput,
@@ -49,7 +50,7 @@ export async function ailkSearchContent(
49
50
  ): Promise<SearchContentOutput> {
50
51
  searchContentInputSchema.parse(input);
51
52
 
52
- const root = contentRoot ?? join(process.cwd(), "content");
53
+ const root = contentRoot ?? mcpContentRoot();
53
54
  const locale = resolveLocale(input.locale);
54
55
  const adapter = getMcpContentAdapter({ contentRoot: root, locale });
55
56
 
@@ -4,6 +4,8 @@
4
4
  "outDir": "./dist",
5
5
  "rootDir": "./src",
6
6
  "composite": true,
7
+ "module": "ESNext",
8
+ "moduleResolution": "bundler",
7
9
  "types": ["node"]
8
10
  },
9
11
  "include": ["src"],
@@ -19,6 +19,11 @@
19
19
  * Issue #7396 — `mcpEndpoint` is the configured MCP HTTP host or absent. It
20
20
  * was `${baseUrl}/api/mcp`, a path apps/web does not serve, so every agent
21
21
  * that followed it got a 404.
22
+ * Issue #8070 — `unservedTools` drops the tools this topology's MCP server
23
+ * does not register (`create_project` on a spoke).
24
+ * Issue #8057 — `description` resolves from SITE_DESCRIPTION
25
+ * (`siteIdentity.description` in _site.mdx → neutral default) instead of the
26
+ * template's own blurb on every site.
22
27
  */
23
28
 
24
29
  import {
@@ -27,7 +32,7 @@ import {
27
32
  type McpToolCatalogEntry,
28
33
  } from "@working-theory/metadata";
29
34
 
30
- import { SITE_NAME } from "../../../lib/site-brand";
35
+ import { SITE_DESCRIPTION, SITE_NAME } from "../../../lib/site-brand";
31
36
 
32
37
  /**
33
38
  * Inline transport descriptor shape — mirrors McpTransportDescriptor from
@@ -107,6 +112,14 @@ export interface BuildMcpManifestOptions {
107
112
  /** Overrides `MCP_FEATURE_GATE_MODE` — a test seam; `route.ts` never
108
113
  * passes it. */
109
114
  featureGate?: McpCatalogOptions["featureGate"];
115
+ /**
116
+ * Tool names this site's MCP server does not serve (#8070 D7) — the
117
+ * topology seam's `UNSERVED_MCP_TOOLS`: `create_project` on a spoke, none
118
+ * on a hub. Dropped from `tools`, so the manifest never advertises a tool
119
+ * the server will not register. Injected, like `commerceClaimed`. Omitted ⇒
120
+ * none dropped.
121
+ */
122
+ unservedTools?: readonly string[];
110
123
  }
111
124
 
112
125
  /** MCP HTTP transport version this server implements. */
@@ -132,8 +145,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
132
145
  return {
133
146
  schemaVersion: "1",
134
147
  name: SITE_NAME,
135
- description:
136
- "AX-first OSS template — ship software with API + MCP as canonical agent surfaces.",
148
+ description: SITE_DESCRIPTION,
137
149
  // Spread conditional to satisfy exactOptionalPropertyTypes: the key is
138
150
  // absent, not undefined, when no MCP HTTP host is configured.
139
151
  ...(opts.mcpHttpUrl ? { mcpEndpoint: opts.mcpHttpUrl } : {}),
@@ -141,7 +153,7 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
141
153
  addons: { commerce: opts.commerceClaimed === true },
142
154
  features: opts.heldFeatures ?? [],
143
155
  ...(opts.featureGate ? { featureGate: opts.featureGate } : {}),
144
- }),
156
+ }).filter((tool) => !(opts.unservedTools ?? []).includes(tool.name)),
145
157
  transport: {
146
158
  type: "http",
147
159
  version: MCP_HTTP_VERSION,
@@ -26,6 +26,7 @@
26
26
  * is appropriate per the MCP discovery spec.
27
27
  */
28
28
 
29
+ import { UNSERVED_MCP_TOOLS } from "../../../lib/topology.generated";
29
30
  import packageJson from "../../../package.json";
30
31
  import {
31
32
  COMMERCE_FEATURE,
@@ -59,6 +60,8 @@ export async function GET(): Promise<Response> {
59
60
  ailkVersion: packageJson.version,
60
61
  commerceClaimed: heldFeatures.includes(COMMERCE_FEATURE),
61
62
  heldFeatures,
63
+ // The tools this topology's MCP server does not register (#8070 D7).
64
+ unservedTools: UNSERVED_MCP_TOOLS,
62
65
  // Spread conditional to satisfy exactOptionalPropertyTypes: omit the key
63
66
  // entirely when the env var is not set rather than passing undefined.
64
67
  ...(mcpHttpUrlEnv ? { mcpHttpUrl: mcpHttpUrlEnv } : {}),
@@ -13,10 +13,24 @@
13
13
  * navigation telemetry for a Server Action, which is inherent to any hosted-
14
14
  * portal handoff, not something this design avoids).
15
15
  *
16
- * CSRF is already handled: `forwardedCookieInit()` forwards the incoming
17
- * request's real `Origin` alongside the cookie, and `apps/api`'s
18
- * `authPreHandler` requires a matching `Origin` on this cookie-authenticated
19
- * mutating request (D3.1/D5).
16
+ * CSRF protection depends on the render (`forwardedCookieInit()` branches on
17
+ * `SIGN_IN_PROVIDER`, `lib/server-api.ts`):
18
+ *
19
+ * - Hub render (`SIGN_IN_PROVIDER === "site"`): `forwardedCookieInit()`
20
+ * forwards the incoming request's real `Origin` alongside the Better Auth
21
+ * cookie, and `apps/api`'s `authPreHandler` requires that `Origin` to be one
22
+ * of its trusted origins on this cookie-authenticated mutating request
23
+ * (`isOriginAllowed`, `middleware/auth.ts`). The portal-session route also
24
+ * accepts only a cookie session (`request.sessionTenant`), never a bearer
25
+ * (D3.1/D5).
26
+ * - Spoke render, sign-in through the hub (`SIGN_IN_PROVIDER === "hub"`):
27
+ * there is no cookie and no `Origin` forward. `hubSessionInit()`
28
+ * (`lib/hub-session/session.ts`) sends the sealed session's credential as
29
+ * `Authorization: Bearer`, and `authPreHandler` skips its origin check for a
30
+ * bearer. This action does not complete on a spoke: the hub admits a hub
31
+ * credential on four read routes only (`HUB_SESSION_ROUTES`), so
32
+ * `POST /v1/subscription/portal-session` is a 404 there, and Settings hides
33
+ * the Billing button because `subscription.me()` is a 404 too.
20
34
  *
21
35
  * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE the
22
36
  * try/catch (mirrors `checkout.actions.ts`'s idiom) so the catch never
@@ -170,6 +170,48 @@ describe("COPYRIGHT_HOLDER (#7796)", () => {
170
170
  });
171
171
  });
172
172
 
173
+ /**
174
+ * `SITE_DESCRIPTION` — `siteIdentity.description`, else a neutral fallback
175
+ * that never names the template (#8057).
176
+ */
177
+ describe("SITE_DESCRIPTION (#8057)", () => {
178
+ function writeDescription(identityLines: string): void {
179
+ writeFileSync(
180
+ join(tmpRoot, "_site.mdx"),
181
+ `---\nsiteName: Fixture Co\nsiteIdentity:\n kind: Organization\n name: Fixture Co\n${identityLines}---\n`,
182
+ );
183
+ process.env["AILK_CONTENT_ROOT"] = tmpRoot;
184
+ delete process.env["NEXT_PUBLIC_SITE_NAME"];
185
+ }
186
+
187
+ it("is siteIdentity.description when the site declares one (folded scalar trimmed)", async () => {
188
+ writeDescription(" description: >\n Fixture Co sells widgets.\n");
189
+ const { SITE_DESCRIPTION } = await loadSiteBrand();
190
+
191
+ expect(SITE_DESCRIPTION).toBe("Fixture Co sells widgets.");
192
+ });
193
+
194
+ it("falls back to a neutral description that does not mention the template", async () => {
195
+ writeDescription("");
196
+ const { SITE_DESCRIPTION, FALLBACK_SITE_DESCRIPTION } =
197
+ await loadSiteBrand();
198
+
199
+ expect(SITE_DESCRIPTION).toBe(FALLBACK_SITE_DESCRIPTION);
200
+ expect(SITE_DESCRIPTION).not.toMatch(/template/i);
201
+ expect(SITE_DESCRIPTION).not.toMatch(/AX-first/);
202
+ });
203
+
204
+ it("falls back when description is blank or _site.mdx is absent", async () => {
205
+ writeDescription(' description: " "\n');
206
+ let mod = await loadSiteBrand();
207
+ expect(mod.SITE_DESCRIPTION).toBe(mod.FALLBACK_SITE_DESCRIPTION);
208
+
209
+ rmSync(join(tmpRoot, "_site.mdx"));
210
+ mod = await loadSiteBrand();
211
+ expect(mod.SITE_DESCRIPTION).toBe(mod.FALLBACK_SITE_DESCRIPTION);
212
+ });
213
+ });
214
+
173
215
  /**
174
216
  * `siteLogo()` — the brand triad's logomark (#6820 D5). No `brand:` renders
175
217
  * exactly today's element; a declared mark is the one rendered, with its dark
@@ -36,6 +36,12 @@ import { parse as parseYaml } from "yaml";
36
36
  export const FALLBACK_SITE_NAME = "My Site";
37
37
  /** Neutral, non-vendor fallback — see module doc comment point 3. */
38
38
  export const FALLBACK_BASE_URL = "https://example.com";
39
+ /**
40
+ * Neutral fallback for the site description (#8057) — says what the site
41
+ * exposes to agents without naming the template or any vendor.
42
+ */
43
+ export const FALLBACK_SITE_DESCRIPTION =
44
+ "A website with an HTTP API and an MCP server for AI agents.";
39
45
 
40
46
  const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
41
47
 
@@ -49,6 +55,8 @@ interface SiteFrontmatter {
49
55
  logomark?: unknown;
50
56
  /** `siteIdentity.legalName`, when declared as a non-empty string. */
51
57
  legalName?: string | undefined;
58
+ /** `siteIdentity.description`, when declared as a non-empty string. */
59
+ description?: string | undefined;
52
60
  }
53
61
 
54
62
  /**
@@ -90,15 +98,15 @@ function readSiteFrontmatter(): SiteFrontmatter | null {
90
98
  string,
91
99
  unknown
92
100
  >;
93
- const legalName =
101
+ const identity =
94
102
  siteIdentity && typeof siteIdentity === "object"
95
- ? (siteIdentity as Record<string, unknown>).legalName
103
+ ? (siteIdentity as Record<string, unknown>)
96
104
  : undefined;
105
+ const nonEmpty = (v: unknown): string | undefined =>
106
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
97
107
  return {
98
- legalName:
99
- typeof legalName === "string" && legalName.trim().length > 0
100
- ? legalName.trim()
101
- : undefined,
108
+ legalName: nonEmpty(identity?.legalName),
109
+ description: nonEmpty(identity?.description),
102
110
  siteName: typeof siteName === "string" ? siteName : undefined,
103
111
  baseUrl: typeof baseUrl === "string" ? baseUrl : undefined,
104
112
  logomark:
@@ -125,6 +133,14 @@ export const SITE_NAME =
125
133
  export const SITE_BASE_URL =
126
134
  process.env.NEXT_PUBLIC_BASE_URL ?? siteFrontmatter?.baseUrl ?? FALLBACK_BASE_URL;
127
135
 
136
+ /**
137
+ * The site's own description (#8057): `siteIdentity.description` from
138
+ * `_site.mdx`, else a neutral fallback that never names the template. Surfaced
139
+ * to agents by `/.well-known/mcp.json`.
140
+ */
141
+ export const SITE_DESCRIPTION =
142
+ siteFrontmatter?.description ?? FALLBACK_SITE_DESCRIPTION;
143
+
128
144
  /**
129
145
  * The footer copyright holder (#7796): `siteIdentity.legalName` when the site
130
146
  * declares one (schema.org's `Organization.legalName` — the owning entity,