@ampless/runtime 1.0.0-beta.71 → 1.0.0-beta.73

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.ja.md CHANGED
@@ -105,6 +105,19 @@ middleware はリクエストごとに AppSync から `post.format` /
105
105
  `llms.txt` は予約 slug になります — slug が `llms.txt` の投稿はテーマ
106
106
  ページに到達できなくなります。
107
107
 
108
+ `/api/mcp` は **匿名・読み取り専用の MCP エンドポイント**(POST での
109
+ JSON-RPC 2.0)で、`createPublicMcpRouteHandler(ampless)` が生成し
110
+ `app/api/mcp/route.ts` にマウントされます。`@ampless/mcp-server/public`
111
+ の 4 ツール(`list_posts` / `get_post` / `search_posts` / `list_tags`)を
112
+ 公開投稿のみに対して提供します — 書き込み不可・draft 不可視。既定は
113
+ 無効で、`cms.config.ai.publicMcp: true` で有効化します。ルートは open
114
+ CORS(匿名・読み取り専用)、64KB のボディ上限、粗い warm-instance 単位
115
+ の circuit breaker(1 warm lambda あたり 600 req/min。per-IP rate limit
116
+ ではないので、実運用の濫用対策は CloudFront / WAF を併用)を付与します。
117
+ ファクトリは `{ POST, OPTIONS }` を返すので、テンプレートは両方を分割
118
+ 代入します(`export const POST = ...` の一括代入だと CORS preflight が
119
+ 欠落します)。
120
+
108
121
  また、`post.metadata.cache`(auto / deep / hot)+ `post.updatedAt` +
109
122
  `cms.config.cache.{cooldownMs, freshTtlSeconds, deepTtlSeconds}` から
110
123
  `Cache-Control` を算出してレスポンスに付与します。キャッシュ戦略の
@@ -114,7 +127,7 @@ middleware はリクエストごとに AppSync から `post.format` /
114
127
 
115
128
  - `@ampless/runtime` — `createAmpless`、ランタイム型、`renderBody`・`renderThemeCss`・フォーマットコンバーターの再エクスポート
116
129
  - `@ampless/runtime/middleware` — `createAmplessMiddleware`、`defaultMatcherConfig`
117
- - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`、`createStaticRouteHandler`、`createMarkdownRouteHandler`、`createLlmsTxtRouteHandler`
130
+ - `@ampless/runtime/routes` — `createOgRouteHandler`、`createSitemapRouteHandler`、`createFeedRouteHandler`、`createRawRouteHandler`、`createStaticRouteHandler`、`createMarkdownRouteHandler`、`createLlmsTxtRouteHandler`、`createPublicMcpRouteHandler`
118
131
  - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`、`createThemePostDispatcher`、`createThemeTagDispatcher`(それぞれ対応する `*Metadata` ファクトリーあり)
119
132
 
120
133
  ## テンプレートに残るもの
package/README.md CHANGED
@@ -104,6 +104,18 @@ a single-file index. Disable with `cms.config.ai.llmsTxt: false`. Note
104
104
  that `llms.txt` becomes a reserved slug either way — a post whose slug
105
105
  happens to be `llms.txt` can no longer reach the themed route.
106
106
 
107
+ `/api/mcp` is an **anonymous, read-only MCP endpoint** (JSON-RPC 2.0
108
+ over POST) built by `createPublicMcpRouteHandler(ampless)` and mounted
109
+ at `app/api/mcp/route.ts`. It serves the four `@ampless/mcp-server/public`
110
+ tools (`list_posts` / `get_post` / `search_posts` / `list_tags`) over
111
+ published posts only — never writes, never sees drafts. Off by default;
112
+ set `cms.config.ai.publicMcp: true` to enable. The route adds open CORS
113
+ (anonymous read-only), a 64KB body cap, and a coarse warm-instance
114
+ circuit breaker (600 req/min per warm lambda, not a per-IP limiter —
115
+ pair it with CloudFront / WAF for real abuse protection). The factory
116
+ returns `{ POST, OPTIONS }`; the template destructures both (a single
117
+ `export const POST = ...` would drop the CORS preflight).
118
+
107
119
  It also computes `Cache-Control` from `post.metadata.cache` (auto /
108
120
  deep / hot) + `post.updatedAt` + `cms.config.cache.{cooldownMs,
109
121
  freshTtlSeconds, deepTtlSeconds}` and sets the header on the response.
