@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +27 -5
  2. package/dist/auth/adapter.js +9 -6
  3. package/dist/auth/caller.js +2 -1
  4. package/dist/auth/identity.d.ts +2 -2
  5. package/dist/auth/oauth.d.ts +33 -1
  6. package/dist/auth/oauth.js +121 -28
  7. package/dist/auth/routes.js +6 -4
  8. package/dist/build/compiler.d.ts +44 -122
  9. package/dist/build/compiler.js +202 -277
  10. package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
  11. package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
  12. package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
  13. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  14. package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
  15. package/dist/domain/describe/index.js +1 -1
  16. package/dist/domain/describe/target-hash.js +1 -1
  17. package/dist/domain/describe/targets.d.ts +1 -1
  18. package/dist/domain/generated-tools.js +2 -0
  19. package/dist/domain/paging.js +41 -15
  20. package/dist/infrastructure/bundle.js +118 -15
  21. package/dist/infrastructure/cli.js +11 -15
  22. package/dist/infrastructure/generate.d.ts +1 -3
  23. package/dist/infrastructure/generate.js +11 -3
  24. package/dist/infrastructure/generateAuth.js +57 -0
  25. package/dist/infrastructure/inputs.js +8 -3
  26. package/dist/infrastructure/wrangler.js +50 -12
  27. package/dist/kernel/binds.d.ts +0 -3
  28. package/dist/kernel/contracts.d.ts +1 -1
  29. package/dist/kernel/contracts.js +4 -4
  30. package/dist/kernel/dialect/common.d.ts +0 -1
  31. package/dist/kernel/drain.d.ts +5 -0
  32. package/dist/kernel/drain.js +35 -0
  33. package/dist/kernel/driver/postgres.js +6 -2
  34. package/dist/kernel/host-caller.d.ts +1 -0
  35. package/dist/kernel/host-caller.js +1 -0
  36. package/dist/kernel/mcp-apps.d.ts +29 -9
  37. package/dist/kernel/mcp-apps.js +3 -3
  38. package/dist/kernel/mcp-handler.js +101 -18
  39. package/dist/kernel/mcp.d.ts +12 -3
  40. package/dist/kernel/mcp.js +31 -6
  41. package/dist/kernel/rest-router.js +3 -2
  42. package/dist/kernel/scalars.js +3 -2
  43. package/dist/kernel/site.d.ts +1 -3
  44. package/dist/kernel/site.js +2 -2
  45. package/dist/kernel/web-index.js +40 -0
  46. package/dist/kernel/web.d.ts +3 -1
  47. package/dist/kernel/web.js +6 -37
  48. package/dist/usecase/adminBindings.js +2 -3
  49. package/dist/usecase/adminClient.js +87 -0
  50. package/dist/usecase/adminDescriptors.js +19 -65
  51. package/dist/usecase/generate.js +5 -4
  52. package/dist/usecase/mcp-media.js +2 -2
  53. package/dist/usecase/mcp.js +16 -12
  54. package/dist/usecase/paging.js +2 -3
  55. package/dist/usecase/publishing.js +3 -3
  56. package/dist/usecase/site.js +0 -1
  57. package/dist/usecase/tool-version.js +9 -0
  58. package/dist/usecase/translations.js +1 -1
  59. package/dist/usecase/validation.js +128 -19
  60. package/dist/usecase/web.js +3 -2
  61. package/docs/examples/README.md +1 -1
  62. package/docs/examples/app-shell/README.md +45 -3
  63. package/docs/examples/app-shell/package.json +14 -6
  64. package/docs/examples/app-shell/smoke-kit.mjs +54 -0
  65. package/docs/examples/app-shell/src/auth.ts +6 -3
  66. package/docs/examples/app-shell/src/service.ts +3 -3
  67. package/docs/examples/app-shell/tsconfig.json +4 -1
  68. package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
  69. package/docs/examples/app-shell/wrangler.jsonc +5 -4
  70. package/docs/examples/club-events/manifests/club-events.yaml +7 -0
  71. package/docs/examples/club-events/src/handlers.ts +1 -1
  72. package/docs/examples/generated-web/README.md +6 -2
  73. package/docs/examples/generated-web/wrangler.jsonc +1 -0
  74. package/docs/examples/purchasing/README.md +1 -1
  75. package/docs/examples/purchasing/manifests/purchasing.yaml +17 -1
  76. package/docs/examples/purchasing/smoke.mjs +10 -4
  77. package/docs/examples/purchasing/src/admin.tsx +13 -0
  78. package/docs/examples/purchasing/src/handlers.ts +1 -1
  79. package/docs/examples/purchasing/wrangler.jsonc +5 -4
  80. package/docs/examples/recipes/wrangler.jsonc +5 -4
  81. package/docs/handbook/cloudflare/authentication.md +43 -6
  82. package/docs/handbook/cloudflare/chatgpt-sites.md +4 -2
  83. package/docs/handbook/cloudflare/deploy-and-operate.md +45 -1
  84. package/docs/handbook/cloudflare/media-r2.md +2 -3
  85. package/docs/handbook/cloudflare/service-entry.md +68 -31
  86. package/docs/handbook/concepts/authorization.md +1 -1
  87. package/docs/handbook/concepts/mcp-and-agents.md +19 -0
  88. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  89. package/docs/handbook/examples/hub.md +1 -1
  90. package/docs/handbook/guides/admin-ui.md +107 -1
  91. package/docs/handbook/guides/agent-setup.md +27 -9
  92. package/docs/handbook/guides/agent-workflow-acceptance.md +222 -0
  93. package/docs/handbook/guides/mcp-apps.md +522 -6
  94. package/docs/handbook/guides/native-admin.md +300 -51
  95. package/docs/handbook/guides/oauth.md +119 -5
  96. package/docs/handbook/guides/public-pages.md +2 -2
  97. package/docs/handbook/guides/size-and-startup.md +1 -1
  98. package/docs/handbook/guides/typed-queries.md +35 -0
  99. package/docs/handbook/navigation.json +0 -4
  100. package/docs/handbook/reference/admin-descriptors.md +0 -35
  101. package/docs/handbook/reference/procedure.md +6 -2
  102. package/docs/handbook/reference/schema.md +13 -3
  103. package/docs/handbook/reference/surface.md +129 -15
  104. package/docs/handbook/reference/trigger.md +43 -1
  105. package/docs/handbook/reference/view.md +1 -1
  106. package/docs/handbook/releases/index.md +74 -2
  107. package/docs/handbook/start/overview.md +1 -1
  108. package/docs/handbook/start/project-and-cli.md +16 -8
  109. package/docs/handbook/start/quickstart-worker.md +1 -1
  110. package/docs/skills/develop/SKILL.md +46 -18
  111. package/docs/skills/integrate/SKILL.md +4 -9
  112. package/docs/skills/media-gc/SKILL.md +7 -5
  113. package/docs/skills/provision/SKILL.md +18 -12
  114. package/docs/skills/update/SKILL.md +19 -18
  115. package/docs/upgrade-0.1-to-0.2.md +6 -6
  116. package/package.json +2 -1
  117. package/skills/README.md +11 -16
  118. package/skills/mantle/SKILL.md +39 -17
  119. package/skills/mantle/references/local-testing.md +9 -0
  120. package/skills/mantle/references/web.md +1 -1
  121. package/skills/mantle/references/website.md +26 -22
  122. package/docs/examples/app-shell/src/admin.ts +0 -10
  123. package/docs/examples/purchasing/src/admin.ts +0 -12
  124. package/docs/handbook/guides/admin-extensions.md +0 -20
  125. package/docs/skills/develop/mcp-app.md +0 -39
  126. package/skills/.keep +0 -0
