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
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Vercel entry (#8070 D2, D5).
|
|
3
|
+
*
|
|
4
|
+
* `src/app.ts` awaits the boot at module level, which ts-jest's CommonJS
|
|
5
|
+
* compile cannot load — so, like `index.test.ts`, its shape is checked as
|
|
6
|
+
* source text, and its behaviour is exercised through `serverless-app.ts`,
|
|
7
|
+
* the part it delegates to.
|
|
8
|
+
*
|
|
9
|
+
* Acceptance criteria verified here (spec docs/specs/8070-spoke-mcp-vercel-backend-spec.md):
|
|
10
|
+
* - AC-4 (amendment 2): app.ts imports Fastify, caps a request with
|
|
11
|
+
* `export const config = { maxDuration: 30 }`, builds the app from
|
|
12
|
+
* SERVERLESS_FASTIFY_OPTIONS, configures it, awaits `app.ready()`, default-exports
|
|
13
|
+
* `serverlessHandler(app)` (the `(req, res)` function Vercel's Node runtime
|
|
14
|
+
* calls — a Fastify instance is not a shape it accepts) and never listens; the
|
|
15
|
+
* options are `{ logger: false, trustProxy: trustOneHop }` — the spec's
|
|
16
|
+
* `trustProxy: 1` is a no-op on Fastify 5.12 (pinned below), so the same
|
|
17
|
+
* one-hop trust is expressed as a function; an `http.createServer` serving
|
|
18
|
+
* `serverlessHandler(app)` answers `initialize` and a session-less `tools/list`
|
|
19
|
+
* with MCP_HTTP_STATELESS unset.
|
|
20
|
+
* - AC-5: on the serverless app the daily cap keys on the LAST
|
|
21
|
+
* X-Forwarded-For hop; the long-running app ignores the header.
|
|
22
|
+
* - AC-13: tsconfig.json names `module` and `moduleResolution`, and there is
|
|
23
|
+
* no vercel.json.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { createServer, type Server } from "node:http";
|
|
28
|
+
import type { AddressInfo } from "node:net";
|
|
29
|
+
import { resolve } from "node:path";
|
|
30
|
+
|
|
31
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
32
|
+
|
|
33
|
+
import { buildHttpTransportApp } from "../src/http-transport.js";
|
|
34
|
+
import {
|
|
35
|
+
configureServerlessApp,
|
|
36
|
+
SERVERLESS_FASTIFY_OPTIONS,
|
|
37
|
+
serverlessHandler,
|
|
38
|
+
trustOneHop,
|
|
39
|
+
} from "../src/serverless-app.js";
|
|
40
|
+
|
|
41
|
+
// The hub call capture_lead makes after the guard is stubbed, as
|
|
42
|
+
// http-transport.test.ts stubs checkout: only the guard's verdict is asserted.
|
|
43
|
+
jest.mock("@working-theory/api-client", () => {
|
|
44
|
+
const actual = jest.requireActual("@working-theory/api-client");
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
apiClient: {
|
|
48
|
+
...actual.apiClient,
|
|
49
|
+
leads: {
|
|
50
|
+
...actual.apiClient.leads,
|
|
51
|
+
submit: jest.fn().mockResolvedValue({
|
|
52
|
+
ok: true,
|
|
53
|
+
body: {
|
|
54
|
+
id: "lead_00000000-0000-7000-8000-000000008070",
|
|
55
|
+
persisted: "db",
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const VARS = [
|
|
64
|
+
"MCP_HTTP_STATELESS",
|
|
65
|
+
"MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
|
|
66
|
+
"MCP_CORS_WRITE_ALLOWED_ORIGINS",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
const saved: Partial<Record<(typeof VARS)[number], string | undefined>> = {};
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
for (const name of VARS) {
|
|
73
|
+
saved[name] = process.env[name];
|
|
74
|
+
delete process.env[name];
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
for (const name of VARS) {
|
|
80
|
+
const value = saved[name];
|
|
81
|
+
if (value === undefined) delete process.env[name];
|
|
82
|
+
else process.env[name] = value;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
async function mcpPost(
|
|
87
|
+
port: number,
|
|
88
|
+
payload: object,
|
|
89
|
+
headers: Record<string, string> = {},
|
|
90
|
+
): Promise<Response> {
|
|
91
|
+
return fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: JSON.stringify(payload),
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
accept: "application/json, text/event-stream",
|
|
97
|
+
...headers,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function listen(app: FastifyInstance): Promise<number> {
|
|
103
|
+
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
104
|
+
return (app.server.address() as AddressInfo).port;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The app src/app.ts builds, minus the boot and the default export. */
|
|
108
|
+
async function serverlessApp(): Promise<FastifyInstance> {
|
|
109
|
+
const app = Fastify(SERVERLESS_FASTIFY_OPTIONS);
|
|
110
|
+
await configureServerlessApp(app);
|
|
111
|
+
await app.ready();
|
|
112
|
+
return app;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Serve the app through the exact function Vercel's Node runtime calls. */
|
|
116
|
+
async function serveHandler(
|
|
117
|
+
app: FastifyInstance,
|
|
118
|
+
): Promise<{ server: Server; port: number }> {
|
|
119
|
+
const server = createServer(serverlessHandler(app));
|
|
120
|
+
await new Promise<void>((done) => server.listen(0, "127.0.0.1", done));
|
|
121
|
+
return { server, port: (server.address() as AddressInfo).port };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function stop(server: Server | undefined): Promise<void> {
|
|
125
|
+
if (!server) return;
|
|
126
|
+
await new Promise<void>((done) => server.close(() => done()));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function captureLead(id: number, name: string): object {
|
|
130
|
+
return {
|
|
131
|
+
jsonrpc: "2.0",
|
|
132
|
+
id,
|
|
133
|
+
method: "tools/call",
|
|
134
|
+
params: {
|
|
135
|
+
name: "capture_lead",
|
|
136
|
+
arguments: {
|
|
137
|
+
name,
|
|
138
|
+
email: `${name.toLowerCase()}@example.com`,
|
|
139
|
+
message: "Interested in your services.",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("AC-4 — src/app.ts, read as source text", () => {
|
|
146
|
+
const src = readFileSync(resolve(__dirname, "../src/app.ts"), "utf-8");
|
|
147
|
+
|
|
148
|
+
it("imports Fastify by value, so Vercel's Node-backend detection finds it", () => {
|
|
149
|
+
expect(src).toContain('import Fastify from "fastify"');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("awaits the shared boot, builds Fastify from the serverless options and configures it", () => {
|
|
153
|
+
expect(src).toContain("await bootMcp()");
|
|
154
|
+
expect(src).toContain("Fastify(SERVERLESS_FASTIFY_OPTIONS)");
|
|
155
|
+
expect(src).toContain("configureServerlessApp(app)");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("boots before it builds the app, and readies it before it exports", () => {
|
|
159
|
+
expect(src.indexOf("await bootMcp()")).toBeLessThan(
|
|
160
|
+
src.indexOf("Fastify(SERVERLESS_FASTIFY_OPTIONS)"),
|
|
161
|
+
);
|
|
162
|
+
expect(src).toContain("await app.ready()");
|
|
163
|
+
expect(src.indexOf("configureServerlessApp(app)")).toBeLessThan(
|
|
164
|
+
src.indexOf("await app.ready()"),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("caps one request at 30 s with a literal exported config Vercel reads statically", () => {
|
|
169
|
+
expect(src).toContain("export const config = { maxDuration: 30 }");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("default-exports the handler, not the app, and never listens", () => {
|
|
173
|
+
expect(src).toContain("export default serverlessHandler(app)");
|
|
174
|
+
expect(src).not.toContain("export default app;");
|
|
175
|
+
expect(src).not.toContain("listen(");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("AC-13 — Vercel's type-check keeps strict", () => {
|
|
180
|
+
it("tsconfig.json names module and moduleResolution, so @vercel/node's fixConfig leaves strict alone", () => {
|
|
181
|
+
const tsconfig = JSON.parse(
|
|
182
|
+
readFileSync(resolve(__dirname, "../tsconfig.json"), "utf-8"),
|
|
183
|
+
) as { compilerOptions: Record<string, unknown> };
|
|
184
|
+
expect(tsconfig.compilerOptions["module"]).toBe("ESNext");
|
|
185
|
+
expect(tsconfig.compilerOptions["moduleResolution"]).toBe("bundler");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("there is no vercel.json (its functions pattern is rejected for the fastify builder)", () => {
|
|
189
|
+
expect(existsSync(resolve(__dirname, "../vercel.json"))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("AC-4 — the serverless options and the configured app", () => {
|
|
194
|
+
let app: FastifyInstance | undefined;
|
|
195
|
+
let server: Server | undefined;
|
|
196
|
+
let port = 0;
|
|
197
|
+
|
|
198
|
+
afterEach(async () => {
|
|
199
|
+
await stop(server);
|
|
200
|
+
await app?.close();
|
|
201
|
+
app = undefined;
|
|
202
|
+
server = undefined;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("SERVERLESS_FASTIFY_OPTIONS is { logger: false, trustProxy: trustOneHop }", () => {
|
|
206
|
+
expect(SERVERLESS_FASTIFY_OPTIONS).toEqual({
|
|
207
|
+
logger: false,
|
|
208
|
+
trustProxy: trustOneHop,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("trustOneHop trusts the immediate peer (hop 0) and nothing behind it, whatever its address", () => {
|
|
213
|
+
expect(trustOneHop("127.0.0.1", 0)).toBe(true);
|
|
214
|
+
expect(trustOneHop("10.0.0.1", 0)).toBe(true);
|
|
215
|
+
expect(trustOneHop("9.9.9.9", 1)).toBe(false);
|
|
216
|
+
expect(trustOneHop("1.1.1.1", 2)).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("why not trustProxy: 1 — this Fastify treats a numeric hop count as trust-nothing", async () => {
|
|
220
|
+
const numeric = Fastify({ logger: false, trustProxy: 1 });
|
|
221
|
+
numeric.get("/ip", async (request) => ({ ip: request.ip }));
|
|
222
|
+
const res = await numeric.inject({
|
|
223
|
+
url: "/ip",
|
|
224
|
+
remoteAddress: "127.0.0.1",
|
|
225
|
+
headers: { "x-forwarded-for": "3.3.3.3, 8.8.8.8" },
|
|
226
|
+
});
|
|
227
|
+
await numeric.close();
|
|
228
|
+
|
|
229
|
+
// Every caller would key on the edge's address.
|
|
230
|
+
expect(res.json()).toEqual({ ip: "127.0.0.1" });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("serverlessHandler(app) is a (req, res) function", async () => {
|
|
234
|
+
app = await serverlessApp();
|
|
235
|
+
expect(typeof serverlessHandler(app)).toBe("function");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("with MCP_HTTP_STATELESS unset, an http.createServer serving serverlessHandler(app) answers initialize and then a session-less tools/list", async () => {
|
|
239
|
+
app = await serverlessApp();
|
|
240
|
+
({ server, port } = await serveHandler(app));
|
|
241
|
+
|
|
242
|
+
const init = await mcpPost(port, {
|
|
243
|
+
jsonrpc: "2.0",
|
|
244
|
+
id: 1,
|
|
245
|
+
method: "initialize",
|
|
246
|
+
params: {
|
|
247
|
+
protocolVersion: "2025-03-26",
|
|
248
|
+
capabilities: {},
|
|
249
|
+
clientInfo: { name: "app-entry-test", version: "1.0.0" },
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
expect(init.status).toBe(200);
|
|
253
|
+
const initBody = (await init.json()) as {
|
|
254
|
+
result?: { serverInfo?: { name?: string } };
|
|
255
|
+
};
|
|
256
|
+
expect(initBody.result?.serverInfo).toMatchObject({ name: "ailk" });
|
|
257
|
+
|
|
258
|
+
const list = await mcpPost(port, {
|
|
259
|
+
jsonrpc: "2.0",
|
|
260
|
+
id: 2,
|
|
261
|
+
method: "tools/list",
|
|
262
|
+
params: {},
|
|
263
|
+
});
|
|
264
|
+
expect(list.status).toBe(200);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("AC-5 — the daily cap keys on the last X-Forwarded-For hop", () => {
|
|
269
|
+
let app: FastifyInstance | undefined;
|
|
270
|
+
let server: Server | undefined;
|
|
271
|
+
|
|
272
|
+
afterEach(async () => {
|
|
273
|
+
await stop(server);
|
|
274
|
+
await app?.close();
|
|
275
|
+
app = undefined;
|
|
276
|
+
server = undefined;
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("serverless app: two callers sharing a last hop are one caller; a different last hop is another", async () => {
|
|
280
|
+
process.env["MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP"] = "1";
|
|
281
|
+
// Set explicitly so the write-origin check is never what refuses a call.
|
|
282
|
+
process.env["MCP_CORS_WRITE_ALLOWED_ORIGINS"] = "*";
|
|
283
|
+
app = await serverlessApp();
|
|
284
|
+
const { server: served, port } = await serveHandler(app);
|
|
285
|
+
server = served;
|
|
286
|
+
|
|
287
|
+
const first = await mcpPost(port, captureLead(10, "First"), {
|
|
288
|
+
"x-forwarded-for": "1.1.1.1, 9.9.9.9",
|
|
289
|
+
});
|
|
290
|
+
expect(first.status).toBe(200);
|
|
291
|
+
|
|
292
|
+
const second = await mcpPost(port, captureLead(11, "Second"), {
|
|
293
|
+
"x-forwarded-for": "2.2.2.2, 9.9.9.9",
|
|
294
|
+
});
|
|
295
|
+
expect(second.status).toBe(403);
|
|
296
|
+
const refusal = (await second.json()) as { error?: string };
|
|
297
|
+
expect(refusal.error).toBe("daily capture_lead cap reached for this IP");
|
|
298
|
+
|
|
299
|
+
const third = await mcpPost(port, captureLead(12, "Third"), {
|
|
300
|
+
"x-forwarded-for": "3.3.3.3, 8.8.8.8",
|
|
301
|
+
});
|
|
302
|
+
expect(third.status).toBe(200);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("long-running app (no trustProxy): the header is ignored — every call is the socket's address", async () => {
|
|
306
|
+
app = await buildHttpTransportApp({
|
|
307
|
+
path: "/mcp",
|
|
308
|
+
stateless: true,
|
|
309
|
+
rateLimits: { readPerMin: 60, writePerMin: 10 },
|
|
310
|
+
cors: { writeAllowedOrigins: ["*"] },
|
|
311
|
+
abuse: { captureLeadDailyCapPerIp: 1, blockedUserAgents: "" },
|
|
312
|
+
});
|
|
313
|
+
const port = await listen(app);
|
|
314
|
+
|
|
315
|
+
const first = await mcpPost(port, captureLead(20, "First"), {
|
|
316
|
+
"x-forwarded-for": "3.3.3.3",
|
|
317
|
+
});
|
|
318
|
+
expect(first.status).toBe(200);
|
|
319
|
+
|
|
320
|
+
const second = await mcpPost(port, captureLead(21, "Second"), {
|
|
321
|
+
"x-forwarded-for": "4.4.4.4",
|
|
322
|
+
});
|
|
323
|
+
expect(second.status).toBe(403);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared boot module (#8070 D1, D2): `readStatelessEnv` and
|
|
3
|
+
* `httpTransportConfigFromEnv`, which both entrypoints read the environment
|
|
4
|
+
* through.
|
|
5
|
+
*
|
|
6
|
+
* Acceptance criteria verified here (spec docs/specs/8070-spoke-mcp-vercel-backend-spec.md):
|
|
7
|
+
* - AC-1: MCP_HTTP_STATELESS=true selects the stateless transport — a
|
|
8
|
+
* session-less tools/list answers 200 and lists list_pages.
|
|
9
|
+
* - AC-2: each entry's default, and the accepted spellings.
|
|
10
|
+
* - AC-3: a bad value throws naming the variable; a blank per-minute rate
|
|
11
|
+
* falls back to its default rather than disabling the limit.
|
|
12
|
+
*
|
|
13
|
+
* Real sockets on port 0, the pattern of `http-transport.test.ts`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AddressInfo } from "node:net";
|
|
17
|
+
|
|
18
|
+
import type { FastifyInstance } from "fastify";
|
|
19
|
+
|
|
20
|
+
import { httpTransportConfigFromEnv, readStatelessEnv } from "../src/boot.js";
|
|
21
|
+
import { buildHttpTransportApp } from "../src/http-transport.js";
|
|
22
|
+
|
|
23
|
+
const VARS = [
|
|
24
|
+
"MCP_HTTP_STATELESS",
|
|
25
|
+
"MCP_HTTP_PORT",
|
|
26
|
+
"MCP_HTTP_PATH",
|
|
27
|
+
"MCP_RATE_LIMIT_READ_PER_MIN",
|
|
28
|
+
"MCP_RATE_LIMIT_WRITE_PER_MIN",
|
|
29
|
+
"MCP_CORS_WRITE_ALLOWED_ORIGINS",
|
|
30
|
+
"MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP",
|
|
31
|
+
"MCP_ABUSE_START_PURCHASE_DAILY_CAP",
|
|
32
|
+
"MCP_ABUSE_BLOCKED_USER_AGENTS",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
const saved: Partial<Record<(typeof VARS)[number], string | undefined>> = {};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
for (const name of VARS) {
|
|
39
|
+
saved[name] = process.env[name];
|
|
40
|
+
delete process.env[name];
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
for (const name of VARS) {
|
|
46
|
+
const value = saved[name];
|
|
47
|
+
if (value === undefined) delete process.env[name];
|
|
48
|
+
else process.env[name] = value;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} };
|
|
53
|
+
|
|
54
|
+
async function listen(app: FastifyInstance): Promise<number> {
|
|
55
|
+
await app.listen({ host: "127.0.0.1", port: 0 });
|
|
56
|
+
return (app.server.address() as AddressInfo).port;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function postToolsList(port: number): Promise<Response> {
|
|
60
|
+
return fetch(`http://127.0.0.1:${port}/mcp`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
body: JSON.stringify(TOOLS_LIST),
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
accept: "application/json, text/event-stream",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The app the long-running entry builds, from the environment as it stands. */
|
|
71
|
+
async function appFromEnv(defaultStateless: boolean): Promise<FastifyInstance> {
|
|
72
|
+
const { port: _port, ...config } = httpTransportConfigFromEnv();
|
|
73
|
+
return buildHttpTransportApp({
|
|
74
|
+
...config,
|
|
75
|
+
stateless: readStatelessEnv(defaultStateless),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("AC-1 — MCP_HTTP_STATELESS=true selects the stateless transport", () => {
|
|
80
|
+
let app: FastifyInstance | undefined;
|
|
81
|
+
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
await app?.close();
|
|
84
|
+
app = undefined;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("answers a tools/list with no Mcp-Session-Id with 200, listing list_pages", async () => {
|
|
88
|
+
process.env["MCP_HTTP_STATELESS"] = "true";
|
|
89
|
+
app = await appFromEnv(false);
|
|
90
|
+
const port = await listen(app);
|
|
91
|
+
|
|
92
|
+
const res = await postToolsList(port);
|
|
93
|
+
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
const body = (await res.json()) as {
|
|
96
|
+
result?: { tools?: Array<{ name: string }> };
|
|
97
|
+
};
|
|
98
|
+
const names = (body.result?.tools ?? []).map((tool) => tool.name);
|
|
99
|
+
expect(names).toContain("list_pages");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("AC-2 — each entry's default, and the accepted spellings", () => {
|
|
104
|
+
let app: FastifyInstance | undefined;
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await app?.close();
|
|
108
|
+
app = undefined;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("unset, the long-running default is false — and a session-less tools/list is 400 (today's stateful behaviour)", async () => {
|
|
112
|
+
expect(readStatelessEnv(false)).toBe(false);
|
|
113
|
+
|
|
114
|
+
app = await appFromEnv(false);
|
|
115
|
+
const port = await listen(app);
|
|
116
|
+
|
|
117
|
+
const res = await postToolsList(port);
|
|
118
|
+
expect(res.status).toBe(400);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("unset, the serverless default is true", () => {
|
|
122
|
+
expect(readStatelessEnv(true)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("an empty value takes the default", () => {
|
|
126
|
+
process.env["MCP_HTTP_STATELESS"] = "";
|
|
127
|
+
expect(readStatelessEnv(true)).toBe(true);
|
|
128
|
+
expect(readStatelessEnv(false)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each(["false", "0", "FALSE"])("%s reads as false", (value) => {
|
|
132
|
+
process.env["MCP_HTTP_STATELESS"] = value;
|
|
133
|
+
expect(readStatelessEnv(true)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it.each(["1", "True"])("%s reads as true", (value) => {
|
|
137
|
+
process.env["MCP_HTTP_STATELESS"] = value;
|
|
138
|
+
expect(readStatelessEnv(false)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("surrounding whitespace is trimmed", () => {
|
|
142
|
+
process.env["MCP_HTTP_STATELESS"] = " true ";
|
|
143
|
+
expect(readStatelessEnv(false)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("AC-3 — a bad value fails the boot; a blank rate does not disable the limit", () => {
|
|
148
|
+
it("a value outside the four spellings throws an Error naming MCP_HTTP_STATELESS", () => {
|
|
149
|
+
process.env["MCP_HTTP_STATELESS"] = "yes";
|
|
150
|
+
expect(() => readStatelessEnv(true)).toThrow(Error);
|
|
151
|
+
expect(() => readStatelessEnv(true)).toThrow(/MCP_HTTP_STATELESS/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("MCP_RATE_LIMIT_READ_PER_MIN='' yields the default 60, never NaN", () => {
|
|
155
|
+
process.env["MCP_RATE_LIMIT_READ_PER_MIN"] = "";
|
|
156
|
+
expect(httpTransportConfigFromEnv().rateLimits.readPerMin).toBe(60);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("MCP_RATE_LIMIT_WRITE_PER_MIN='' yields the default 10, never NaN", () => {
|
|
160
|
+
process.env["MCP_RATE_LIMIT_WRITE_PER_MIN"] = "";
|
|
161
|
+
expect(httpTransportConfigFromEnv().rateLimits.writePerMin).toBe(10);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("httpTransportConfigFromEnv — today's reading of every other variable", () => {
|
|
166
|
+
it("unset, it returns today's defaults", () => {
|
|
167
|
+
expect(httpTransportConfigFromEnv()).toEqual({
|
|
168
|
+
port: 8080,
|
|
169
|
+
path: "/mcp",
|
|
170
|
+
rateLimits: { readPerMin: 60, writePerMin: 10 },
|
|
171
|
+
cors: { writeAllowedOrigins: [] },
|
|
172
|
+
abuse: {
|
|
173
|
+
captureLeadDailyCapPerIp: 50,
|
|
174
|
+
startPurchaseDailyCapPerIp: 20,
|
|
175
|
+
blockedUserAgents: "",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("set, it reads each variable as src/index.ts did", () => {
|
|
181
|
+
process.env["MCP_HTTP_PORT"] = "9090";
|
|
182
|
+
process.env["MCP_HTTP_PATH"] = "/agents";
|
|
183
|
+
process.env["MCP_RATE_LIMIT_READ_PER_MIN"] = "120";
|
|
184
|
+
process.env["MCP_RATE_LIMIT_WRITE_PER_MIN"] = "5";
|
|
185
|
+
process.env["MCP_CORS_WRITE_ALLOWED_ORIGINS"] =
|
|
186
|
+
"https://a.example, https://b.example";
|
|
187
|
+
process.env["MCP_ABUSE_CAPTURE_LEAD_DAILY_CAP"] = "3";
|
|
188
|
+
process.env["MCP_ABUSE_START_PURCHASE_DAILY_CAP"] = "2";
|
|
189
|
+
process.env["MCP_ABUSE_BLOCKED_USER_AGENTS"] = "BadBot";
|
|
190
|
+
|
|
191
|
+
expect(httpTransportConfigFromEnv()).toEqual({
|
|
192
|
+
port: 9090,
|
|
193
|
+
path: "/agents",
|
|
194
|
+
rateLimits: { readPerMin: 120, writePerMin: 5 },
|
|
195
|
+
cors: { writeAllowedOrigins: ["https://a.example", "https://b.example"] },
|
|
196
|
+
abuse: {
|
|
197
|
+
captureLeadDailyCapPerIp: 3,
|
|
198
|
+
startPurchaseDailyCapPerIp: 2,
|
|
199
|
+
blockedUserAgents: "BadBot",
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|