@@ -113,7 +125,7 @@ See `docs/CONTENT.md` for the cache strategy contract.
113
125
 
114
126
  - `@ampless/runtime` — `createAmpless`, runtime types, and re-exports of `renderBody`, `renderThemeCss`, format converters
115
127
  - `@ampless/runtime/middleware` — `createAmplessMiddleware`, `defaultMatcherConfig`
116
- - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createRawRouteHandler`, `createStaticRouteHandler`, `createMarkdownRouteHandler`, `createLlmsTxtRouteHandler`
128
+ - `@ampless/runtime/routes` — `createOgRouteHandler`, `createSitemapRouteHandler`, `createFeedRouteHandler`, `createRawRouteHandler`, `createStaticRouteHandler`, `createMarkdownRouteHandler`, `createLlmsTxtRouteHandler`, `createPublicMcpRouteHandler`
117
129
  - `@ampless/runtime/dispatchers` — `createThemeHomeDispatcher`, `createThemePostDispatcher`, `createThemeTagDispatcher` (each with a matching `*Metadata` factory)
118
130
 
119
131
  ## What's still in the template
@@ -131,4 +131,21 @@ type LlmsTxtRouteHandler = (req: Request, ctx: Ctx) => Promise<Response>;
131
131
  */
132
132
  declare function createLlmsTxtRouteHandler(ampless: Ampless): LlmsTxtRouteHandler;
133
133
 
134
- export { type FeedRouteHandler, type LlmsTxtRouteHandler, type MarkdownRouteHandler, type OgRouteHandler, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, createFeedRouteHandler, createLlmsTxtRouteHandler, createMarkdownRouteHandler, createOgRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
134
+ interface PublicMcpRouteHandlers {
135
+ POST: (request: Request) => Promise<Response>;
136
+ OPTIONS: (request: Request) => Promise<Response>;
137
+ }
138
+ /**
139
+ * Test hook: reset the module-scope circuit breaker between cases.
140
+ * Production code never calls this — the counter is intentionally
141
+ * process-local (mirrors middleware's `_resetFlagCache`).
142
+ */
143
+ declare function _resetPublicMcpRateLimit(): void;
144
+ /**
145
+ * Build `{ POST, OPTIONS }` for the public MCP route. The template must
146
+ * destructure both — a single `export const POST = ...(ampless)` assignment
147
+ * would drop OPTIONS and break the CORS preflight.
148
+ */
149
+ declare function createPublicMcpRouteHandler(ampless: Ampless): PublicMcpRouteHandlers;
150
+
151
+ export { type FeedRouteHandler, type LlmsTxtRouteHandler, type MarkdownRouteHandler, type OgRouteHandler, type PublicMcpRouteHandlers, type RawRouteHandler, type SitemapRouteHandler, type StaticRouteHandler, _resetPublicMcpRateLimit, createFeedRouteHandler, createLlmsTxtRouteHandler, createMarkdownRouteHandler, createOgRouteHandler, createPublicMcpRouteHandler, createRawRouteHandler, createSitemapRouteHandler, createStaticRouteHandler };
@@ -207,6 +207,7 @@ function createMarkdownRouteHandler(ampless) {
207
207
  }
208
208
 
209
209
  // src/routes/llms.ts
210
+ import { collectBounded } from "ampless";
210
211
  var DEFAULT_LIMIT = 100;
211
212
  var MIN_LIMIT = 1;
212
213
  var MAX_LIMIT = 1e3;
@@ -241,36 +242,10 @@ function fixedEncodeURIComponent(s) {
241
242
  );
242
243
  }
243
244
  async function collectPosts(ampless, limit) {
244
- const items = [];
245
- const seenTokens = /* @__PURE__ */ new Set();
246
- let token;
247
- let truncated = null;
248
- for (let page = 0; page < MAX_PAGES; page++) {
249
- const remaining = limit + 1 - items.length;
250
- const pageLimit = Math.min(PAGE_SIZE_CAP, remaining);
251
- const res = await ampless.listPublishedPosts({ limit: pageLimit, nextToken: token });
252
- items.push(...res.items);
253
- if (items.length > limit) {
254
- truncated = "limit";
255
- break;
256
- }
257
- if (!res.nextToken) {
258
- break;
259
- }
260
- if (seenTokens.has(res.nextToken)) {
261
- truncated = "early";
262
- break;
263
- }
264
- seenTokens.add(res.nextToken);
265
- token = res.nextToken;
266
- if (page === MAX_PAGES - 1) {
267
- truncated = "early";
268
- }
269
- }
270
- return {
271
- items: truncated === "limit" ? items.slice(0, limit) : items,
272
- truncated
273
- };
245
+ return collectBounded(
246
+ (args) => ampless.listPublishedPosts({ limit: args.limit, nextToken: args.nextToken }),
247
+ { limit, pageSizeCap: PAGE_SIZE_CAP, maxPages: MAX_PAGES }
248
+ );
274
249
  }
275
250
  function truncationNote(truncated, limit) {
276
251
  if (truncated === "limit") {
@@ -351,11 +326,172 @@ ${lines.join("\n")}`);
351
326
  });
