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.
- package/dist/lib/env-catalog.js +5 -9
- package/dist/topology.d.ts +8 -0
- package/dist/topology.js +24 -2
- package/package.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +51 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/boot.test.ts +203 -0
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +185 -0
- package/templates/apps/mcp/src/http-transport.ts +39 -15
- package/templates/apps/mcp/src/index.ts +17 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +18 -4
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- 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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
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 {
|
|
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
|
|
42
|
-
*
|
|
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:
|
|
51
|
-
const root = contentRoot ??
|
|
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 {
|
|
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
|
|
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:
|
|
52
|
-
const root = contentRoot ??
|
|
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 {
|
|
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
|
|
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 ??
|
|
53
|
+
const root = contentRoot ?? mcpContentRoot();
|
|
53
54
|
const locale = resolveLocale(input.locale);
|
|
54
55
|
const adapter = getMcpContentAdapter({ contentRoot: root, locale });
|
|
55
56
|
|
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
101
|
+
const identity =
|
|
94
102
|
siteIdentity && typeof siteIdentity === "object"
|
|
95
|
-
? (siteIdentity as Record<string, unknown>)
|
|
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
|
-
|
|
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,
|