@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 +14 -1
- package/README.md +13 -1
- package/dist/routes/index.d.ts +18 -1
- package/dist/routes/index.js +166 -30
- package/package.json +4 -3
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
|
package/dist/routes/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/routes/index.js
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
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/
|
|
55
|
-
"ampless": "1.0.0-beta.
|
|
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",
|