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.
- package/dist/lib/env-catalog.js +5 -9
- package/dist/spoke-checkout.d.ts +27 -2
- package/dist/spoke-checkout.js +207 -4
- package/dist/topology.d.ts +8 -0
- package/dist/topology.js +29 -5
- package/package.json +3 -3
- 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/app/openapi.json/__tests__/route.test.ts +46 -28
- package/templates/apps/web/lib/__tests__/relay.test.ts +13 -7
- package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
- 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
- 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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
122
|
-
*
|
|
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
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
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
|
|
294
|
+
export async function registerHttpTransport(
|
|
295
|
+
app: FastifyInstance,
|
|
285
296
|
config: HttpTransportConfig,
|
|
286
|
-
): Promise<
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
//
|
|
90
|
-
// the
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
150
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
|
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 ??
|
|
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/
|
|
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 {
|
|
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
|
|
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 ??
|
|
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
|
-
*
|
|
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 `
|
|
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 {
|
|
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
|
|