create-ailk 0.10.0 → 0.11.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/README.md +7 -2
- package/dist/cli.js +5 -1
- package/dist/content-payloads.d.ts +37 -0
- package/dist/content-payloads.js +64 -0
- package/dist/lib/fetch-module.d.ts +3 -3
- package/dist/lib/fetch-module.js +11 -5
- package/dist/lib/fetch-template.d.ts +1 -1
- package/dist/lib/fetch-template.js +7 -4
- package/dist/module-architecture.d.ts +9 -0
- package/dist/module-architecture.js +43 -12
- package/dist/parse-args.d.ts +2 -0
- package/dist/parse-args.js +7 -0
- package/dist/surfaces.js +2 -3
- package/package.json +3 -3
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +4 -3
- package/templates/.claude/settings.json +12 -0
- package/templates/.env.example +1 -1
- package/templates/CLAUDE.md +38 -22
- package/templates/README.md +12 -12
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/mcp/CLAUDE.md +23 -10
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +160 -17
- package/templates/apps/mcp/__tests__/index.test.ts +118 -1
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +425 -5
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +67 -3
- package/templates/apps/mcp/__tests__/server.test.ts +162 -1
- package/templates/apps/mcp/src/index.ts +35 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +193 -24
- package/templates/apps/mcp/src/server.ts +77 -5
- package/templates/apps/mcp/src/tools/purchase.ts +1 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +15 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +10 -3
- package/templates/apps/web/src/lib/commerce-claim.ts +15 -6
- package/templates/content/_site.mdx +23 -4
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +34 -33
- package/templates/.claude/skills/README.md +0 -37
- package/templates/.claude/skills/add-auth/SKILL.md +0 -313
- package/templates/.claude/skills/add-auth/references/env-vars.md +0 -106
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +0 -180
- package/templates/.claude/skills/add-page/SKILL.md +0 -260
- package/templates/.claude/skills/add-page/references/page-types.md +0 -123
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +0 -87
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +0 -99
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +0 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +0 -43
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +0 -52
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +0 -38
- package/templates/.claude/skills/check-site/SKILL.md +0 -245
- package/templates/.claude/skills/check-site/references/common-errors.md +0 -116
- package/templates/.claude/skills/check-site/references/findings-glossary.md +0 -140
- package/templates/.claude/skills/check-site/references/severity-levels.md +0 -114
- package/templates/.claude/skills/fill-page/SKILL.md +0 -199
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +0 -80
- package/templates/.claude/skills/fill-page/references/section-sources.md +0 -41
- package/templates/.claude/skills/setup-database/SKILL.md +0 -156
- package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +0 -154
- package/templates/.claude/skills/setup-deploy/SKILL.md +0 -312
- package/templates/.claude/skills/setup-deploy/references/runbook-template.md +0 -204
- package/templates/.claude/skills/setup-deploy/references/vercel-json-template.md +0 -98
- package/templates/.claude/skills/update-page/SKILL.md +0 -117
- package/templates/.claude/skills/update-page/scripts/update-page.ts +0 -640
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +0 -31
- package/templates/.claude/skills/upgrade-plan/SKILL.md +0 -121
- package/templates/.claude/skills/upgrade-plan/references/options.md +0 -24
- package/templates/.claude/skills/upgrade-plan/scripts/upgrade-plan.ts +0 -527
- package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +0 -75
- package/templates/apps/mcp/src/lib/require-paid-tier.ts +0 -44
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* (#5971, superseding S15a / issue #610). The describe block at the end of
|
|
12
12
|
* this file pins the registration half from the bootstrap's own side.
|
|
13
13
|
*
|
|
14
|
+
* #7136: the five licence-feature-tagged tools — registration in both gate
|
|
15
|
+
* modes, the once-per-boot warning (`reportUnlicensedProTools`), and its
|
|
16
|
+
* place in `index.ts`'s boot order.
|
|
17
|
+
*
|
|
14
18
|
* Strategy: createMcpServer() returns the McpServer instance before connecting
|
|
15
19
|
* to a transport. Tests inspect server._registeredTools (plain object in SDK
|
|
16
20
|
* v1.x — no public API exists for listing tools without a live transport).
|
|
@@ -18,11 +22,20 @@
|
|
|
18
22
|
* casts below. Pinned to @modelcontextprotocol/sdk ^1.29.0.
|
|
19
23
|
*/
|
|
20
24
|
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
21
28
|
import {
|
|
22
29
|
__setMcpCommerceClaimForTest,
|
|
30
|
+
__setMcpFeatureGateForTest,
|
|
31
|
+
__setMcpHeldFeaturesForTest,
|
|
23
32
|
resetMcpCommerceClaim,
|
|
24
33
|
} from "../src/lib/commerce-claim.js";
|
|
25
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
createMcpServer,
|
|
36
|
+
listRegisteredTools,
|
|
37
|
+
reportUnlicensedProTools,
|
|
38
|
+
} from "../src/server.js";
|
|
26
39
|
|
|
27
40
|
describe("createMcpServer", () => {
|
|
28
41
|
it("creates an McpServer without throwing", () => {
|
|
@@ -166,3 +179,151 @@ describe("start_purchase registration — gated on the commerce claim (#5971)",
|
|
|
166
179
|
expect(keyed).toEqual([...keyless, "start_purchase"].sort());
|
|
167
180
|
});
|
|
168
181
|
});
|
|
182
|
+
|
|
183
|
+
describe("the five licence-feature tools — one-release warning cycle (#7136)", () => {
|
|
184
|
+
const FLOW_PAIR = ["get_flow", "submit_flow_step"];
|
|
185
|
+
const BOOKING_TRIO = [
|
|
186
|
+
"list_availability",
|
|
187
|
+
"get_event_meta",
|
|
188
|
+
"create_booking",
|
|
189
|
+
];
|
|
190
|
+
const FIVE = [...FLOW_PAIR, ...BOOKING_TRIO];
|
|
191
|
+
const FEATURE_OF: Record<string, string> = {
|
|
192
|
+
get_flow: "flows-deliverables",
|
|
193
|
+
submit_flow_step: "flows-deliverables",
|
|
194
|
+
list_availability: "scheduling",
|
|
195
|
+
get_event_meta: "scheduling",
|
|
196
|
+
create_booking: "scheduling",
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
process.env.AILK_ENABLED_MODULES = "scheduling,signal-collectors";
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(() => {
|
|
206
|
+
resetMcpCommerceClaim();
|
|
207
|
+
jest.restoreAllMocks();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
afterAll(() => {
|
|
211
|
+
if (priorEnabledModules === undefined) {
|
|
212
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
213
|
+
} else {
|
|
214
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
function registeredKeys(): string[] {
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- #506/#507: mirrors the registration assertions above
|
|
220
|
+
return Object.keys((createMcpServer() as any)._registeredTools);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it("warn mode, no feature held: all five still register and serve", () => {
|
|
224
|
+
__setMcpHeldFeaturesForTest([]);
|
|
225
|
+
|
|
226
|
+
const listed = listRegisteredTools().map((t) => t.name);
|
|
227
|
+
const registered = registeredKeys();
|
|
228
|
+
for (const name of FIVE) {
|
|
229
|
+
expect(listed).toContain(name);
|
|
230
|
+
expect(registered).toContain(name);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("warn mode, no feature held: one warning per tool, naming the tool, its feature and the next release", () => {
|
|
235
|
+
__setMcpHeldFeaturesForTest([]);
|
|
236
|
+
const warn = jest.fn();
|
|
237
|
+
|
|
238
|
+
const names = reportUnlicensedProTools(warn);
|
|
239
|
+
|
|
240
|
+
expect(names.slice().sort()).toEqual(FIVE.slice().sort());
|
|
241
|
+
expect(warn).toHaveBeenCalledTimes(5);
|
|
242
|
+
for (const name of FIVE) {
|
|
243
|
+
const message = warn.mock.calls
|
|
244
|
+
.map(([m]) => m as string)
|
|
245
|
+
.find((m) => m.includes(`"${name}"`));
|
|
246
|
+
expect(message).toBeDefined();
|
|
247
|
+
expect(message).toContain(`"${FEATURE_OF[name]}"`);
|
|
248
|
+
expect(message).toContain("next release stops serving it");
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("warns only for tools whose feature is unheld", () => {
|
|
253
|
+
const warn = jest.fn();
|
|
254
|
+
|
|
255
|
+
__setMcpHeldFeaturesForTest(["scheduling"]);
|
|
256
|
+
expect(reportUnlicensedProTools(warn).sort()).toEqual(
|
|
257
|
+
FLOW_PAIR.slice().sort(),
|
|
258
|
+
);
|
|
259
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
260
|
+
|
|
261
|
+
warn.mockClear();
|
|
262
|
+
__setMcpHeldFeaturesForTest(["scheduling", "flows-deliverables"]);
|
|
263
|
+
expect(reportUnlicensedProTools(warn)).toEqual([]);
|
|
264
|
+
expect(warn).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("never warns about a tool whose module is off", () => {
|
|
268
|
+
process.env.AILK_ENABLED_MODULES = "signal-collectors";
|
|
269
|
+
__setMcpHeldFeaturesForTest([]);
|
|
270
|
+
const warn = jest.fn();
|
|
271
|
+
|
|
272
|
+
expect(reportUnlicensedProTools(warn).sort()).toEqual(
|
|
273
|
+
FLOW_PAIR.slice().sort(),
|
|
274
|
+
);
|
|
275
|
+
expect(warn).toHaveBeenCalledTimes(2);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("refuse mode, no feature held: none of the five registers, and nothing is warned", () => {
|
|
279
|
+
__setMcpFeatureGateForTest("refuse");
|
|
280
|
+
__setMcpHeldFeaturesForTest([]);
|
|
281
|
+
const warn = jest.fn();
|
|
282
|
+
|
|
283
|
+
expect(reportUnlicensedProTools(warn)).toEqual([]);
|
|
284
|
+
expect(warn).not.toHaveBeenCalled();
|
|
285
|
+
const listed = listRegisteredTools().map((t) => t.name);
|
|
286
|
+
const registered = registeredKeys();
|
|
287
|
+
for (const name of FIVE) {
|
|
288
|
+
expect(listed).not.toContain(name);
|
|
289
|
+
expect(registered).not.toContain(name);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("refuse mode registers exactly the tools whose feature is held", () => {
|
|
294
|
+
__setMcpFeatureGateForTest("refuse");
|
|
295
|
+
__setMcpHeldFeaturesForTest(["scheduling"]);
|
|
296
|
+
|
|
297
|
+
const registered = registeredKeys();
|
|
298
|
+
for (const name of BOOKING_TRIO) expect(registered).toContain(name);
|
|
299
|
+
for (const name of FLOW_PAIR) expect(registered).not.toContain(name);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("building a server never warns — the warning is once per boot, not per HTTP session", () => {
|
|
303
|
+
__setMcpHeldFeaturesForTest([]);
|
|
304
|
+
const consoleWarn = jest
|
|
305
|
+
.spyOn(console, "warn")
|
|
306
|
+
.mockImplementation(() => undefined);
|
|
307
|
+
|
|
308
|
+
createMcpServer();
|
|
309
|
+
createMcpServer();
|
|
310
|
+
|
|
311
|
+
expect(consoleWarn).not.toHaveBeenCalled();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("index.ts reports after the claim resolves and before either transport starts", () => {
|
|
315
|
+
const index = readFileSync(join(__dirname, "../src/index.ts"), "utf8");
|
|
316
|
+
const init = index.indexOf("await initMcpCommerceClaim()");
|
|
317
|
+
const report = index.indexOf("reportUnlicensedProTools(");
|
|
318
|
+
const transport = index.indexOf('if (transport === "http")');
|
|
319
|
+
|
|
320
|
+
expect(init).toBeGreaterThan(-1);
|
|
321
|
+
expect(report).toBeGreaterThan(init);
|
|
322
|
+
expect(transport).toBeGreaterThan(report);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("server.ts never writes to stdout — the stdio transport's JSON-RPC channel", () => {
|
|
326
|
+
const server = readFileSync(join(__dirname, "../src/server.ts"), "utf8");
|
|
327
|
+
expect(server).not.toContain("console.log");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* DATABASE_URL= (required only when CONTENT_ADAPTER=database)
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
initMcpCommerceClaim,
|
|
26
|
+
startMcpClaimRefresh,
|
|
27
|
+
} from "./lib/commerce-claim.js";
|
|
25
28
|
import { registerLicenseKeyStore } from "./lib/license-key-store.js";
|
|
26
29
|
import { initMcpPublicWorkspace } from "./lib/public-workspace.js";
|
|
27
30
|
|
|
@@ -55,10 +58,17 @@ try {
|
|
|
55
58
|
// it. Never throws; without DATABASE_URL the file store stays in place.
|
|
56
59
|
await registerLicenseKeyStore();
|
|
57
60
|
|
|
58
|
-
// Resolves the deployment's `commerce` add-on claim
|
|
61
|
+
// Resolves the deployment's `commerce` add-on claim at boot, before either
|
|
59
62
|
// transport branch — the sibling of the workspace resolution above and the
|
|
60
63
|
// same #5876 idiom (#5971 spec D2). Without this await the memoised claim is
|
|
61
|
-
// never resolved and `start_purchase` is invisible on a KEYED site too.
|
|
64
|
+
// never resolved and `start_purchase` is invisible on a KEYED site too. The
|
|
65
|
+
// same single licence read now also resolves the licence features the
|
|
66
|
+
// deployment holds, which gate the five `feature`-tagged tools (#7136).
|
|
67
|
+
// The re-read loop below runs every `MCP_CLAIM_REFRESH_MS` thereafter
|
|
68
|
+
// (#7156), so the server's origin agrees with the manifest route's origin
|
|
69
|
+
// response within that bound; the route answers
|
|
70
|
+
// `cache-control: public, max-age=86400`, so a downstream cache may serve an
|
|
71
|
+
// older manifest for up to 24 hours.
|
|
62
72
|
//
|
|
63
73
|
// A REJECTION is caught for the same reason: a licence read that fails must
|
|
64
74
|
// not take the other 19 tools down. `initMcpCommerceClaim()` already resolves
|
|
@@ -74,6 +84,28 @@ try {
|
|
|
74
84
|
);
|
|
75
85
|
}
|
|
76
86
|
|
|
87
|
+
// Starts the bounded re-read (#7156 spec D1), after the boot read resolves so
|
|
88
|
+
// the first re-read never races it. Never throws; the timer is unref'd.
|
|
89
|
+
startMcpClaimRefresh();
|
|
90
|
+
|
|
91
|
+
// The one-release warning cycle (#7136): once per process, whichever
|
|
92
|
+
// transport follows, one stderr line per tool served without its licence
|
|
93
|
+
// feature. After the claim resolves, or it would warn for every tool.
|
|
94
|
+
//
|
|
95
|
+
// A throw here must not stop the server (#7156 spec D2, L1) — only the call
|
|
96
|
+
// is guarded, not the import: if `./server.js` fails to load, both transport
|
|
97
|
+
// branches load it again and the server could not start anyway.
|
|
98
|
+
const { reportUnlicensedProTools } = await import("./server.js");
|
|
99
|
+
try {
|
|
100
|
+
reportUnlicensedProTools();
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error(
|
|
103
|
+
"[mcp] reportUnlicensedProTools() failed — the licence boot warning was " +
|
|
104
|
+
"not written; every tool is served as registered:",
|
|
105
|
+
err,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
77
109
|
const transport = process.env["MCP_TRANSPORT"] ?? "stdio";
|
|
78
110
|
|
|
79
111
|
if (transport === "http") {
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The deployment's
|
|
3
|
-
*
|
|
2
|
+
* The deployment's licence claims, resolved at boot and re-read every
|
|
3
|
+
* `MCP_CLAIM_REFRESH_MS` (#7156 spec D1), read synchronously thereafter: the
|
|
4
|
+
* `commerce` add-on claim (#5971 spec D2) and,
|
|
5
|
+
* from the same single `checkLicense()` read, the set of licence features the
|
|
6
|
+
* deployment holds (#7136 spec D3) — which gates the five `feature`-tagged
|
|
7
|
+
* catalog tools under `MCP_FEATURE_GATE_MODE`. The claim is the deployment's
|
|
8
|
+
* own: no tool argument, request header or session feeds it.
|
|
4
9
|
*
|
|
5
10
|
* Why memoised: `createMcpServer()` and `listRegisteredTools()`
|
|
6
11
|
* (`apps/mcp/src/server.ts`) are synchronous and must stay that way —
|
|
@@ -20,17 +25,18 @@
|
|
|
20
25
|
* `unclaimed` at the boundary, and this gate needs neither the stored-JWT read
|
|
21
26
|
* nor the env-presence read the API twin carries.
|
|
22
27
|
*
|
|
23
|
-
* Fail closed (NFR-3): the initial state is `"unclaimed"
|
|
24
|
-
* cannot POSITIVELY establish the claim — an
|
|
25
|
-
* `checkLicense()`, a revoked licence, an expired
|
|
26
|
-
* it that way.
|
|
28
|
+
* Fail closed (NFR-3): the initial state is `"unclaimed"` with no feature
|
|
29
|
+
* held, and every path that cannot POSITIVELY establish the claim — an
|
|
30
|
+
* unresolved boot, a thrown `checkLicense()`, a revoked licence, an expired
|
|
31
|
+
* or unreadable one — leaves it that way.
|
|
27
32
|
*
|
|
28
33
|
* `apps/*` may not import `apps/*`, so the small duplication of the API-side
|
|
29
34
|
* gate is the same shape `apps/mcp/src/config/modules.ts` already documents
|
|
30
35
|
* for `isModuleEnabled`.
|
|
31
36
|
*/
|
|
32
37
|
|
|
33
|
-
import { checkLicense } from "@working-theory/license";
|
|
38
|
+
import { checkLicense, type ProFeature } from "@working-theory/license";
|
|
39
|
+
import type { McpCatalogOptions } from "@working-theory/metadata";
|
|
34
40
|
|
|
35
41
|
/** The licence `features[]` claim that turns the commerce add-on on — the
|
|
36
42
|
* same literal `apps/api/src/lib/require-commerce-claim.ts` reads. */
|
|
@@ -41,17 +47,50 @@ export type McpCommerceClaim = "claimed" | "unclaimed";
|
|
|
41
47
|
|
|
42
48
|
let claim: McpCommerceClaim = "unclaimed";
|
|
43
49
|
|
|
50
|
+
const NO_FEATURES: ReadonlySet<ProFeature> = new Set();
|
|
51
|
+
|
|
52
|
+
let heldFeatures: ReadonlySet<ProFeature> = NO_FEATURES;
|
|
53
|
+
|
|
54
|
+
let featureGateOverride: McpCatalogOptions["featureGate"];
|
|
55
|
+
|
|
44
56
|
export interface InitMcpCommerceClaimOptions {
|
|
45
57
|
/** Injection seam for tests — when supplied, used in place of the real
|
|
46
58
|
* `checkLicense()` so no suite reaches a licence server (NFR-6). */
|
|
47
59
|
checkLicense?: typeof checkLicense;
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
/** One resolved read: the commerce claim and the held feature set it
|
|
63
|
+
* implies, from a single `checkLicense()` call. Never throws — the mapping
|
|
64
|
+
* both {@link initMcpCommerceClaim} and {@link startMcpClaimRefresh}'s loop
|
|
65
|
+
* share (#7156 spec D1): a revoked or unreadable licence resolves to the
|
|
66
|
+
* fail-closed `"unclaimed"` with no feature, rather than propagating. */
|
|
67
|
+
async function readMcpClaims(
|
|
68
|
+
options: InitMcpCommerceClaimOptions,
|
|
69
|
+
): Promise<{ claim: McpCommerceClaim; features: ReadonlySet<ProFeature> }> {
|
|
70
|
+
const read = options.checkLicense ?? checkLicense;
|
|
71
|
+
try {
|
|
72
|
+
const result = await read();
|
|
73
|
+
// Revoked paid licences keep verifying until JWT exp (up to 24h after the
|
|
74
|
+
// server reports `revoked`), so a revoked licence holds no feature. See
|
|
75
|
+
// packages/license/src/types.ts § LicenseResult, ADR 0014.
|
|
76
|
+
const features =
|
|
77
|
+
result.revoked !== true ? new Set(result.features) : NO_FEATURES;
|
|
78
|
+
const resolvedClaim: McpCommerceClaim = features.has(COMMERCE_FEATURE)
|
|
79
|
+
? "claimed"
|
|
80
|
+
: "unclaimed";
|
|
81
|
+
return { claim: resolvedClaim, features };
|
|
82
|
+
} catch {
|
|
83
|
+
return { claim: "unclaimed", features: NO_FEATURES };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
50
87
|
/**
|
|
51
|
-
* Resolves the deployment's commerce claim
|
|
52
|
-
* {@link mcpCommerceClaim}'s
|
|
53
|
-
*
|
|
54
|
-
*
|
|
88
|
+
* Resolves the deployment's commerce claim and held licence features from
|
|
89
|
+
* ONE `checkLicense()` call, and memoises both for {@link mcpCommerceClaim}'s
|
|
90
|
+
* and {@link mcpHeldFeatures}'s synchronous reads. Safe to call more than
|
|
91
|
+
* once (each call re-resolves and re-memoises); production calls it once at
|
|
92
|
+
* bootstrap, and {@link startMcpClaimRefresh} re-reads through the same
|
|
93
|
+
* mapping thereafter.
|
|
55
94
|
*
|
|
56
95
|
* Never throws: a licence read that fails resolves to the fail-closed
|
|
57
96
|
* `"unclaimed"` rather than propagating.
|
|
@@ -59,20 +98,112 @@ export interface InitMcpCommerceClaimOptions {
|
|
|
59
98
|
export async function initMcpCommerceClaim(
|
|
60
99
|
options: InitMcpCommerceClaimOptions = {},
|
|
61
100
|
): Promise<McpCommerceClaim> {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
const result = await readMcpClaims(options);
|
|
102
|
+
claim = result.claim;
|
|
103
|
+
heldFeatures = result.features;
|
|
104
|
+
return claim;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How often the running server re-reads the deployment's licence claim
|
|
108
|
+
* (#7156). The server re-reads on this bound, so it agrees with the manifest
|
|
109
|
+
* route's origin response within it; the route answers
|
|
110
|
+
* `cache-control: public, max-age=86400`, so a downstream cache may serve an
|
|
111
|
+
* older manifest for up to 24 hours. A code constant, not an env var.
|
|
112
|
+
*
|
|
113
|
+
* The call-time half of the refuse-flip gate is `isServedNow()` in
|
|
114
|
+
* `server.ts` (#7315): it re-applies this memo at every claim-tagged call, so
|
|
115
|
+
* a session opened before a re-read still refuses within this bound. */
|
|
116
|
+
export const MCP_CLAIM_REFRESH_MS = 5 * 60 * 1000;
|
|
117
|
+
|
|
118
|
+
/** How many consecutive re-read intervals may find the previous read still
|
|
119
|
+
* unfinished before the memo falls back to no claim (#7315 L1). With
|
|
120
|
+
* `MCP_CLAIM_REFRESH_MS` at 5 minutes, a claim is served at most 20 minutes
|
|
121
|
+
* after the last read that finished. A code constant, not an env var. */
|
|
122
|
+
export const MCP_CLAIM_MAX_MISSED_REREADS = 3;
|
|
123
|
+
|
|
124
|
+
/** The running re-read loop's timer, if one is active — module-global so
|
|
125
|
+
* {@link resetMcpCommerceClaim} can stop it. */
|
|
126
|
+
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
127
|
+
|
|
128
|
+
/** Bumped by every {@link startMcpClaimRefresh} and {@link
|
|
129
|
+
* resetMcpCommerceClaim} call. Each loop's closure captures its own
|
|
130
|
+
* generation at start; a tick whose generation has gone stale (a later
|
|
131
|
+
* start, or a reset) discards its result instead of writing it. */
|
|
132
|
+
let refreshGeneration = 0;
|
|
133
|
+
|
|
134
|
+
/** Stops the running re-read loop, if any, and bumps the generation so any
|
|
135
|
+
* read already in flight from it is discarded on settling. */
|
|
136
|
+
function stopMcpClaimRefresh(): void {
|
|
137
|
+
if (refreshTimer !== undefined) {
|
|
138
|
+
clearInterval(refreshTimer);
|
|
139
|
+
refreshTimer = undefined;
|
|
140
|
+
}
|
|
141
|
+
refreshGeneration++;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Starts the bounded re-read: every `MCP_CLAIM_REFRESH_MS`, one licence read,
|
|
146
|
+
* skipped while the previous one is still pending. At most one loop per
|
|
147
|
+
* process — a second call replaces the first. The timer is unref'd, so it
|
|
148
|
+
* never holds a process open. Never throws.
|
|
149
|
+
*
|
|
150
|
+
* A tick that finds the previous read still unfinished increments a `missed`
|
|
151
|
+
* count and returns, as before, UNLESS that count has reached
|
|
152
|
+
* {@link MCP_CLAIM_MAX_MISSED_REREADS}: at the ceiling the memo falls back to
|
|
153
|
+
* no claim and a fresh read starts on the same tick (#7315 L1). Each read
|
|
154
|
+
* captures its own sequence number; a read writes the memo, and clears the
|
|
155
|
+
* in-flight flag, only while it is still the most recently started read — so
|
|
156
|
+
* a read the ceiling abandons can never write a stale result once a fresh
|
|
157
|
+
* read has replaced it.
|
|
158
|
+
*/
|
|
159
|
+
export function startMcpClaimRefresh(
|
|
160
|
+
options: InitMcpCommerceClaimOptions = {},
|
|
161
|
+
): void {
|
|
162
|
+
stopMcpClaimRefresh();
|
|
163
|
+
const generation = refreshGeneration;
|
|
164
|
+
let inFlight = false;
|
|
165
|
+
let missed = 0;
|
|
166
|
+
let readSeq = 0;
|
|
167
|
+
|
|
168
|
+
function startRead(): void {
|
|
169
|
+
inFlight = true;
|
|
170
|
+
missed = 0;
|
|
171
|
+
const seq = ++readSeq;
|
|
172
|
+
void readMcpClaims(options)
|
|
173
|
+
.then((result) => {
|
|
174
|
+
if (generation === refreshGeneration && seq === readSeq) {
|
|
175
|
+
claim = result.claim;
|
|
176
|
+
heldFeatures = result.features;
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
.finally(() => {
|
|
180
|
+
if (seq === readSeq) {
|
|
181
|
+
inFlight = false;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const timer = setInterval(() => {
|
|
187
|
+
if (generation !== refreshGeneration) return;
|
|
188
|
+
if (!inFlight) {
|
|
189
|
+
startRead();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (++missed < MCP_CLAIM_MAX_MISSED_REREADS) return;
|
|
73
193
|
claim = "unclaimed";
|
|
194
|
+
heldFeatures = NO_FEATURES;
|
|
195
|
+
startRead();
|
|
196
|
+
}, MCP_CLAIM_REFRESH_MS);
|
|
197
|
+
timer.unref();
|
|
198
|
+
refreshTimer = timer;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Test-only writers are inert outside a test run (#7156 L2): each throws,
|
|
202
|
+
* before touching any state, unless NODE_ENV is "test" — which Jest sets. */
|
|
203
|
+
function assertTestOnly(name: string): void {
|
|
204
|
+
if (process.env["NODE_ENV"] !== "test") {
|
|
205
|
+
throw new Error(`${name} is test-only and cannot run outside a test`);
|
|
74
206
|
}
|
|
75
|
-
return claim;
|
|
76
207
|
}
|
|
77
208
|
|
|
78
209
|
/** The memoised claim, read synchronously by both gates in `server.ts` and
|
|
@@ -85,10 +216,48 @@ export function mcpCommerceClaim(): McpCommerceClaim {
|
|
|
85
216
|
* licence read entirely. The seam every claim-state matrix in
|
|
86
217
|
* `apps/mcp/__tests__` drives both halves through. */
|
|
87
218
|
export function __setMcpCommerceClaimForTest(next: McpCommerceClaim): void {
|
|
219
|
+
assertTestOnly("__setMcpCommerceClaimForTest");
|
|
88
220
|
claim = next;
|
|
89
221
|
}
|
|
90
222
|
|
|
91
|
-
/**
|
|
223
|
+
/** The memoised licence features this deployment holds (#7136), read
|
|
224
|
+
* synchronously by `server.ts`'s catalog options and boot warning. */
|
|
225
|
+
export function mcpHeldFeatures(): ReadonlySet<ProFeature> {
|
|
226
|
+
return heldFeatures;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Test-only injector — sets the memoised feature set directly, bypassing
|
|
230
|
+
* the licence read entirely. */
|
|
231
|
+
export function __setMcpHeldFeaturesForTest(
|
|
232
|
+
features: readonly ProFeature[],
|
|
233
|
+
): void {
|
|
234
|
+
assertTestOnly("__setMcpHeldFeaturesForTest");
|
|
235
|
+
heldFeatures = new Set(features);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The test-set gate mode, or `undefined` — meaning `MCP_FEATURE_GATE_MODE`
|
|
239
|
+
* decides. Production never sets it. */
|
|
240
|
+
export function mcpFeatureGateOverride(): McpCatalogOptions["featureGate"] {
|
|
241
|
+
return featureGateOverride;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Test-only injector — overrides `MCP_FEATURE_GATE_MODE` for this module's
|
|
245
|
+
* readers, so a suite can drive refuse mode without flipping the constant. */
|
|
246
|
+
export function __setMcpFeatureGateForTest(
|
|
247
|
+
mode: McpCatalogOptions["featureGate"],
|
|
248
|
+
): void {
|
|
249
|
+
assertTestOnly("__setMcpFeatureGateForTest");
|
|
250
|
+
featureGateOverride = mode;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Test-only reset — returns to the module's initial, fail-closed state: no
|
|
254
|
+
* commerce claim, no feature held, no gate-mode override — and stops the
|
|
255
|
+
* re-read loop (#7156 D1), so a suite's `afterEach(resetMcpCommerceClaim)`
|
|
256
|
+
* cannot leak one. */
|
|
92
257
|
export function resetMcpCommerceClaim(): void {
|
|
258
|
+
assertTestOnly("resetMcpCommerceClaim");
|
|
259
|
+
stopMcpClaimRefresh();
|
|
93
260
|
claim = "unclaimed";
|
|
261
|
+
heldFeatures = NO_FEATURES;
|
|
262
|
+
featureGateOverride = undefined;
|
|
94
263
|
}
|
|
@@ -16,10 +16,17 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
16
16
|
import {
|
|
17
17
|
getMcpToolRegistrations,
|
|
18
18
|
OSS_DISABLED_TOOLS,
|
|
19
|
+
type McpCatalogOptions,
|
|
20
|
+
type McpToolRegistration,
|
|
19
21
|
} from "@working-theory/metadata";
|
|
20
22
|
|
|
21
23
|
import { isModuleEnabled } from "./config/modules.js";
|
|
22
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
mcpCommerceClaim,
|
|
26
|
+
mcpFeatureGateOverride,
|
|
27
|
+
mcpHeldFeatures,
|
|
28
|
+
} from "./lib/commerce-claim.js";
|
|
29
|
+
import { notFoundError } from "./lib/errors.js";
|
|
23
30
|
import { ailkAudit } from "./tools/audit.js";
|
|
24
31
|
import { ailkCaptureLead } from "./tools/capture_lead.js";
|
|
25
32
|
import { ailkCreateProject } from "./tools/create_project.js";
|
|
@@ -179,8 +186,14 @@ export interface RegisteredToolDescriptor {
|
|
|
179
186
|
* repo, and "keyless site" / "keyed site" name the LICENCE, not an env
|
|
180
187
|
* allowlist.
|
|
181
188
|
*
|
|
182
|
-
* The claim is the
|
|
183
|
-
* synchronous — see `lib/commerce-claim.ts`.
|
|
189
|
+
* The claim is the memoised claim, re-read every `MCP_CLAIM_REFRESH_MS`; this
|
|
190
|
+
* stays synchronous — see `lib/commerce-claim.ts`. {@link isServedNow}
|
|
191
|
+
* re-applies this filter at call time.
|
|
192
|
+
*
|
|
193
|
+
* The LICENCE-FEATURE gate (#7136) is not repeated here: it applies upstream,
|
|
194
|
+
* in `getMcpToolRegistrations()`, through {@link catalogOptionsForThisBoot} —
|
|
195
|
+
* only the catalog knows `MCP_FEATURE_GATE_MODE`. `catalog-drift.test.ts`
|
|
196
|
+
* pins that upstream filter from this side in every claim state.
|
|
184
197
|
*/
|
|
185
198
|
function shouldRegister(reg: {
|
|
186
199
|
name: string;
|
|
@@ -194,13 +207,62 @@ function shouldRegister(reg: {
|
|
|
194
207
|
return true;
|
|
195
208
|
}
|
|
196
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Call-time gate (#7315). True when a server built NOW would register this
|
|
212
|
+
* tool — the exact same predicate {@link listRegisteredTools} computes, so
|
|
213
|
+
* the call-time answer and a fresh session's `tools/list` cannot disagree by
|
|
214
|
+
* construction. A session keeps the tool list it registered at `initialize`;
|
|
215
|
+
* this makes each listed entry refuse within `MCP_CLAIM_REFRESH_MS` of a
|
|
216
|
+
* re-read that drops its claim.
|
|
217
|
+
*/
|
|
218
|
+
function isServedNow(reg: McpToolRegistration): boolean {
|
|
219
|
+
return listRegisteredTools().some((current) => current.name === reg.name);
|
|
220
|
+
}
|
|
221
|
+
|
|
197
222
|
/**
|
|
198
223
|
* The {@link McpCatalogOptions} this boot's claims imply — passed to
|
|
199
224
|
* `getMcpToolRegistrations()` so the upstream catalog filter and
|
|
200
225
|
* {@link shouldRegister} agree by construction rather than by coincidence.
|
|
226
|
+
* Carries the held licence features (#7136); the gate mode is the catalog's
|
|
227
|
+
* `MCP_FEATURE_GATE_MODE` unless a test has overridden it.
|
|
228
|
+
*/
|
|
229
|
+
function catalogOptionsForThisBoot(): McpCatalogOptions {
|
|
230
|
+
const featureGate = mcpFeatureGateOverride();
|
|
231
|
+
return {
|
|
232
|
+
addons: { commerce: mcpCommerceClaim() === "claimed" },
|
|
233
|
+
features: [...mcpHeldFeatures()],
|
|
234
|
+
...(featureGate ? { featureGate } : {}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The one-release warning cycle's boot report (#7136): for every tool this
|
|
240
|
+
* boot serves whose licence `feature` the deployment does not hold, calls
|
|
241
|
+
* `warn` once, naming the tool and the feature, and returns their names.
|
|
242
|
+
*
|
|
243
|
+
* Called ONCE per process from `index.ts`, after the claim resolves — never
|
|
244
|
+
* from {@link createMcpServer}, which runs once per HTTP session. In refuse
|
|
245
|
+
* mode it finds nothing: the upstream catalog filter has already dropped
|
|
246
|
+
* every such tool, so it needs no mode check of its own. Defaults to
|
|
247
|
+
* `console.warn` — stderr, never stdout, which is the stdio transport's
|
|
248
|
+
* JSON-RPC channel. The message carries two catalog literals and nothing
|
|
249
|
+
* read from the licence.
|
|
201
250
|
*/
|
|
202
|
-
function
|
|
203
|
-
|
|
251
|
+
export function reportUnlicensedProTools(
|
|
252
|
+
warn: (message: string) => void = console.warn,
|
|
253
|
+
): string[] {
|
|
254
|
+
const held = mcpHeldFeatures();
|
|
255
|
+
const unlicensed = getMcpToolRegistrations(catalogOptionsForThisBoot())
|
|
256
|
+
.filter(shouldRegister)
|
|
257
|
+
.filter((reg) => reg.feature !== undefined && !held.has(reg.feature));
|
|
258
|
+
for (const reg of unlicensed) {
|
|
259
|
+
warn(
|
|
260
|
+
`[mcp] licence feature "${reg.feature}" is not held by this deployment — ` +
|
|
261
|
+
`tool "${reg.name}" is served in this release only; the next release ` +
|
|
262
|
+
`stops serving it unless the licence includes "${reg.feature}".`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return unlicensed.map((reg) => reg.name);
|
|
204
266
|
}
|
|
205
267
|
|
|
206
268
|
/**
|
|
@@ -246,6 +308,16 @@ export function createMcpServer(): McpServer {
|
|
|
246
308
|
throw new Error(`MCP catalog drift: no handler for tool '${reg.name}'`);
|
|
247
309
|
}
|
|
248
310
|
server.tool(reg.name, reg.description, reg.inputShape, async (input) => {
|
|
311
|
+
if (
|
|
312
|
+
(reg.feature !== undefined || reg.addon !== undefined) &&
|
|
313
|
+
!isServedNow(reg)
|
|
314
|
+
) {
|
|
315
|
+
return {
|
|
316
|
+
content: [
|
|
317
|
+
{ type: "text", text: JSON.stringify(notFoundError("Not found")) },
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
249
321
|
const handler = await resolveHandler(reg.name);
|
|
250
322
|
const result = await handler(input);
|
|
251
323
|
return {
|
|
@@ -92,7 +92,7 @@ function commerceDisabledBody(): ErrorBody {
|
|
|
92
92
|
*
|
|
93
93
|
* Returns a typed error BODY rather than throwing on every failure an agent
|
|
94
94
|
* can act on (FR-2) — MCP tools surface errors as their result body, the
|
|
95
|
-
* pattern `lib/
|
|
95
|
+
* pattern `lib/errors.ts`'s error bodies establish.
|
|
96
96
|
*/
|
|
97
97
|
export async function ailkPurchase(
|
|
98
98
|
input: PurchaseMcpInput,
|
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
* endpoint when MCP_PUBLIC_HTTP_URL is configured.
|
|
15
15
|
* Issue #4157 — `name` resolves from SITE_NAME (env → _site.mdx → neutral
|
|
16
16
|
* default) instead of a hardcoded vendor-brand literal.
|
|
17
|
+
* Issue #7136 — the five `feature`-tagged tools are gated on the licence
|
|
18
|
+
* features the route passes in (`heldFeatures`), as registration is.
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
21
|
import {
|
|
20
22
|
getMcpToolCatalog,
|
|
23
|
+
type McpCatalogOptions,
|
|
21
24
|
type McpToolCatalogEntry,
|
|
22
25
|
} from "@working-theory/metadata";
|
|
23
26
|
|
|
@@ -88,6 +91,16 @@ export interface BuildMcpManifestOptions {
|
|
|
88
91
|
* claim and passes it in. Omitted ⇒ false, the fail-closed default.
|
|
89
92
|
*/
|
|
90
93
|
commerceClaimed?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* The licence features this deployment holds (#7136). In refuse mode a
|
|
96
|
+
* `feature`-tagged tool is advertised only when its feature is held — the
|
|
97
|
+
* same gate the MCP server's registration applies. Injected, like
|
|
98
|
+
* `commerceClaimed`. Omitted ⇒ none, the fail-closed default.
|
|
99
|
+
*/
|
|
100
|
+
heldFeatures?: readonly string[];
|
|
101
|
+
/** Overrides `MCP_FEATURE_GATE_MODE` — a test seam; `route.ts` never
|
|
102
|
+
* passes it. */
|
|
103
|
+
featureGate?: McpCatalogOptions["featureGate"];
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
/** Path the MCP server is mounted at, relative to baseUrl. */
|
|
@@ -121,6 +134,8 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
|
|
|
121
134
|
mcpEndpoint: `${opts.baseUrl}${MCP_ENDPOINT_PATH}`,
|
|
122
135
|
tools: getMcpToolCatalog({
|
|
123
136
|
addons: { commerce: opts.commerceClaimed === true },
|
|
137
|
+
features: opts.heldFeatures ?? [],
|
|
138
|
+
...(opts.featureGate ? { featureGate: opts.featureGate } : {}),
|
|
124
139
|
}),
|
|
125
140
|
transport: {
|
|
126
141
|
type: "http",
|