@@ -1,8 +1,9 @@
1
1
  /** Request-only MCP implementation, excluded from Worker startup. */
2
2
  import { authorized, authorizeTarget } from "./authorization.js";
3
3
  import { forwarded, isAdmin, ContractError, checkOrigin, surfaceCaller, surfaceError, wire } from "./contracts.js";
4
+ import { draining } from "./drain.js";
4
5
  import { callToolLazy as callTool } from "./mcp-call.js";
5
- import { RESOURCE_MIME_TYPE, linkSurface } from "./mcp-apps.js";
6
+ import { RESOURCE_MIME_TYPE, linkSurface, resourcesOf } from "./mcp-apps.js";
6
7
  const text = (value, locale) => {
7
8
  if (typeof value === "string")
8
9
  return value;
@@ -18,11 +19,18 @@ function observe(listener, event) {
18
19
  catch { }
19
20
  }
20
21
  const paging = (schema) => ({ ...schema, properties: { ...schema.properties, limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: { type: "string" } } });
22
+ /**
23
+ * Characters of `JSON.stringify(page)` an MCP View page may hold. A result carries its rows twice (text block and
24
+ * `structuredContent`) and hosts are reported to truncate near 150k characters, so one copy stays at 60k.
25
+ * ponytail: one fixed number; raise it or make it an option if hosts publish their limits.
26
+ */
27
+ const MCP_PAGE_BUDGET = 60_000;
21
28
  /** The scopes a challenge asks for: the credential's, plus what the request needs. */
22
29
  const union = (scopes, extra) => [...new Set([...scopes, ...extra])].join(" ");
23
30
  /** The handler for `/mcp` (public tools) and `/mcp/admin` (tools for `admin` roles); other paths are undefined. */
24
31
  export function handler(tools, options, apps = {}, reads = []) {
25
- return async (request, env, ctx) => {
32
+ const allowed = options.allowedOrigins ?? [];
33
+ return draining(async (request, env, ctx) => {
26
34
  const access = options.gateway ? (await import("./host-access.js")).accessRequest(ctx, options) : undefined;
27
35
  let resolved;
28
36
  try {
@@ -30,6 +38,18 @@ export function handler(tools, options, apps = {}, reads = []) {
30
38
  const url = new URL(request.url), path = url.pathname.replace(/\/+$/, ""), surface = path === "/mcp" ? "public" : path === "/mcp/admin" ? "admin" : undefined;
31
39
  if (!surface)
32
40
  return;
41
+ // MCP transport MUST: a present Origin is a browser, answered only for loopback or `allowedOrigins`. The request's own
42
+ // host is not implied, since a DNS-rebinding page sends Origin == its rebound Host; a missing Origin is a server client.
43
+ if (request.headers.has("origin")) {
44
+ const sdk = await import("@modelcontextprotocol/server");
45
+ const foreign = sdk.originValidationResponse(request, [...sdk.localhostAllowedOrigins(), ...allowed]);
46
+ if (foreign) {
47
+ access?.finish(new ContractError("AUTH_DENIED", "Origin is not allowed.", "request:origin"));
48
+ observe(options.onObservation, { kind: "request-refused", at: Date.now(), surface, status: 403 });
49
+ observe(options.onRefusal, { at: Date.now(), status: 403, reason: "origin" });
50
+ return foreign;
51
+ }
52
+ }
33
53
  const select = async () => (typeof tools === "function" ? await tools(request, env) : tools).filter(tool => tool.surface === surface);
34
54
  // Dynamic admin tables stay behind the admin gate; cache classification uses generated metadata only.
35
55
  let selected = surface === "public" ? await select() : [];
@@ -74,7 +94,7 @@ export function handler(tools, options, apps = {}, reads = []) {
74
94
  observe(options.onRefusal, { at: Date.now(), status: result.status, reason: "credential" });
75
95
  }
76
96
  if (result.status === 401)
77
- scope ??= requiredScopes.join(" ");
97
+ scope = union(scope === undefined ? requiredScopes : scope.split(" "), options.authorizationScopes ?? []);
78
98
  return (await import("./host-caller.js")).hostFailure(error, resolved, `mcp ${path}`, bare ? undefined : 'Bearer ' + (scope ? (result.status === 403 ? 'error="insufficient_scope", ' : '') + 'scope="' + scope + '", ' : '') + 'resource_metadata="' + (options.resourceMetadata ?? url.origin + '/.well-known/oauth-protected-resource/mcp') + '"');
79
99
  };
80
100
  let caller;
@@ -100,7 +120,9 @@ export function handler(tools, options, apps = {}, reads = []) {
100
120
  }
101
121
  if (surface === "admin")
102
122
  selected = await select();
103
- const { McpServer, createMcpHandler, fromJsonSchema } = await import("@modelcontextprotocol/server");
123
+ // Before per-caller filtering, so every caller of a deploy sees one catalogue version.
124
+ const version = catalogueVersion(selected);
125
+ const { McpServer, ProtocolError, ProtocolErrorCode, createMcpHandler, fromJsonSchema } = await import("@modelcontextprotocol/server");
104
126
  if (request.method === "POST" && !cookieOnly && !options.gateway)
105
127
  await parse();
106
128
  if (tooLarge && access)
@@ -149,7 +171,17 @@ export function handler(tools, options, apps = {}, reads = []) {
149
171
  }
150
172
  }
151
173
  const app = options.apps?.[surface];
152
- const links = app ? (apps[surface] = linkSurface(app, selected, apps[surface])).links : undefined;
174
+ let links;
175
+ if (app) {
176
+ const sources = (Array.isArray(app) ? app : [app]);
177
+ const dynamic = sources.some(source => typeof source === "function");
178
+ const resolved = resourcesOf(await Promise.all(sources.map(source => typeof source === "function" ? source({ env, request }) : source)));
179
+ // Equal tool names say nothing about request-bound resource identity or ownership.
180
+ const current = linkSurface(resolved, selected, dynamic ? undefined : apps[surface]);
181
+ if (!dynamic)
182
+ apps[surface] = current;
183
+ links = current.links;
184
+ }
153
185
  if (options.authorize) {
154
186
  try {
155
187
  for (const message of Array.isArray(parsedBody) ? parsedBody : [parsedBody]) {
@@ -161,7 +193,8 @@ export function handler(tools, options, apps = {}, reads = []) {
161
193
  tool.check?.(caller);
162
194
  await authorizeTarget(options.authorize, tool.target ?? { kind: tool.page ? "view" : "procedure", name: tool.name }, caller);
163
195
  }
164
- if ((Array.isArray(parsedBody) ? parsedBody : [parsedBody]).some(message => message?.method === "tools/list")) {
196
+ // an App's HTML is rendered for the same tools the caller's tools/list shows
197
+ if ((Array.isArray(parsedBody) ? parsedBody : [parsedBody]).some(message => message?.method === "tools/list" || message?.method === "resources/read")) {
165
198
  const visible = [];
166
199
  for (const tool of selected) {
167
200
  if (caller.kind === "anonymous" && tool.closed || !options.gateway && caller.kind === "user" && caller.credential === "oauth" && tool.scopes?.some(scope => !caller.scopes.includes(scope)))
@@ -184,17 +217,31 @@ export function handler(tools, options, apps = {}, reads = []) {
184
217
  return challenge(error);
185
218
  }
186
219
  }
220
+ const serverInfo = { name: "aotter.mantle", ...options.serverInfo, version: options.serverInfo?.version ?? version };
187
221
  // Host calls execute in the SDK callback, after transport validation.
188
222
  const handler = createMcpHandler(() => {
189
223
  const schema = (value) => fromJsonSchema(value, { getValidator() { return (input) => ({ valid: true, data: input, errorMessage: undefined }); } });
190
- const server = new McpServer({ name: "aotter.mantle", version: "0.2.0", ...options.serverInfo }, { capabilities: { tools: { listChanged: false } }, ...(options.instructions ? { instructions: options.instructions } : {}) });
224
+ // Tools capability after construction: given to the constructor, the SDK installs its tools/call handler before the wrap below.
225
+ const server = new McpServer(serverInfo, options.instructions ? { instructions: options.instructions } : {});
226
+ server.server.registerCapabilities({ tools: { listChanged: false } });
191
227
  if (host)
192
228
  server.server.onclose = () => access?.finish(isRecord(parsedBody) && parsedBody.method === "initialize" ? undefined : new ContractError("INPUT_VALIDATION_FAILED", "MCP request refused.", "mcp", { status: 400 }));
193
- const appMeta = links ? registerApps(server, links, parsedBody) : undefined;
229
+ const appMeta = links ? registerApps(server, links, parsedBody, new Set(selected.map(tool => tool.name)), { env, request }) : undefined;
230
+ // A host's stale copy names a tool this catalogue lacks: say so, with the catalogue version, instead of the SDK's bare "not found".
231
+ // Wrapped where the SDK stores it, so transport and request validation still answer first.
232
+ const registered = new Set(), set = server.server.setRequestHandler.bind(server.server);
233
+ server.server.setRequestHandler = ((method, handler) => set(method, method === "tools/call" && typeof handler === "function" ? (request, context) => {
234
+ if (!registered.has(request.params.name))
235
+ throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No tool named ${JSON.stringify(request.params.name)} exists in the current catalogue (catalogue version ${serverInfo.version}).`);
236
+ return handler(request, context);
237
+ } : handler));
194
238
  for (const tool of selected) {
195
239
  const meta = appMeta?.(tool.name);
196
240
  if (meta === false)
197
241
  continue;
242
+ registered.add(tool.name);
243
+ // Not part of structured output: the version, and on failure the current input contract.
244
+ const about = (contract) => { const text = [tool.version && `tool version ${tool.version}`, contract && `input schema ${JSON.stringify(tool.page ? paging(tool.inputSchema) : tool.inputSchema)}`].filter(Boolean).join("; "); return text ? [{ type: "text", text }] : []; };
198
245
  server.registerTool(tool.name, { ...(meta ? { _meta: meta } : {}), ...(tool.title ? { title: text(tool.title, locale) } : {}), description: text(tool.description, locale) ?? text(tool.title, locale) ?? tool.name, inputSchema: schema(tool.page ? paging(tool.inputSchema) : tool.inputSchema), ...(tool.outputSchema ? { outputSchema: schema(tool.outputSchema) } : {}), ...(tool.annotations ? { annotations: tool.annotations } : {}) }, async (raw) => {
199
246
  const metadata = options.onObservation ? { kind: "invocation", surface, invocationId: crypto.randomUUID(), tool: tool.name, caller: Object.freeze(caller.kind === "user" ? { ...caller, scopes: Object.freeze([...caller.scopes]) } : { ...caller }) } : undefined;
200
247
  const started = metadata ? performance.now() : 0;
@@ -206,10 +253,34 @@ export function handler(tools, options, apps = {}, reads = []) {
206
253
  tool.check?.(caller);
207
254
  if (tool.run && (!host || !tool.readOnly))
208
255
  checkOrigin(request, caller);
209
- const data = await callTool(tool, raw, () => options.db(env), caller, options.handlers, access?.dryRun ? () => ({ dryRun: true, event: access.finish() }) : undefined);
256
+ let db;
257
+ const run = (input) => callTool(tool, input, () => db ??= options.db(env), caller, options.handlers, access?.dryRun ? () => ({ dryRun: true, event: access.finish() }) : undefined);
258
+ let data = await run(raw);
259
+ // A page is cut before the first row that overflows the budget: the same page re-runs with the limit that fits, so the cursor is the View's own.
260
+ // Data can change between reads, so the re-run is measured again. A View without an order is one page and refuses a smaller limit: it stays whole.
261
+ for (let tries = 0; tool.page && isRecord(data) && Array.isArray(data.rows); tries++) {
262
+ const rows = data.rows, total = JSON.stringify(data).length;
263
+ if (total <= MCP_PAGE_BUDGET)
264
+ break;
265
+ if (rows.length === 1 || tries === 4)
266
+ break;
267
+ const sizes = rows.map(row => JSON.stringify(row).length);
268
+ let size = total - sizes.reduce((sum, n) => sum + n, 0) - (rows.length - 1), fit = 0;
269
+ for (const n of sizes) {
270
+ size += n + (fit ? 1 : 0);
271
+ if (size > MCP_PAGE_BUDGET)
272
+ break;
273
+ fit++;
274
+ }
275
+ const cut = await run({ ...raw, limit: Math.max(1, fit) }).catch(() => undefined);
276
+ if (cut === undefined)
277
+ break;
278
+ data = cut;
279
+ }
210
280
  if (host)
211
281
  access?.finish();
212
- return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), content: [{ type: "text", text: JSON.stringify(data) }], ...(data && typeof data === "object" && !Array.isArray(data) ? { structuredContent: data } : {}) };
282
+ const text = JSON.stringify(data), notice = tool.page && text.length > MCP_PAGE_BUDGET;
283
+ return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), content: [{ type: "text", text }, ...about(false), ...(notice ? [{ type: "text", text: `This page is larger than the ${MCP_PAGE_BUDGET}-character page size budget (a single row over it, or a View without an order, is returned whole), so a host may truncate it. Continue with nextCursor, if any.` }] : [])], ...(data && typeof data === "object" && !Array.isArray(data) ? { structuredContent: data } : {}) };
213
284
  }
214
285
  catch (error) {
215
286
  if (host)
@@ -220,7 +291,7 @@ export function handler(tools, options, apps = {}, reads = []) {
220
291
  if (diagnostic.path === "request:origin")
221
292
  observe(options.onRefusal, { at: Date.now(), status: 403, reason: "origin" });
222
293
  const payload = { diagnostics: [diagnostic] };
223
- return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), isError: true, content: [{ type: "text", text: JSON.stringify(payload) }], ...(tool.outputSchema ? {} : { structuredContent: payload }) };
294
+ return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), isError: true, content: [{ type: "text", text: JSON.stringify(payload) }, ...about(outcome === "failed")], ...(tool.outputSchema ? {} : { structuredContent: payload }) };
224
295
  }
225
296
  finally {
226
297
  if (metadata)
@@ -228,6 +299,11 @@ export function handler(tools, options, apps = {}, reads = []) {
228
299
  }
229
300
  });
230
301
  }
302
+ // An empty catalogue still answers tools/list and refuses calls the same way.
303
+ if (registered.size === 0) {
304
+ server.server.setRequestHandler("tools/list", () => ({ tools: [] }));
305
+ server.server.setRequestHandler("tools/call", () => ({ content: [] }));
306
+ }
231
307
  return server;
232
308
  }, { legacy: "stateless", maxRequestBodySize: 1024 * 1024, onerror: error => console.error("[mantle mcp] request failed", error) });
233
309
  const response = await handler.fetch(request, { parsedBody });
@@ -241,7 +317,12 @@ export function handler(tools, options, apps = {}, reads = []) {
241
317
  access?.finish(error);
242
318
  return (await import("./host-caller.js")).hostFailure(error, resolved, "mcp");
243
319
  }
244
- };
320
+ });
321
+ }
322
+ /** A hash of the tools an endpoint serves (`surface:name:version`, sorted), as 32-bit FNV-1a hex: it changes with any tool's definition. */
323
+ function catalogueVersion(tools) {
324
+ const text = JSON.stringify(tools.map(tool => `${tool.surface}:${tool.name}:${tool.version ?? ""}`).sort());
325
+ return [...text].reduce((h, c) => Math.imul(h ^ c.codePointAt(0), 16777619) >>> 0, 2166136261).toString(16).padStart(8, "0");
245
326
  }
246
327
  const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities";
247
328
  const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
@@ -259,22 +340,24 @@ function clientUiSupport(message) {
259
340
  }
260
341
  return "unknown";
261
342
  }
262
- function appMeta(r) {
263
- const ui = { ...(r.csp ? { csp: r.csp } : {}), ...(r.permissions ? { permissions: r.permissions } : {}), ...(r.prefersBorder !== undefined ? { prefersBorder: r.prefersBorder } : {}) };
343
+ function appMeta(r, csp = r.csp) {
344
+ const ui = { ...(csp ? { csp } : {}), ...(r.permissions ? { permissions: r.permissions } : {}), ...(r.domain !== undefined ? { domain: r.domain } : {}), ...(r.prefersBorder !== undefined ? { prefersBorder: r.prefersBorder } : {}) };
264
345
  return Object.keys(ui).length > 0 ? { ui } : undefined;
265
346
  }
266
347
  /** Registration uses the official SDK; HTML loaders run only on resources/read. */
267
- function registerApps(server, links, message) {
348
+ function registerApps(server, links, message, tools, request) {
268
349
  const enabled = clientUiSupport(message) !== "unsupported";
269
350
  if (enabled)
270
351
  for (const resource of links.resources) {
271
352
  const meta = appMeta(resource);
272
353
  server.registerResource(resource.name, resource.uri, { mimeType: RESOURCE_MIME_TYPE, title: resource.title, description: resource.description, ...(meta ? { _meta: meta } : {}) }, async () => {
273
354
  try {
274
- return { contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: typeof resource.html === "string" ? resource.html : await resource.html(), ...(meta ? { _meta: meta } : {}) }] };
355
+ const loaded = typeof resource.html === "string" ? resource.html : await resource.html({ tools, ...request });
356
+ const page = typeof loaded === "string" ? { html: loaded } : loaded, read = page.csp ? appMeta(resource, page.csp) : meta;
357
+ return { contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: page.html, ...(read ? { _meta: read } : {}) }] };
275
358
  }
276
- catch {
277
- console.error("[mantle mcp] resource rendering failed");
359
+ catch (error) {
360
+ console.error("[mantle mcp] resource rendering failed:", error instanceof Error ? error.message : String(error));
278
361
  throw new Error("Internal error.");
279
362
  }
280
363
  });
@@ -1,11 +1,14 @@
1
1
  /** MCP request handling for a generated tool table; the official SDK loads on the first MCP request. */
2
2
  import type { Caller, Target, Authorize, Gateway, RequestContext, GatewayOptions } from "./contracts.js";
3
3
  import type { Conversions } from "./surface.js";
4
- export { callTool, callToolLazy } from "./mcp-call.js";
5
- export type { McpApps, McpAppResource } from "./mcp-apps.js";
4
+ import { type McpAppsSource } from "./mcp-apps.js";
5
+ type Call = typeof import("./mcp-call.js");
6
+ export declare const callTool: Call["callTool"];
7
+ export declare const callToolLazy: Call["callToolLazy"];
8
+ export type { McpApps, McpAppsSource, McpAppResource, McpAppRender, McpAppPage, McpAppCsp } from "./mcp-apps.js";
6
9
  /** Options of a generated `mcp(...)`; it adds required `handlers` when the app declares ref Procedures. */
7
10
  export interface Options<Env, Database> extends GatewayOptions {
8
- apps?: Readonly<Partial<Record<"public" | "admin", import("./mcp-apps.js").McpApps | readonly import("./mcp-apps.js").McpApps[]>>>;
11
+ apps?: Readonly<Partial<Record<"public" | "admin", McpAppsSource | readonly McpAppsSource[]>>>;
9
12
  authorize?: Authorize;
10
13
  gateway?: Gateway;
11
14
  fields?: import("./user-fields.js").UserFields;
@@ -27,7 +30,11 @@ export interface Options<Env, Database> extends GatewayOptions {
27
30
  }[];
28
31
  };
29
32
  requiredScopes?: readonly string[];
33
+ /** Additional scopes requested by an unauthenticated OAuth challenge (for example offline_access), not an API scope floor. */
34
+ authorizationScopes?: readonly string[];
30
35
  resourceMetadata?: string;
36
+ /** Browser origins MCP answers besides loopback, compared by hostname: `https://console.example` or the official SDK's bare `console.example`. The site's own origin is not implied. */
37
+ allowedOrigins?: readonly string[];
31
38
  onObservation?: (event: McpObservation) => void | Promise<void>;
32
39
  onRefusal?: (event: CallerRefusal) => void | Promise<void>;
33
40
  }
@@ -79,6 +86,8 @@ export interface Tool<Database, Handlers = never> {
79
86
  readonly idempotentHint?: boolean;
80
87
  };
81
88
  readonly inputSchema: unknown;
89
+ /** The listed definition's version; results carry it so a model holding an older definition can tell. */
90
+ readonly version?: string;
82
91
  /** Declares an auth requirement: anonymous callers are challenged. */
83
92
  readonly closed?: boolean;
84
93
  /** OAuth scopes the requirement names. */
@@ -1,20 +1,32 @@
1
1
  import { linkApps, linkSurface, resourcesOf } from "./mcp-apps.js";
2
- export { callTool, callToolLazy } from "./mcp-call.js";
2
+ export const callTool = async (...args) => (await import("./mcp-call.js")).callTool(...args);
3
+ export const callToolLazy = async (...args) => (await import("./mcp-call.js")).callToolLazy(...args);
3
4
  /** The selected MCP routes; all request work loads only when an MCP request arrives. */
4
5
  export function mcp(tools, options, reads = []) {
5
6
  let serve;
6
- // a bad App configuration fails here, not on the first request
7
+ // Check known static App configuration now; factories wait for the request caller gate.
7
8
  const linked = {};
8
9
  const apps = {};
9
10
  for (const surface of ["public", "admin"]) {
10
11
  const declared = options.apps?.[surface];
11
12
  if (declared) {
12
- const app = resourcesOf(declared);
13
- apps[surface] = app;
14
- typeof tools === "function" ? linkApps(app) : linked[surface] = linkSurface(app, tools.filter(tool => tool.surface === surface));
13
+ // Read each static contributor's resources getter once, including mixed arrays.
14
+ const sources = (Array.isArray(declared) ? declared : [declared])
15
+ .map(source => typeof source === "function" ? source : resourcesOf(source));
16
+ const fixed = resourcesOf(sources.filter(source => typeof source !== "function"));
17
+ const dynamic = sources.some(source => typeof source === "function");
18
+ apps[surface] = dynamic ? sources : fixed;
19
+ // Known static conflicts still fail before any request or dynamic factory runs.
20
+ if (typeof tools === "function")
21
+ linkApps(fixed);
22
+ else {
23
+ const current = linkSurface(fixed, tools.filter(tool => tool.surface === surface));
24
+ if (!dynamic)
25
+ linked[surface] = current;
26
+ }
15
27
  }
16
28
  }
17
- options = { ...options, apps };
29
+ options = { ...options, apps, allowedOrigins: originHostnames(options.allowedOrigins) };
18
30
  return async (request, env, ctx) => {
19
31
  if (!/^\/mcp(?:\/admin)?\/*$/.test(new URL(request.url).pathname))
20
32
  return;
@@ -23,3 +35,16 @@ export function mcp(tools, options, reads = []) {
23
35
  return serve(request, env, ctx);
24
36
  };
25
37
  }
38
+ /** `allowedOrigins` as the hostnames the official SDK compares; a bad entry fails here, not on each request. */
39
+ function originHostnames(origins = []) {
40
+ return Object.freeze(origins.map(origin => {
41
+ let url;
42
+ try {
43
+ url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(origin) ? origin : `http://${origin}`);
44
+ }
45
+ catch { }
46
+ if (origin.includes("*") || !url?.hostname || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
47
+ throw new TypeError(`mcp allowedOrigins: "${origin}" is not an origin (https://host[:port]) or a host[:port]; wildcards never match.`);
48
+ return url.hostname;
49
+ }));
50
+ }
@@ -1,11 +1,12 @@
1
1
  /** Route matching and caller enforcement load on the first request. */
2
2
  import { forwarded, ContractError, checkOrigin, surfaceCaller } from "./contracts.js";
3
3
  import { decode, invalid } from "./surface.js";
4
+ import { draining } from "./drain.js";
4
5
  const isApi = (path) => path === "/api" || path.startsWith("/api/");
5
6
  const fail = message => invalid(message, "rest");
6
7
  /** Shared matching/auth/error envelope; selected dispatch is supplied by the surface. */
7
8
  export function router(routes, options, send) {
8
- return async (request, env, ctx) => {
9
+ return draining(async (request, env, ctx) => {
9
10
  let caller;
10
11
  const isForwarded = forwarded(ctx, options.gateway);
11
12
  const access = options.gateway ? (await import("./host-access.js")).accessRequest(ctx, options) : undefined;
@@ -59,5 +60,5 @@ export function router(routes, options, send) {
59
60
  access?.finish(error);
60
61
  return (await import("./host-caller.js")).hostFailure(error, caller, `rest ${request.method} ${new URL(request.url).pathname}`);
61
62
  }
62
- };
63
+ });
63
64
  }
@@ -6,9 +6,10 @@
6
6
  import { ContractError } from "./contracts.js";
7
7
  export function encodeTimestamptz(v) {
8
8
  if (v instanceof Date) {
9
- if (Number.isNaN(v.getTime()))
9
+ const micros = v.getTime() * 1000;
10
+ if (!Number.isSafeInteger(micros))
10
11
  throw new ContractError("SQL_TYPE", "Invalid Date is not a timestamp");
11
- return v.getTime() * 1000;
12
+ return micros;
12
13
  }
13
14
  // hours 00-23: Date.parse would roll T24:00 over to the next day
14
15
  const m = /^(\d{4}-\d\d-\d\d)[T ]((?:[01]\d|2[0-3]):\d\d(?::\d\d)?)(?:\.(\d{1,6}))?(Z|[+-]\d\d(?::?\d\d)?)$/.exec(v.trim());
@@ -5,7 +5,7 @@ import type { Dialect } from "./dialect/common.js";
5
5
  import type { MantleSite, SiteDefaults, SiteConfig, MediaPurposePolicy } from "./site-types.js";
6
6
  export type { MantleSite, SiteDefaults, SiteConfig, MediaStorage, MediaAsset, MediaLibrary, MediaPurposePolicy } from "./site-types.js";
7
7
  export declare function createSite(db: MediaDatabase, d: Pick<Dialect, "param" | "escapeLike" | "likeEscape" | "like">, caller: Caller, defaults: SiteDefaults, policies: readonly MediaPurposePolicy[]): MantleSite;
8
- /** 0.2 /admin/api/site projection; the app supplies its selected capabilities and extensions. */
8
+ /** 0.2 /admin/api/site projection; the app supplies its selected capabilities. */
9
9
  export declare function siteInfo(config: SiteConfig, options: {
10
10
  origin: string;
11
11
  media?: boolean;
@@ -14,7 +14,6 @@ export declare function siteInfo(config: SiteConfig, options: {
14
14
  public?: string | null;
15
15
  admin?: string | null;
16
16
  };
17
- extensions?: readonly unknown[];
18
17
  siteSettings?: boolean;
19
18
  }): {
20
19
  publicUrl: string;
@@ -28,7 +27,6 @@ export declare function siteInfo(config: SiteConfig, options: {
28
27
  invitationEmail: boolean;
29
28
  statistics: boolean;
30
29
  };
31
- extensions: readonly unknown[];
32
30
  brand: string;
33
31
  title: string;
34
32
  description: string;
@@ -23,10 +23,10 @@ export function createSite(db, d, caller, defaults, policies) {
23
23
  return read();
24
24
  }, media(storage) { admin(); return mediaLibrary(db, d, storage, policies); } };
25
25
  }
26
- /** 0.2 /admin/api/site projection; the app supplies its selected capabilities and extensions. */
26
+ /** 0.2 /admin/api/site projection; the app supplies its selected capabilities. */
27
27
  export function siteInfo(config, options) {
28
28
  const { origin, ...stored } = config;
29
29
  const publicUrl = URL.canParse(origin) ? origin : options.origin;
30
30
  const at = (path) => path ? new URL(path, publicUrl).href : null;
31
- return { ...stored, publicUrl, mcpEndpoints: { public: at(options.mcpEndpoints?.public), admin: at(options.mcpEndpoints?.admin) }, capabilities: { siteSettings: options.siteSettings ?? true, media: !!options.media, invitationEmail: !!options.invitationEmail, statistics: false }, extensions: options.extensions ?? [] };
31
+ return { ...stored, publicUrl, mcpEndpoints: { public: at(options.mcpEndpoints?.public), admin: at(options.mcpEndpoints?.admin) }, capabilities: { siteSettings: options.siteSettings ?? true, media: !!options.media, invitationEmail: !!options.invitationEmail, statistics: false } };
32
32
  }
@@ -0,0 +1,40 @@
1
+ /** /llms.txt and /sitemap.xml bodies; loaded only when an index is requested, so page Workers do not pay for it at startup. */
2
+ const escape = (v) => String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
3
+ const label = (v) => String(v).replace(/[\r\n]/g, " ").replace(/[\\[\]]/g, "\\$&");
4
+ const size = (s) => new TextEncoder().encode(s).length;
5
+ export async function webIndex(sitemap, options, pages, list, validUrl) {
6
+ const cap = sitemap ? 50000 : 5000, limit = sitemap ? 50 * 1024 * 1024 : 1024 * 1024;
7
+ const start = sitemap ? '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' : "# " + label(options.title) + "\n\n> " + label(options.description) + "\n\n## Pages\n\n", end = sitemap ? "</urlset>" : "\n";
8
+ const links = [];
9
+ let visited = 0, requests = 0, bytes = size(start + end);
10
+ if (bytes > limit)
11
+ throw new TypeError("Web index header exceeds its byte limit.");
12
+ pagesLoop: for (const entry of pages) {
13
+ if (!entry.page.list)
14
+ continue;
15
+ let cursor;
16
+ do {
17
+ if (requests >= (sitemap ? 100 : 10))
18
+ break pagesLoop;
19
+ requests++;
20
+ const page = await list(entry.page.list, cursor);
21
+ for (const row of page.rows) {
22
+ if (visited++ >= cap)
23
+ break pagesLoop;
24
+ const url = validUrl(entry.path, row, entry.page.view);
25
+ if (!url)
26
+ continue;
27
+ const line = sitemap ? "<url><loc>" + escape(url) + "</loc></url>" : "- [" + label(row.title ?? url) + "](" + url + "): " + label(row.description ?? "") + "\n";
28
+ const length = size(line);
29
+ if (bytes + length > limit)
30
+ break pagesLoop;
31
+ bytes += length;
32
+ links.push(line);
33
+ }
34
+ if (visited >= cap)
35
+ break pagesLoop;
36
+ cursor = page.nextCursor;
37
+ } while (cursor);
38
+ }
39
+ return start + links.join("") + end;
40
+ }
@@ -2,7 +2,7 @@
2
2
  import { type Caller, type Gateway, type RequestContext } from "./contracts.js";
3
3
  import type { UserFields } from "./user-fields.js";
4
4
  import { type Conversions } from "./surface.js";
5
- /** An app's public Views: row type and input keys per View, and the Views a list may name (no required input). */
5
+ /** An app's public Views: row type and input keys per View, and the Views a list may name (input that accepts no fields). */
6
6
  export interface App {
7
7
  Rows: Record<string, any>;
8
8
  Inputs: Record<string, string>;
@@ -33,6 +33,8 @@ export type Options<A extends App, Database, P extends Record<string, Page<A>>,
33
33
  /** One public View: its query-string reader, paged boundary, bind conversions and boolean input keys. */
34
34
  export interface View<Database> {
35
35
  readonly check?: (caller: Caller) => void;
36
+ /** The input rejects an empty query, so a list cannot name this View. */
37
+ readonly unlisted?: true;
36
38
  readonly query: (params: URLSearchParams) => {
37
39
  input: Record<string, unknown>;
38
40
  };
@@ -1,9 +1,6 @@
1
1
  /** Web output for a generated public View table: /llms.txt, /sitemap.xml, Markdown pages and head tags. SQL owns visibility. */
2
2
  import { forwarded, wire, ContractError } from "./contracts.js";
3
3
  import { convert, decode } from "./surface.js";
4
- const escape = (v) => String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
5
- const label = (v) => String(v).replace(/[\r\n]/g, " ").replace(/[\\[\]]/g, "\\$&");
6
- const size = (s) => new TextEncoder().encode(s).length;
7
4
  const NO_STORE = { "cache-control": "no-store" };
8
5
  const notFound = () => new Response(null, { status: 404, headers: NO_STORE });
9
6
  export function web(options, table) {
@@ -13,6 +10,9 @@ export function web(options, table) {
13
10
  convert(input, view.convert);
14
11
  return view.page(db, caller, input, { limit: 500, cursor });
15
12
  };
13
+ for (const [path, page] of Object.entries(options.pages))
14
+ if (page.list && views.get(page.list)?.unlisted)
15
+ throw new TypeError(`Page ${path} lists View '${page.list}', whose input rejects an empty query; a list View must accept no input.`);
16
16
  const origin = new URL(options.origin);
17
17
  if (!/^https?:$/.test(origin.protocol) || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash)
18
18
  throw new TypeError("origin must be an HTTP(S) origin.");
@@ -73,40 +73,9 @@ export function web(options, table) {
73
73
  const ctx = await context(request, env, execution);
74
74
  caller = ctx.caller;
75
75
  if (index) {
76
- const sitemap = path === "/sitemap.xml", cap = sitemap ? 50000 : 5000, limit = sitemap ? 50 * 1024 * 1024 : 1024 * 1024;
77
- const start = sitemap ? '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' : "# " + label(options.title) + "\n\n> " + label(options.description) + "\n\n## Pages\n\n", end = sitemap ? "</urlset>" : "\n";
78
- const links = [];
79
- let visited = 0, requests = 0, bytes = size(start + end);
80
- if (bytes > limit)
81
- throw new TypeError("Web index header exceeds its byte limit.");
82
- pagesLoop: for (const entry of pages) {
83
- if (!entry.page.list)
84
- continue;
85
- let cursor;
86
- do {
87
- if (requests >= (sitemap ? 100 : 10))
88
- break pagesLoop;
89
- requests++;
90
- const page = await query(entry.page.list, ctx.db, ctx.caller, new URLSearchParams(), cursor);
91
- for (const row of page.rows) {
92
- if (visited++ >= cap)
93
- break pagesLoop;
94
- const url = validUrl(entry.path, row, entry.page.view);
95
- if (!url)
96
- continue;
97
- const line = sitemap ? "<url><loc>" + escape(url) + "</loc></url>" : "- [" + label(row.title ?? url) + "](" + url + "): " + label(row.description ?? "") + "\n";
98
- const length = size(line);
99
- if (bytes + length > limit)
100
- break pagesLoop;
101
- bytes += length;
102
- links.push(line);
103
- }
104
- if (visited >= cap)
105
- break pagesLoop;
106
- cursor = page.nextCursor;
107
- } while (cursor);
108
- }
109
- return response(request, start + links.join("") + end, sitemap ? "application/xml" : "text/plain", ctx.caller);
76
+ const sitemap = path === "/sitemap.xml";
77
+ const body = await (await import("./web-index.js")).webIndex(sitemap, options, pages, (list, cursor) => query(list, ctx.db, ctx.caller, new URLSearchParams(), cursor), validUrl);
78
+ return response(request, body, sitemap ? "application/xml" : "text/plain", ctx.caller);
110
79
  }
111
80
  const exact = match(path);
112
81
  if (exact)
@@ -1,6 +1,6 @@
1
1
  import { userReferences } from "../domain/userReferences.js";
2
2
  /** Selected application bindings: compiled checks/native SQL; only the app depends on Admin adapters. */
3
- import { collectionOf } from "../build/compiler.js";
3
+ import { isPrimaryCollection } from "../build/compiler.js";
4
4
  import { enumOptions } from "../build/compiler.js";
5
5
  import { databaseImport, isSqlite, literal as q } from "../domain/entry.js";
6
6
  import { conversions, authLines } from "../domain/input.js";
@@ -83,8 +83,7 @@ export function emitAdminBindings(input, refuse) {
83
83
  continue;
84
84
  }
85
85
  const defaults = Object.fromEntries(Object.entries(checks(s, true).properties ?? {}).flatMap(([key, p]) => p.default !== undefined ? [[key, p.default]] : []));
86
- const descriptor = collectionOf(s, Object.values(plan.schemas));
87
- const primary = !s.translates && (!descriptor.parent || descriptor.nav?.standalone === true);
86
+ const primary = isPrimaryCollection(s, Object.values(plan.schemas));
88
87
  const schemaCode = `{
89
88
  name:${q(s.name)},primary:${primary},descriptor:allCollections[${q(s.name)}],publishing:${!!s.publishing},readOnly:${s.schema.readOnly === true || declared.some(f => s.fields[f] === "geo")},fields:names${n},defaults:${q(defaults)},
90
89
  binding:${entrySpec},