@xingwangzhe/stalux 1.35.12 → 1.35.13

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.
@@ -1,6 +1,3 @@
1
- import { createPostLookup } from "../utils/post-lookup";
2
- import { createClientLogger } from "./logger";
3
-
4
1
  /**
5
2
  * Stalux WebMCP 工具注册(纯前端,零后端)
6
3
  *
@@ -20,8 +17,13 @@ import { createClientLogger } from "./logger";
20
17
  */
21
18
  // 浏览器无原生 document.modelContext 时由 polyfill 兜底安装;
22
19
  // 有原生实现或已存在 WebMCP-aware 扩展时,polyfill 检测到后自动 no-op。
23
- // 放在模块顶部 import,保证其副作用在下方注册逻辑执行前完成。
24
- import "@mcp-b/webmcp-polyfill";
20
+ // ESM entry exports an initializer; importing it alone does not install the API.
21
+ import { initializeWebMCPPolyfill } from "@mcp-b/webmcp-polyfill";
22
+ import { createPostLookup } from "../utils/post-lookup";
23
+ import { sitePath } from "../utils/site-path";
24
+ import { createClientLogger } from "./logger";
25
+ import { registerPageLifecycle } from "./page-runtime";
26
+ import { loadPagefind } from "./pagefind-api";
25
27
 
26
28
  const logger = createClientLogger("webmcp");
27
29
 
@@ -46,7 +48,10 @@ interface WebMCPTool {
46
48
  readOnlyHint?: boolean;
47
49
  untrustedContentHint?: boolean;
48
50
  };
49
- execute: (input: Record<string, unknown>) => Promise<unknown>;
51
+ execute: (
52
+ input: Record<string, unknown>,
53
+ options?: { signal?: AbortSignal },
54
+ ) => Promise<unknown>;
50
55
  }
51
56
 