352
327
  };
353
328
  }
329
+
330
+ // src/routes/public-mcp.ts
331
+ import {
332
+ dispatchJsonRpcMessage,
333
+ jsonRpcError,
334
+ JSON_RPC_PARSE_ERROR,
335
+ JSON_RPC_INVALID_REQUEST,
336
+ JSON_RPC_INTERNAL_ERROR,
337
+ MAX_BATCH
338
+ } from "@ampless/mcp-server/jsonrpc";
339
+ import { publicTools } from "@ampless/mcp-server/public";
340
+ var MAX_BODY_BYTES = 64 * 1024;
341
+ var RATE_WINDOW_MS = 6e4;
342
+ var RATE_MAX = 600;
343
+ var rateState = { count: 0, windowExpires: 0 };
344
+ function reserve(n) {
345
+ const now = Date.now();
346
+ if (now >= rateState.windowExpires) {
347
+ rateState = { count: 0, windowExpires: now + RATE_WINDOW_MS };
348
+ }
349
+ if (rateState.count + n > RATE_MAX) return false;
350
+ rateState.count += n;
351
+ return true;
352
+ }
353
+ function retryAfterSeconds() {
354
+ return Math.max(1, Math.ceil((rateState.windowExpires - Date.now()) / 1e3));
355
+ }
356
+ function _resetPublicMcpRateLimit() {
357
+ rateState = { count: 0, windowExpires: 0 };
358
+ }
359
+ function corsHeaders(extra = {}) {
360
+ return {
361
+ "Access-Control-Allow-Origin": "*",
362
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
363
+ "Access-Control-Allow-Headers": "content-type, mcp-protocol-version",
364
+ "Access-Control-Max-Age": "86400",
365
+ ...extra
366
+ };
367
+ }
368
+ function jsonResponse(status, body) {
369
+ return new Response(JSON.stringify(body), {
370
+ status,
371
+ headers: corsHeaders({ "Content-Type": "application/json", "Cache-Control": "no-store" })
372
+ });
373
+ }
374
+ function notFound() {
375
+ return new Response("Not Found", { status: 404, headers: corsHeaders() });
376
+ }
377
+ function rateLimited() {
378
+ return new Response(JSON.stringify(jsonRpcError(null, -32e3, "Rate limit exceeded")), {
379
+ status: 429,
380
+ headers: corsHeaders({
381
+ "Content-Type": "application/json",
382
+ "Cache-Control": "no-store",
383
+ "Retry-After": String(retryAfterSeconds())
384
+ })
385
+ });
386
+ }
387
+ function payloadTooLarge() {
388
+ return new Response(
389
+ JSON.stringify(jsonRpcError(null, JSON_RPC_INVALID_REQUEST, "Payload too large")),
390
+ {
391
+ status: 413,
392
+ headers: corsHeaders({ "Content-Type": "application/json", "Cache-Control": "no-store" })
393
+ }
394
+ );
395
+ }
396
+ async function readBodyCapped(request) {
397
+ const body = request.body;
398
+ if (!body) return "";
399
+ const reader = body.getReader();
400
+ const chunks = [];
401
+ let total = 0;
402
+ for (; ; ) {
403
+ const { done, value } = await reader.read();
404
+ if (done) break;
405
+ if (!value) continue;
406
+ total += value.byteLength;
407
+ if (total > MAX_BODY_BYTES) {
408
+ await reader.cancel();
409
+ return null;
410
+ }
411
+ chunks.push(value);
412
+ }
413
+ if (total === 0) return "";
414
+ const merged = new Uint8Array(total);
415
+ let offset = 0;
416
+ for (const chunk of chunks) {
417
+ merged.set(chunk, offset);
418
+ offset += chunk.byteLength;
419
+ }
420
+ return new TextDecoder("utf-8").decode(merged);
421
+ }
422
+ function createPublicMcpRouteHandler(ampless) {
423
+ const publicCtx = {
424
+ listPublishedPosts: (opts) => ampless.listPublishedPosts(opts),
425
+ getPublishedPost: (slug) => ampless.getPublishedPost(slug),
426
+ postToMarkdown: (post, opts) => ampless.postToMarkdown(post, opts)
427
+ };
428
+ function formatToolError(err) {
429
+ console.error("[ampless] public MCP tool error", err);
430
+ return "Internal error while executing the tool.";
431
+ }
432
+ function gateOpen() {
433
+ return ampless.cmsConfig.ai?.publicMcp === true;
434
+ }
435
+ async function POST(request) {
436
+ try {
437
+ if (!gateOpen()) return notFound();
438
+ if (!reserve(1)) return rateLimited();
439
+ const contentLength = request.headers.get("content-length");
440
+ if (contentLength) {
441
+ const declared = Number(contentLength);
442
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
443
+ return payloadTooLarge();
444
+ }
445
+ }
446
+ const bodyText = await readBodyCapped(request);
447
+ if (bodyText === null) return payloadTooLarge();
448
+ if (bodyText.length === 0) {
449
+ return jsonResponse(400, jsonRpcError(null, JSON_RPC_INVALID_REQUEST, "Empty body"));
450
+ }
451
+ let parsed;
452
+ try {
453
+ parsed = JSON.parse(bodyText);
454
+ } catch {
455
+ return jsonResponse(400, jsonRpcError(null, JSON_RPC_PARSE_ERROR, "Parse error"));
456
+ }
457
+ if (Array.isArray(parsed) && parsed.length >= 1 && parsed.length <= MAX_BATCH) {
458
+ if (!reserve(parsed.length - 1)) return rateLimited();
459
+ }
460
+ const result = await dispatchJsonRpcMessage(parsed, {
461
+ tools: publicTools,
462
+ getContext: () => publicCtx,
463
+ serverInfo: { name: "ampless-mcp", version: "0.2" },
464
+ formatToolError,
465
+ maxBatch: MAX_BATCH
466
+ });
467
+ if (result.status === "invalid") {
468
+ return jsonResponse(400, result.body);
469
+ }
470
+ if (result.status === "no-content") {
471
+ return new Response(null, {
472
+ status: 202,
473
+ headers: corsHeaders({ "Cache-Control": "no-store" })
474
+ });
475
+ }
476
+ return jsonResponse(200, result.body);
477
+ } catch (err) {
478
+ console.error("[ampless] public MCP route error", err);
479
+ return jsonResponse(500, jsonRpcError(null, JSON_RPC_INTERNAL_ERROR, "Internal error"));
480
+ }
481
+ }
482
+ async function OPTIONS(_request) {
483
+ if (!gateOpen()) return notFound();
484
+ return new Response(null, { status: 204, headers: corsHeaders() });
485
+ }
486
+ return { POST, OPTIONS };
487
+ }
354
488
  export {
489
+ _resetPublicMcpRateLimit,
355
490
  createFeedRouteHandler,
356
491
  createLlmsTxtRouteHandler,
357
492
  createMarkdownRouteHandler,
358
493
  createOgRouteHandler,
494
+ createPublicMcpRouteHandler,
359
495
  createRawRouteHandler,
360
496
  createSitemapRouteHandler,
361
497
  createStaticRouteHandler
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/runtime",
3
- "version": "1.0.0-beta.71",
3
+ "version": "1.0.0-beta.73",
4
4
  "description": "Public-side runtime for ampless: post fetching, theme dispatch, middleware, public route handlers",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -51,8 +51,9 @@
51
51
  "marked": "^18.0.4",
52
52
  "sanitize-html": "^2.17.4",
53
53
  "tailwind-merge": "^3.6.0",
54
- "@ampless/plugin-og-image": "0.2.0-beta.58",
55
- "ampless": "1.0.0-beta.58"
54
+ "@ampless/mcp-server": "1.0.0-beta.67",
55
+ "ampless": "1.0.0-beta.60",
56
+ "@ampless/plugin-og-image": "0.2.0-beta.60"
56
57
  },
57
58
  "peerDependencies": {
58
59
  "@aws-amplify/adapter-nextjs": "^1",