52
57
  interface ModelContext {
@@ -88,7 +93,10 @@ function err(
88
93
  }
89
94
 
90
95
  async function fetchJSON(url: string): Promise<unknown> {
91
- const r = await fetch(url, { headers: { Accept: "application/json" } });
96
+ const r = await fetch(url, {
97
+ headers: { Accept: "application/json" },
98
+ signal: AbortSignal.timeout(15000),
99
+ });
92
100
  if (!r.ok) {
93
101
  logger.debug(`JSON request failed; status=${r.status}`);
94
102
  return null;
@@ -96,27 +104,6 @@ async function fetchJSON(url: string): Promise<unknown> {
96
104
  return r.json();
97
105
  }
98
106
 
99
- /** 动态加载 Pagefind 全文索引(仅首次调用,结果跨调用缓存) */
100
- let pagefindLoadPromise: Promise<{ search: (q: string) => Promise<unknown> } | null> | null = null;
101
- async function loadPagefind(): Promise<{ search: (q: string) => Promise<unknown> } | null> {
102
- logger.debug(pagefindLoadPromise ? "Pagefind module cache hit" : "loading Pagefind module");
103
- if (!pagefindLoadPromise) {
104
- pagefindLoadPromise = (async () => {
105
- try {
106
- // 用完整 URL 构造,让 Vite 在 dev(vite:import-analysis)与 build 都不解析该路径;
107
- // pagefind 索引是构建期产物,运行时从站点根加载。
108
- const url = new URL("/pagefind/pagefind.js", location.origin).href;
109
- const mod = await import(/* @vite-ignore */ url);
110
- return mod.default ?? mod;
111
- } catch {
112
- logger.warn("Pagefind index unavailable; search requires a production index");
113
- return null;
114
- }
115
- })();
116
- }
117
- return pagefindLoadPromise;
118
- }
119
-
120
107
  const MAX_LIST_PAGE_SIZE = 50;
121
108
  const MAX_SEARCH_RESULTS = 10;
122
109
 
@@ -151,9 +138,35 @@ function lookupFor(list: PostMeta[]) {
151
138
  /** 加载文章元信息索引(惰性 + 缓存) */
152
139
  function loadPostsMeta(): Promise<PostMeta[] | null> {
153
140
  if (!postsMetaPromise) {
154
- postsMetaPromise = fetchJSON("/api/posts.json").then((data) =>
155
- Array.isArray(data) ? (data as PostMeta[]) : null,
156
- );
141
+ const attempt = fetchJSON(sitePath("api/posts.json"))
142
+ .then((data) => {
143
+ if (
144
+ !Array.isArray(data) ||
145
+ !data.every(
146
+ (post) =>
147
+ post &&
148
+ typeof post.title === "string" &&
149
+ typeof post.abbrlink === "string" &&
150
+ typeof post.url === "string" &&
151
+ (post.desc === undefined || typeof post.desc === "string") &&
152
+ (!post.categories ||
153
+ (Array.isArray(post.categories) &&
154
+ post.categories.every(
155
+ (category: unknown) => typeof category === "string",
156
+ ))) &&
157
+ (!post.tags ||
158
+ (Array.isArray(post.tags) &&
159
+ post.tags.every((tag: unknown) => typeof tag === "string"))),
160
+ )
161
+ )
162
+ return null;
163
+ return data as PostMeta[];
164
+ })
165
+ .catch(() => null);
166
+ postsMetaPromise = attempt;
167
+ void attempt.then((result) => {
168
+ if (!result && postsMetaPromise === attempt) postsMetaPromise = null;
169
+ });
157
170
  }
158
171
  return postsMetaPromise;
159
172
  }
@@ -178,15 +191,43 @@ function briefMeta(p: PostMeta): Record<string, unknown> {
178
191
  categories: p.categories ?? [],
179
192
  desc: p.desc ?? undefined,
180
193
  wordCount: p.wordCount ?? undefined,
181
- url: p.url,
194
+ url: new URL(p.url, location.origin).href,
182
195
  };
183
196
  }
184
197
 
185
198
  /** 从当前 URL 解析 abbrlink(/posts/{abbrlink}/ 或 /posts/{abbrlink}.md) */
186
199
  function currentAbbrlinkFromPath(): string | null {
187
- const match = location.pathname.match(/^\/posts\/([^/]+?)(?:\.md)?\/?$/);
188
- const encoded = match?.[1];
189
- return encoded ? decodeURIComponent(encoded) : null;
200
+ return postIdFromUrl(location.href);
201
+ }
202
+
203
+ function postIdFromUrl(value: string): string | null {
204
+ try {
205
+ const url = new URL(value, location.origin);
206
+ if (url.origin !== location.origin) return null;
207
+ const prefix = sitePath("posts/");
208
+ if (!url.pathname.startsWith(prefix)) return null;
209
+ const suffix = url.pathname.slice(prefix.length);
210
+ const path = suffix.endsWith("/") ? suffix.slice(0, -1) : suffix.replace(/\.md$/, "");
211
+ if (!path || path.includes("/")) return null;
212
+ return decodeURIComponent(path);
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function integerInput(
219
+ value: unknown,
220
+ fallback: number,
221
+ minimum: number,
222
+ maximum = Number.MAX_SAFE_INTEGER,
223
+ ): number | undefined {
224
+ if (value === undefined) return fallback;
225
+ return typeof value === "number" &&
226
+ Number.isSafeInteger(value) &&
227
+ value >= minimum &&
228
+ value <= maximum
229
+ ? value
230
+ : undefined;
190
231
  }
191
232
 
192
233
  // ---------------------------------------------------------------------------
@@ -201,11 +242,11 @@ function listPostsTool(): WebMCPTool {
201
242
  "分页列出博客的全部已发布文章,返回标题、永久链接(abbrlink)、日期、分类、标签、摘要与文章页 URL。" +
202
243
  "需要浏览全站文章、确认某篇文章的 abbrlink 时使用;不返回正文," +
203
244
  "只要某篇元信息用 stalux_get_post,读正文请用 stalux_read_post。" +
204
- "pageSize 建议设 50 一次拿完,减少翻页;页码越界时会自动返回最后一页(clamped 标记)。",
245
+ "pageSize 可设 50 减少翻页,按 totalPages 继续分页;页码越界时会自动返回最后一页(clamped 标记)。",
205
246
  "Paginated list of all published posts, returning title, permanent link (abbrlink), date, categories, tags, description and post page URL. " +
206
247
  "Use it to browse the whole blog or find a post's abbrlink; it does not return body content — " +
207
248
  "use stalux_get_post for one post's metadata, or stalux_read_post for the full body. " +
208
- "Prefer pageSize 50 to fetch everything in one call; out-of-range pages clamp to the last page (clamped flag).",
249
+ "Use pageSize 50 to reduce calls and follow totalPages to continue; out-of-range pages clamp to the last page (clamped flag).",
209
250
  ),
210
251
  annotations: { readOnlyHint: true, untrustedContentHint: false },
211
252
  inputSchema: {
@@ -231,11 +272,16 @@ function listPostsTool(): WebMCPTool {
231
272
  additionalProperties: false,
232
273
  },
233
274
  execute: async (input) => {
234
- const page = Math.max(1, Number(input.page) || 1);
235
- const pageSize = Math.min(
236
- MAX_LIST_PAGE_SIZE,
237
- Math.max(1, Number(input.pageSize) || 10),
238
- );
275
+ const page = integerInput(input.page, 1, 1);
276
+ const pageSize = integerInput(input.pageSize, 10, 1, MAX_LIST_PAGE_SIZE);
277
+ if (page === undefined || pageSize === undefined)
278
+ return err(
279
+ "BAD_INPUT",
280
+ pick(
281
+ "页码和每页条数必须是有效整数",
282
+ "Page and pageSize must be valid integers",
283
+ ),
284
+ );
239
285
  const list = await loadPostsMeta();
240
286
  if (!list)
241
287
  return err(
@@ -286,8 +332,8 @@ function getPostTool(): WebMCPTool {
286
332
  additionalProperties: false,
287
333
  },
288
334
  execute: async (input) => {
289
- const id = String(input.id ?? "").trim();
290
- if (!id) {
335
+ const id = typeof input.id === "string" ? input.id.trim() : "";
336
+ if (!id || id.length > 512) {
291
337
  return err(
292
338
  "BAD_INPUT",
293
339
  pick(
@@ -379,9 +425,11 @@ function searchPostsTool(): WebMCPTool {
379
425
  title: pick("搜索博客文章", "Search blog posts"),
380
426
  description: pick(
381
427
  "用关键词搜索博客文章的标题、标签、分类与正文全文,返回命中文章的标题、链接与摘要片段。" +
428
+ "全文索引不可用时仅返回元信息匹配,并标记 fullText: false;不把索引故障当成全文无匹配。" +
382
429
  "适合「博客里写过 X 吗」「帮我找一下关于 Y 的文章」这类问题;" +
383
430
  "定位到具体文章后再用 stalux_read_post 读正文、stalux_get_post 取元信息。",
384
431
  "Search the full text of all posts (title, tags, categories, body) by keyword, returning matching posts with title, URL and excerpt snippets. " +
432
+ "If Pagefind is unavailable, metadata-only matches are marked fullText: false; index failures are not treated as an empty full-text result. " +
385
433
  "Ideal for questions like 'has the blog covered X?' or 'find posts about Y'; " +
386
434
  "once a post is located, use stalux_read_post for the body or stalux_get_post for metadata.",
387
435
  ),
@@ -404,48 +452,98 @@ function searchPostsTool(): WebMCPTool {
404
452
  required: ["keyword"],
405
453
  additionalProperties: false,
406
454
  },
407
- execute: async (input) => {
408
- const keyword = String(input.keyword ?? "").trim();
409
- if (!keyword)
410
- return err("BAD_INPUT", pick("请提供搜索关键词", "Provide a search keyword"));
411
- const limit = Math.min(MAX_SEARCH_RESULTS, Math.max(1, Number(input.limit) || 10));
455
+ execute: async (input, options) => {
456
+ const keyword = typeof input.keyword === "string" ? input.keyword.trim() : "";
457
+ const limit = integerInput(input.limit, 10, 1, MAX_SEARCH_RESULTS);
458
+ if (!keyword || keyword.length > 256 || limit === undefined)
459
+ return err("BAD_INPUT", pick("搜索参数无效", "Invalid search parameters"));
460
+ const list = await loadPostsMeta();
461
+ if (!list)
462
+ return err("INDEX_UNAVAILABLE", pick("文章索引不可用", "Post index unavailable"));
463
+ const metadataMatches = lookupFor(list)
464
+ .findMatches(keyword, limit)
465
+ .map((post) => ({
466
+ abbrlink: post.abbrlink,
467
+ title: post.title,
468
+ url: new URL(post.url, location.origin).href,
469
+ excerpt: (post.desc ?? "").slice(0, 240),
470
+ }));
471
+ const fallback = (code: string) =>
472
+ metadataMatches.length
473
+ ? {
474
+ ok: true,
475
+ keyword,
476
+ count: metadataMatches.length,
477
+ posts: metadataMatches,
478
+ source: "metadata",
479
+ fullText: false,
480
+ notice: pick(
481
+ "全文索引不可用,仅返回标题、标签、分类和摘要匹配",
482
+ "Full-text index unavailable; returning title, tag, category and description matches only",
483
+ ),
484
+ }
485
+ : err(
486
+ code,
487
+ pick(
488
+ "全文索引不可用,且元信息没有匹配项",
489
+ "Full-text index unavailable and no metadata matches",
490
+ ),
491
+ );
412
492
  const pagefind = await loadPagefind();
413
- if (!pagefind) {
414
- return err(
415
- "INDEX_UNAVAILABLE",
416
- pick("全文搜索索引不可用", "Full-text search index unavailable"),
417
- );
418
- }
419
- const res = (await pagefind.search(keyword)) as {
420
- results?: Array<{ data: () => Promise<unknown> }>;
421
- };
422
- if (!res || !Array.isArray(res.results)) {
423
- return err(
424
- "SEARCH_FAILED",
425
- pick("搜索失败,请重试", "Search failed, please retry"),
426
- );
427
- }
428
- const items = await Promise.all(
429
- res.results.slice(0, limit).map(async (r) => {
430
- try {
431
- const data = (await r.data()) as {
432
- url?: string;
433
- title?: string;
434
- excerpt?: string;
435
- };
436
- return {
437
- url: data?.url ?? "",
438
- title: data?.title ?? "",
439
- excerpt: (data?.excerpt ?? "").slice(0, 240),
440
- };
441
- } catch {
442
- logger.warn("search result data unavailable; skipping result");
443
- return null;
493
+ options?.signal?.throwIfAborted();
494
+ if (!pagefind) return fallback("INDEX_UNAVAILABLE");
495
+ try {
496
+ const result = await pagefind.search(keyword);
497
+ if (!result || !Array.isArray(result.results)) return fallback("SEARCH_FAILED");
498
+ const posts: Record<string, unknown>[] = [...metadataMatches];
499
+ const seen = new Set(metadataMatches.map((post) => post.abbrlink));
500
+ for (
501
+ let offset = 0;
502
+ offset < result.results.length && posts.length < limit;
503
+ offset += MAX_SEARCH_RESULTS
504
+ ) {
505
+ options?.signal?.throwIfAborted();
506
+ const batch = await Promise.all(
507
+ result.results
508
+ .slice(offset, offset + MAX_SEARCH_RESULTS)
509
+ .map(async (entry) => {
510
+ try {
511
+ return await entry.data();
512
+ } catch {
513
+ return null;
514
+ }
515
+ }),
516
+ );
517
+ for (const data of batch) {
518
+ if (!data) continue;
519
+ const id = postIdFromUrl(data.url);
520
+ const post = id ? findById(list, id) : undefined;
521
+ if (!post || seen.has(post.abbrlink)) continue;
522
+ seen.add(post.abbrlink);
523
+ posts.push({
524
+ abbrlink: post.abbrlink,
525
+ url: new URL(data.url, location.origin).href,
526
+ title:
527
+ typeof data.meta?.title === "string" && data.meta.title
528
+ ? data.meta.title
529
+ : post.title,
530
+ excerpt: (data.plain_excerpt ?? post.desc ?? "").slice(0, 240),
531
+ });
532
+ if (posts.length >= limit) break;
444
533
  }
445
- }),
446
- );
447
- const posts = items.filter((x): x is NonNullable<typeof x> => x !== null);
448
- return { ok: true, keyword, count: posts.length, posts };
534
+ }
535
+ return {
536
+ ok: true,
537
+ keyword,
538
+ count: posts.length,
539
+ posts,
540
+ source: "pagefind+metadata",
541
+ fullText: true,
542
+ };
543
+ } catch {
544
+ if (options?.signal?.aborted) throw options.signal.reason;
545
+ return fallback("SEARCH_FAILED");
546
+ }
449
547
  },
450
548
  };
451
549
  }
@@ -453,19 +551,32 @@ function searchPostsTool(): WebMCPTool {
453
551
  function readPostTool(): WebMCPTool {
454
552
  return {
455
553
  name: "stalux_read_post",
456
- title: pick("读取文章 Markdown", "Read post Markdown"),
554
+ title: pick("读取文章内容", "Read post content"),
457
555
  description: pick(
458
- "按 abbrlink(文章永久链接 ID)读取一篇博客文章的 Markdown 全文,包含规范化 frontmatter、正文与版权脚注。" +
459
- "需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
556
+ "按 abbrlink 读取文章。开启 Markdown 导出时返回规范化 Markdown,否则从 Pagefind 返回已索引纯文本(不含 Markdown 语法)。" +
557
+ "format 标明格式;truncated 为 true 时用 nextOffset 续读,直到 nextOffset 为 null。只要元信息用 stalux_get_post(更便宜)。" +
460
558
  "获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
461
- "Read the full Markdown of a post by abbrlink (permanent link ID), including normalized frontmatter, body and copyright footer. " +
462
- "Use it for full content, quotes or summarization; for metadata only, stalux_get_post is cheaper. " +
559
+ "Read a post by abbrlink: normalized Markdown when exports are enabled, otherwise indexed plain text from Pagefind without Markdown syntax. " +
560
+ "Check format; when truncated, continue with nextOffset until null. For metadata only, stalux_get_post is cheaper. " +
463
561
  "To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
464
562
  ),
465
563
  annotations: { readOnlyHint: true, untrustedContentHint: false },
466
564
  inputSchema: {
467
565
  type: "object",
468
566
  properties: {
567
+ offset: {
568
+ type: "integer",
569
+ minimum: 0,
570
+ default: 0,
571
+ description: "UTF-16 content offset; continue with nextOffset",
572
+ },
573
+ maxChars: {
574
+ type: "integer",
575
+ minimum: 1,
576
+ maximum: 50000,
577
+ default: 50000,
578
+ description: "Maximum UTF-16 units returned; a surrogate pair is never split",
579
+ },
469
580
  id: {
470
581
  type: "string",
471
582
  description: pick(
@@ -477,22 +588,122 @@ function readPostTool(): WebMCPTool {
477
588
  required: ["id"],
478
589
  additionalProperties: false,
479
590
  },
480
- execute: async (input) => {
481
- const id = String(input.id ?? "").trim();
482
- if (!id)
483
- return err("BAD_INPUT", pick("请提供文章 abbrlink", "Provide a post abbrlink"));
484
- const url = `/posts/${encodeURIComponent(id)}.md`;
485
- const r = await fetch(url, { headers: { Accept: "text/markdown" } });
486
- if (r.status === 404)
487
- return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
488
- if (!r.ok) {
591
+ execute: async (input, options) => {
592
+ const id = typeof input.id === "string" ? input.id.trim() : "";
593
+ const offset = integerInput(input.offset, 0, 0);
594
+ const maxChars = integerInput(input.maxChars, 50000, 1, 50000);
595
+ if (!id || id.length > 512 || offset === undefined || maxChars === undefined)
489
596
  return err(
490
- "FETCH_FAILED",
491
- pick("无法获取文章 Markdown:", "Failed to fetch post Markdown: ") + url,
597
+ "BAD_INPUT",
598
+ pick(
599
+ "请提供有效的文章 ID 和读取范围",
600
+ "Provide a valid post ID and content range",
601
+ ),
492
602
  );
603
+ const list = await loadPostsMeta();
604
+ if (!list)
605
+ return err("INDEX_UNAVAILABLE", pick("文章索引不可用", "Post index unavailable"));
606
+ const post = findById(list, id);
607
+ if (!post) return err("NOT_FOUND", pick("未找到文章: ", "Post not found: ") + id);
608
+ const url = new URL(sitePath(`posts/${encodeURIComponent(id)}.md`), location.origin)
609
+ .href;
610
+ let content: string | undefined;
611
+ let format = "text/markdown";
612
+ let source = url;
613
+ if (document.body?.dataset.staluxMarkdownExport !== "false") {
614
+ try {
615
+ const signal = options?.signal
616
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(15000)])
617
+ : AbortSignal.timeout(15000);
618
+ const response = await fetch(url, {
619
+ headers: { Accept: "text/markdown" },
620
+ signal,
621
+ });
622
+ if (response.ok && !response.headers.get("Content-Type")?.includes("text/html"))
623
+ content = await response.text();
624
+ } catch {
625
+ if (options?.signal?.aborted) throw options.signal.reason;
626
+ }
493
627
  }
494
- const markdown = await r.text();
495
- return { ok: true, url, markdown: markdown.slice(0, 50000) };
628
+ if (content === undefined) {
629
+ const pagefind = await loadPagefind();
630
+ if (!pagefind)
631
+ return err(
632
+ "CONTENT_UNAVAILABLE",
633
+ pick(
634
+ "Markdown 和静态搜索正文均不可用",
635
+ "Neither Markdown nor indexed content is available",
636
+ ),
637
+ );
638
+ try {
639
+ const result = await pagefind.search(null, { filters: { post: id } });
640
+ for (const entry of result.results) {
641
+ const data = await entry.data();
642
+ if (postIdFromUrl(data.url) === id && typeof data.content === "string") {
643
+ content = data.content;
644
+ format = "text/plain";
645
+ source = new URL(data.url, location.origin).href;
646
+ break;
647
+ }
648
+ }
649
+ } catch {
650
+ return err(
651
+ "CONTENT_UNAVAILABLE",
652
+ pick(
653
+ "读取静态搜索正文失败,请重试",
654
+ "Unable to read indexed content; retry",
655
+ ),
656
+ );
657
+ }
658
+ }
659
+ if (content === undefined)
660
+ return err(
661
+ "CONTENT_UNAVAILABLE",
662
+ pick(
663
+ "该文章没有可读取的静态正文",
664
+ "No static content is available for this post",
665
+ ),
666
+ );
667
+ if (
668
+ offset > content.length ||
669
+ (offset > 0 &&
670
+ /[\uDC00-\uDFFF]/u.test(content.charAt(offset)) &&
671
+ /[\uD800-\uDBFF]/u.test(content.charAt(offset - 1)))
672
+ )
673
+ return err(
674
+ "BAD_INPUT",
675
+ pick(
676
+ "读取偏移超出正文范围或切开 Unicode 字符",
677
+ "Offset is outside the content or splits a Unicode character",
678
+ ),
679
+ );
680
+ let end = Math.min(content.length, offset + maxChars);
681
+ if (
682
+ end < content.length &&
683
+ /[\uD800-\uDBFF]/u.test(content.charAt(end - 1)) &&
684
+ /[\uDC00-\uDFFF]/u.test(content.charAt(end))
685
+ )
686
+ end--;
687
+ if (end === offset && end < content.length)
688
+ return err(
689
+ "BAD_INPUT",
690
+ pick(
691
+ "maxChars 太小,无法容纳当前 Unicode 字符",
692
+ "maxChars is too small for this Unicode character",
693
+ ),
694
+ );
695
+ const chunk = content.slice(offset, end);
696
+ return {
697
+ ok: true,
698
+ abbrlink: id,
699
+ url: source,
700
+ format,
701
+ ...(format === "text/markdown" ? { markdown: chunk } : { content: chunk }),
702
+ offset,
703
+ totalChars: content.length,
704
+ truncated: end < content.length,
705
+ nextOffset: end < content.length ? end : null,
706
+ };
496
707
  },
497
708
  };
498
709
  }
@@ -525,15 +736,17 @@ function siteInfoTool(): WebMCPTool {
525
736
  url: info.url ?? "",
526
737
  description: info.description ?? "",
527
738
  lang: info.lang ?? "",
528
- llms: `${info.url ?? ""}/llms.txt`,
529
- llmsFull: `${info.url ?? ""}/llms-full.txt`,
739
+ llms: new URL(sitePath("llms.txt"), location.origin).href,
740
+ llmsFull: new URL(sitePath("llms-full.txt"), location.origin).href,
741
+ markdownExport: document.body?.dataset.staluxMarkdownExport === "true",
742
+ transport: "WebMCP",
530
743
  };
531
744
  },
532
745
  };
533
746
  }
534
747
 
535
748
  function buildTools(): WebMCPTool[] {
536
- return [
749
+ const tools: WebMCPTool[] = [
537
750
  listPostsTool(),
538
751
  getPostTool(),
539
752
  currentPostTool(),
@@ -542,16 +755,46 @@ function buildTools(): WebMCPTool[] {
542
755
  readPostTool(),
543
756
  siteInfoTool(),
544
757
  ];
758
+ return tools.map((tool) => {
759
+ const execute = tool.execute;
760
+ return {
761
+ ...tool,
762
+ execute: async (input: Record<string, unknown>, options?: { signal?: AbortSignal }) => {
763
+ if (!input || typeof input !== "object" || Array.isArray(input))
764
+ return err("BAD_INPUT", pick("参数必须是对象", "Input must be an object"));
765
+ const properties =
766
+ (tool.inputSchema as { properties?: Record<string, unknown> })?.properties ??
767
+ {};
768
+ if (Object.keys(input).some((key) => !Object.hasOwn(properties, key)))
769
+ return err("BAD_INPUT", pick("包含未知参数", "Unknown input property"));
770
+ if (options?.signal?.aborted) throw options.signal.reason;
771
+ try {
772
+ const result = await execute(input, options);
773
+ options?.signal?.throwIfAborted();
774
+ return result;
775
+ } catch (error) {
776
+ if (options?.signal?.aborted) throw options.signal.reason;
777
+ logger.error("tool execution failed", error);
778
+ return err(
779
+ "FETCH_FAILED",
780
+ pick(
781
+ "读取静态博客资源失败,请重试",
782
+ "Unable to read static blog resources; retry",
783
+ ),
784
+ );
785
+ }
786
+ },
787
+ };
788
+ });
545
789
  }
546
790
 
547
791
  // ---------------------------------------------------------------------------
548
792
  // 注册逻辑
549
793
  // ---------------------------------------------------------------------------
550
794
 
551
- /** 当前注册批次使用的 AbortController,软导航时先 abort 注销再重注册 */
552
- let activeController: AbortController | null = null;
553
-
554
- async function registerTools(): Promise<{ registered: string[]; reason?: string }> {
795
+ async function registerTools(
796
+ signal: AbortSignal,
797
+ ): Promise<{ registered: string[]; reason?: string }> {
555
798
  const doc = document as DocumentWithModelContext;
556
799
  const ctx = doc.modelContext;
557
800
  if (!ctx || typeof ctx.registerTool !== "function") {
@@ -559,25 +802,28 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
559
802
  return { registered: [], reason: "unsupported" };
560
803
  }
561
804
 
562
- // 注销上一轮注册(View Transitions 软导航后 modelContext 可能已重置)
563
- if (activeController) {
564
- try {
565
- activeController.abort();
566
- } catch {
567
- logger.debug("previous registration cleanup unavailable");
568
- }
569
- }
570
- const controller = new AbortController();
571
- activeController = controller;
572
-
573
805
  const tools = buildTools();
574
806
  const registered: string[] = [];
575
807
  // 逐个注册:单个失败不中断其余工具
576
808
  for (const tool of tools) {
809
+ if (signal.aborted) break;
577
810
  try {
578
- await ctx.registerTool(tool, { signal: controller.signal });
811
+ await ctx.registerTool(
812
+ {
813
+ ...tool,
814
+ execute: (input, executionOptions) =>
815
+ tool.execute(input, {
816
+ signal: executionOptions?.signal
817
+ ? AbortSignal.any([signal, executionOptions.signal])
818
+ : signal,
819
+ }),
820
+ },
821
+ { signal },
822
+ );
823
+ if (signal.aborted) break;
579
824
  registered.push(tool.name);
580
825
  } catch (e) {
826
+ if (signal.aborted) break;
581
827
  logger.error(`tool registration failed: ${tool.name}`, e);
582
828
  }
583
829
  }
@@ -588,18 +834,21 @@ async function registerTools(): Promise<{ registered: string[]; reason?: string
588
834
  // 挂载与自动注册
589
835
  // ---------------------------------------------------------------------------
590
836
 
591
- if (typeof document !== "undefined") {
592
- // 首屏加载 + View Transitions 软导航都执行(soft navigation 时页面状态会重建)
593
- document.addEventListener("astro:page-load", () => {
594
- void registerTools()
595
- .then((r) => {
596
- if (r.reason === "unsupported") return; // 无 WebMCP 环境,静默跳过
597
- if (r.registered.length) {
598
- logger.debug(
599
- `已注册 ${r.registered.length} 个工具: ${r.registered.join(", ")}`,
600
- );
601
- }
602
- })
603
- .catch((error: unknown) => logger.error("registration batch failed", error));
604
- });
605
- }
837
+ registerPageLifecycle("webmcp", () => {
838
+ // Astro can hoist processed scripts from conditional template branches.
839
+ if (document.body?.dataset.staluxWebmcpEnabled === "false") return;
840
+ initializeWebMCPPolyfill();
841
+ const controller = new AbortController();
842
+ void registerTools(controller.signal)
843
+ .then((r) => {
844
+ if (controller.signal.aborted || r.reason === "unsupported") return;
845
+ if (r.registered.length) {
846
+ logger.debug(`已注册 ${r.registered.length} 个工具: ${r.registered.join(", ")}`);
847
+ }
848
+ })
849
+ .catch((error: unknown) => {
850
+ if (!controller.signal.aborted) logger.error("registration batch failed", error);
851
+ });
852
+ // Abort also unregisters tools already installed by this page's batch.
853
+ return () => controller.abort();
854
+ });