sbuilder-mcp 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ All notable changes to this project are documented in this file.
6
6
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.35.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - The catalog now carries five page-history operations that reached the platform's own browser and swagger.json but had never carried an `@Router` annotation, so they were invisible to this server: `GET`/`POST .../pages/{pageId}/versions` (list and label a snapshot of the current draft), `POST .../pages/{pageId}/versions/{versionId}/restore`, `GET .../pages/{pageId}/history` (the autosave checkpoints written on every draft save), and `POST .../pages/{pageId}/history/{historyId}/restore`.
13
+
14
+ ### Changed
15
+ - sb_undo's description now points to this page history and restore surface as the better way to recover a page, since it survives process exit while sb_undo's own undo stack is in-process, capped, and gone on exit; it no longer claims the platform has no page history at all.
16
+
17
+ ## [0.34.0] - 2026-09-11
18
+
19
+ ### Added
20
+ - sb_store's new `action: "chrome"` builds one shared header (or, with `footer: true`, a shared footer) from the pages a site already has, closing the `siteChrome` gap sb_review has reported since it learned to ask: the menu labels and links come from the site's own pages (home first), the look is read off the home page, and the master is given to every page as a reference instead of copied onto each one. It skips a site that already shares a header or footer, and a site with fewer than two pages. dry_run (the default) returns the planned menu and which pages it would touch without writing anything.
21
+
22
+ ### Fixed
23
+ - The menu built for a shared header or footer (by sb_store's new `action: "chrome"` and by sb_import_site, which already built one) now packs its links against each other at their own width instead of splitting the row into equal-width columns, so three links no longer land spread across the thirds of a wide header with large gaps between them.
24
+
9
25
  ## [0.33.0] - 2026-09-11
10
26
 
11
27
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,22 @@ Mọi thay đổi đáng chú ý của dự án được ghi lại trong file n
6
6
  Định dạng dựa trên [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
7
7
  và dự án tuân theo [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## [0.35.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - Catalog giờ mang theo năm operation lịch sử trang từng có mặt trên trình duyệt của nền tảng và trong swagger.json nhưng chưa từng được gắn annotation `@Router`, nên trước đây server này không thể thấy chúng: `GET`/`POST .../pages/{pageId}/versions` (liệt kê và gắn nhãn một bản chụp của bản nháp hiện tại), `POST .../pages/{pageId}/versions/{versionId}/restore`, `GET .../pages/{pageId}/history` (các checkpoint autosave được ghi ở mỗi lần lưu bản nháp), và `POST .../pages/{pageId}/history/{historyId}/restore`.
13
+
14
+ ### Changed
15
+ - Mô tả của sb_undo giờ trỏ tới bề mặt lịch sử và khôi phục trang này như cách tốt hơn để phục hồi một trang, vì nó tồn tại qua cả khi tiến trình thoát, trong khi ngăn xếp undo của chính sb_undo chỉ nằm trong tiến trình, có giới hạn, và mất đi khi thoát; nó không còn khẳng định nền tảng hoàn toàn không có lịch sử trang.
16
+
17
+ ## [0.34.0] - 2026-09-11
18
+
19
+ ### Added
20
+ - Hành động mới `action: "chrome"` của sb_store dựng một header dùng chung (hoặc, với `footer: true`, một footer dùng chung) từ các trang site đã có sẵn, khép lại khoảng trống `siteChrome` mà sb_review đã báo cáo kể từ khi biết hỏi câu này: nhãn và liên kết của menu lấy từ chính các trang của site (trang chủ trước tiên), giao diện được đọc từ trang chủ, và master được gán cho mọi trang dưới dạng tham chiếu thay vì sao chép riêng vào từng trang. Lệnh này bỏ qua nếu site đã có sẵn header hoặc footer dùng chung, và nếu site có ít hơn hai trang. dry_run (mặc định bật) trả về menu dự kiến và các trang sẽ bị tác động mà không ghi gì cả.
21
+
22
+ ### Fixed
23
+ - Menu được dựng cho header hoặc footer dùng chung (bởi `action: "chrome"` mới của sb_store, và bởi sb_import_site — vốn đã dựng sẵn menu này) giờ xếp các liên kết sát nhau theo đúng chiều rộng nội dung của chúng thay vì chia hàng thành các cột bằng nhau, nên ba liên kết không còn bị dàn trải ra ba phần bằng nhau của một header rộng với khoảng trống lớn giữa chúng.
24
+
9
25
  ## [0.33.0] - 2026-09-11
10
26
 
11
27
  ### Added
@@ -1,8 +1,8 @@
1
1
  export const SWAGGER_SOURCE = {
2
- "operations": 501,
2
+ "operations": 506,
3
3
  "definitions": 105,
4
- "bodyCarrying": 179,
5
- "bodyUndescribed": 64,
4
+ "bodyCarrying": 182,
5
+ "bodyUndescribed": 67,
6
6
  "generatedFrom": "server/docs/swagger.json"
7
7
  };
8
8
  export const API_OPERATIONS = [
@@ -9532,6 +9532,113 @@ export const API_OPERATIONS = [
9532
9532
  "bodyRef": null,
9533
9533
  "credential": "siteScoped"
9534
9534
  },
9535
+ {
9536
+ "id": "get:/api/sites/{siteId}/pages/{pageId}/history",
9537
+ "method": "GET",
9538
+ "path": "/api/sites/{siteId}/pages/{pageId}/history",
9539
+ "tags": [
9540
+ "pages",
9541
+ "pages"
9542
+ ],
9543
+ "summary": "Restore a page's draft from an autosave checkpoint",
9544
+ "params": [
9545
+ {
9546
+ "name": "siteId",
9547
+ "in": "path",
9548
+ "required": true,
9549
+ "type": "string",
9550
+ "description": "Site ID"
9551
+ },
9552
+ {
9553
+ "name": "pageId",
9554
+ "in": "path",
9555
+ "required": true,
9556
+ "type": "string",
9557
+ "description": "Page ID"
9558
+ },
9559
+ {
9560
+ "name": "limit",
9561
+ "in": "query",
9562
+ "required": false,
9563
+ "type": "integer",
9564
+ "description": "1-… , default 50"
9565
+ },
9566
+ {
9567
+ "name": "siteId",
9568
+ "in": "path",
9569
+ "required": true,
9570
+ "type": "string",
9571
+ "description": "Site ID"
9572
+ },
9573
+ {
9574
+ "name": "pageId",
9575
+ "in": "path",
9576
+ "required": true,
9577
+ "type": "string",
9578
+ "description": "Page ID"
9579
+ }
9580
+ ],
9581
+ "bodyDescribed": false,
9582
+ "bodyRef": null,
9583
+ "credential": "siteScoped"
9584
+ },
9585
+ {
9586
+ "id": "post:/api/sites/{siteId}/pages/{pageId}/history/{historyId}/restore",
9587
+ "method": "POST",
9588
+ "path": "/api/sites/{siteId}/pages/{pageId}/history/{historyId}/restore",
9589
+ "tags": [
9590
+ "pages",
9591
+ "pages"
9592
+ ],
9593
+ "summary": "Restore a page's draft from an autosave checkpoint",
9594
+ "params": [
9595
+ {
9596
+ "name": "siteId",
9597
+ "in": "path",
9598
+ "required": true,
9599
+ "type": "string",
9600
+ "description": "Site ID"
9601
+ },
9602
+ {
9603
+ "name": "pageId",
9604
+ "in": "path",
9605
+ "required": true,
9606
+ "type": "string",
9607
+ "description": "Page ID"
9608
+ },
9609
+ {
9610
+ "name": "limit",
9611
+ "in": "query",
9612
+ "required": false,
9613
+ "type": "integer",
9614
+ "description": "1-… , default 50"
9615
+ },
9616
+ {
9617
+ "name": "siteId",
9618
+ "in": "path",
9619
+ "required": true,
9620
+ "type": "string",
9621
+ "description": "Site ID"
9622
+ },
9623
+ {
9624
+ "name": "pageId",
9625
+ "in": "path",
9626
+ "required": true,
9627
+ "type": "string",
9628
+ "description": "Page ID"
9629
+ },
9630
+ {
9631
+ "name": "historyId",
9632
+ "in": "path",
9633
+ "required": true,
9634
+ "type": "string",
9635
+ "description": "History ID from the list"
9636
+ }
9637
+ ],
9638
+ "bodyDescribed": false,
9639
+ "bodyRef": null,
9640
+ "credential": "siteScoped"
9641
+ },
9535
9642
  {
9536
9643
  "id": "get:/api/sites/{siteId}/pages/{pageId}/preview",
9537
9644
  "method": "GET",
@@ -9616,6 +9723,250 @@ export const API_OPERATIONS = [
9616
9723
  "bodyRef": null,
9617
9724
  "credential": "siteScoped"
9618
9725
  },
9726
+ {
9727
+ "id": "get:/api/sites/{siteId}/pages/{pageId}/versions",
9728
+ "method": "GET",
9729
+ "path": "/api/sites/{siteId}/pages/{pageId}/versions",
9730
+ "tags": [
9731
+ "pages",
9732
+ "pages",
9733
+ "pages"
9734
+ ],
9735
+ "summary": "Restore a page's draft from a version snapshot",
9736
+ "params": [
9737
+ {
9738
+ "name": "siteId",
9739
+ "in": "path",
9740
+ "required": true,
9741
+ "type": "string",
9742
+ "description": "Site ID"
9743
+ },
9744
+ {
9745
+ "name": "pageId",
9746
+ "in": "path",
9747
+ "required": true,
9748
+ "type": "string",
9749
+ "description": "Page ID"
9750
+ },
9751
+ {
9752
+ "name": "limit",
9753
+ "in": "query",
9754
+ "required": false,
9755
+ "type": "integer",
9756
+ "description": "1-… , default 50"
9757
+ },
9758
+ {
9759
+ "name": "offset",
9760
+ "in": "query",
9761
+ "required": false,
9762
+ "type": "integer",
9763
+ "description": "default 0"
9764
+ },
9765
+ {
9766
+ "name": "siteId",
9767
+ "in": "path",
9768
+ "required": true,
9769
+ "type": "string",
9770
+ "description": "Site ID"
9771
+ },
9772
+ {
9773
+ "name": "pageId",
9774
+ "in": "path",
9775
+ "required": true,
9776
+ "type": "string",
9777
+ "description": "Page ID"
9778
+ },
9779
+ {
9780
+ "name": "body",
9781
+ "in": "body",
9782
+ "required": true,
9783
+ "type": "object",
9784
+ "description": "{label, document?}"
9785
+ },
9786
+ {
9787
+ "name": "siteId",
9788
+ "in": "path",
9789
+ "required": true,
9790
+ "type": "string",
9791
+ "description": "Site ID"
9792
+ },
9793
+ {
9794
+ "name": "pageId",
9795
+ "in": "path",
9796
+ "required": true,
9797
+ "type": "string",
9798
+ "description": "Page ID"
9799
+ }
9800
+ ],
9801
+ "bodyDescribed": false,
9802
+ "bodyRef": null,
9803
+ "credential": "siteScoped"
9804
+ },
9805
+ {
9806
+ "id": "post:/api/sites/{siteId}/pages/{pageId}/versions",
9807
+ "method": "POST",
9808
+ "path": "/api/sites/{siteId}/pages/{pageId}/versions",
9809
+ "tags": [
9810
+ "pages",
9811
+ "pages",
9812
+ "pages"
9813
+ ],
9814
+ "summary": "Restore a page's draft from a version snapshot",
9815
+ "params": [
9816
+ {
9817
+ "name": "siteId",
9818
+ "in": "path",
9819
+ "required": true,
9820
+ "type": "string",
9821
+ "description": "Site ID"
9822
+ },
9823
+ {
9824
+ "name": "pageId",
9825
+ "in": "path",
9826
+ "required": true,
9827
+ "type": "string",
9828
+ "description": "Page ID"
9829
+ },
9830
+ {
9831
+ "name": "limit",
9832
+ "in": "query",
9833
+ "required": false,
9834
+ "type": "integer",
9835
+ "description": "1-… , default 50"
9836
+ },
9837
+ {
9838
+ "name": "offset",
9839
+ "in": "query",
9840
+ "required": false,
9841
+ "type": "integer",
9842
+ "description": "default 0"
9843
+ },
9844
+ {
9845
+ "name": "siteId",
9846
+ "in": "path",
9847
+ "required": true,
9848
+ "type": "string",
9849
+ "description": "Site ID"
9850
+ },
9851
+ {
9852
+ "name": "pageId",
9853
+ "in": "path",
9854
+ "required": true,
9855
+ "type": "string",
9856
+ "description": "Page ID"
9857
+ },
9858
+ {
9859
+ "name": "body",
9860
+ "in": "body",
9861
+ "required": true,
9862
+ "type": "object",
9863
+ "description": "{label, document?}"
9864
+ },
9865
+ {
9866
+ "name": "siteId",
9867
+ "in": "path",
9868
+ "required": true,
9869
+ "type": "string",
9870
+ "description": "Site ID"
9871
+ },
9872
+ {
9873
+ "name": "pageId",
9874
+ "in": "path",
9875
+ "required": true,
9876
+ "type": "string",
9877
+ "description": "Page ID"
9878
+ }
9879
+ ],
9880
+ "bodyDescribed": false,
9881
+ "bodyRef": null,
9882
+ "credential": "siteScoped"
9883
+ },
9884
+ {
9885
+ "id": "post:/api/sites/{siteId}/pages/{pageId}/versions/{versionId}/restore",
9886
+ "method": "POST",
9887
+ "path": "/api/sites/{siteId}/pages/{pageId}/versions/{versionId}/restore",
9888
+ "tags": [
9889
+ "pages",
9890
+ "pages",
9891
+ "pages"
9892
+ ],
9893
+ "summary": "Restore a page's draft from a version snapshot",
9894
+ "params": [
9895
+ {
9896
+ "name": "siteId",
9897
+ "in": "path",
9898
+ "required": true,
9899
+ "type": "string",
9900
+ "description": "Site ID"
9901
+ },
9902
+ {
9903
+ "name": "pageId",
9904
+ "in": "path",
9905
+ "required": true,
9906
+ "type": "string",
9907
+ "description": "Page ID"
9908
+ },
9909
+ {
9910
+ "name": "limit",
9911
+ "in": "query",
9912
+ "required": false,
9913
+ "type": "integer",
9914
+ "description": "1-… , default 50"
9915
+ },
9916
+ {
9917
+ "name": "offset",
9918
+ "in": "query",
9919
+ "required": false,
9920
+ "type": "integer",
9921
+ "description": "default 0"
9922
+ },
9923
+ {
9924
+ "name": "siteId",
9925
+ "in": "path",
9926
+ "required": true,
9927
+ "type": "string",
9928
+ "description": "Site ID"
9929
+ },
9930
+ {
9931
+ "name": "pageId",
9932
+ "in": "path",
9933
+ "required": true,
9934
+ "type": "string",
9935
+ "description": "Page ID"
9936
+ },
9937
+ {
9938
+ "name": "body",
9939
+ "in": "body",
9940
+ "required": true,
9941
+ "type": "object",
9942
+ "description": "{label, document?}"
9943
+ },
9944
+ {
9945
+ "name": "siteId",
9946
+ "in": "path",
9947
+ "required": true,
9948
+ "type": "string",
9949
+ "description": "Site ID"
9950
+ },
9951
+ {
9952
+ "name": "pageId",
9953
+ "in": "path",
9954
+ "required": true,
9955
+ "type": "string",
9956
+ "description": "Page ID"
9957
+ },
9958
+ {
9959
+ "name": "versionId",
9960
+ "in": "path",
9961
+ "required": true,
9962
+ "type": "string",
9963
+ "description": "Version ID from the list"
9964
+ }
9965
+ ],
9966
+ "bodyDescribed": false,
9967
+ "bodyRef": null,
9968
+ "credential": "siteScoped"
9969
+ },
9619
9970
  {
9620
9971
  "id": "get:/api/sites/{siteId}/payment-gateways",
9621
9972
  "method": "GET",
@@ -1,5 +1,5 @@
1
1
  export const SHAPE_SOURCE = {
2
- "writeOperations": 219,
2
+ "writeOperations": 222,
3
3
  "shaped": 169,
4
4
  "fromHandlers": 169,
5
5
  "fromSwaggerOnly": 0,
@@ -423,6 +423,35 @@ function one(c, t) {
423
423
  // Mobile gets one column by name. The flex path's `flexDirection: column`
424
424
  // says nothing to a grid, and a mobile answer that silently does nothing
425
425
  // is rule 3 failing with a value in the document to prove it tried.
426
+ // A PACKED ROW IS NOT A GRID AND NOT AN EQUAL-SHARE ROW. It wraps, so it
427
+ // needs no stack breakpoint of its own — a menu that runs out of width
428
+ // starts a second line, which is what a menu should do.
429
+ if (c.pack) {
430
+ return {
431
+ type: 'flex-block',
432
+ style: {
433
+ width: '100%',
434
+ display: 'flex',
435
+ flexDirection: 'row',
436
+ flexWrap: 'wrap',
437
+ alignItems: 'center',
438
+ gap: '24px',
439
+ ...(c.align === 'center' ? { justifyContent: 'center' } : {}),
440
+ },
441
+ // `width: auto` IS THE LOAD-BEARING HALF, and leaving it out looked
442
+ // like the whole idea had failed. `flex-block` seeds `width: 100%`
443
+ // from its element defaults, and `flex: 0 0 auto` only says "do not
444
+ // grow or shrink from the BASIS" — the basis being `auto`, which
445
+ // reads the width. So every packed cell stayed 100% wide and the
446
+ // menu came out as a vertical list: measured, three links stacked in
447
+ // a 140px header where the equal-share version had been 52.
448
+ children: kids.map((k) => ({
449
+ type: 'flex-block',
450
+ style: { flex: '0 0 auto', width: 'auto', display: 'flex', flexDirection: 'column' },
451
+ children: [k],
452
+ })),
453
+ };
454
+ }
426
455
  if (c.wrap) {
427
456
  return {
428
457
  type: 'flex-block',
@@ -654,7 +683,8 @@ export function navSpec(links, t) {
654
683
  {
655
684
  kind: 'group',
656
685
  direction: 'row',
657
- wrap: true,
686
+ // PACKED, not an equal share: a menu's links sit against each other.
687
+ pack: true,
658
688
  children: links.map((l) => ({
659
689
  kind: 'button',
660
690
  variant: 'link',
package/dist/server.js CHANGED
@@ -70,7 +70,7 @@ export function createServer(ctx = buildContext()) {
70
70
  registerApiTools(server, ctx);
71
71
  const pageSession = registerPageTools(server, ctx);
72
72
  registerLiveTools(server, ctx, pageSession);
73
- registerStoreTools(server, ctx);
73
+ registerStoreTools(server, ctx, pageSession);
74
74
  registerThemeTools(server, ctx);
75
75
  registerImportTools(server, ctx, pageSession);
76
76
  registerUndoTools(server, ctx);
@@ -0,0 +1,93 @@
1
+ import { request } from '../transport/http.js';
2
+ import { siteToken } from './credentialpick.js';
3
+ import { siteFor } from './context.js';
4
+ import { addSubtree } from '../domains/site/builder.js';
5
+ import { menuLabel, navSpec, tokensFromPage } from '../domains/site/importmap.js';
6
+ import { globalDocumentFrom } from './importpage.js';
7
+ /** Does this site already share a section of this kind? */
8
+ export async function hasGlobal(ctx, siteId, kind) {
9
+ const got = (await request({
10
+ base: ctx.base,
11
+ method: 'GET',
12
+ path: `/api/sites/${encodeURIComponent(siteId)}/global-sections`,
13
+ token: siteToken(ctx),
14
+ fetchImpl: ctx.fetchImpl,
15
+ }));
16
+ return (got.globalSections ?? []).some((g) => g.kind === kind);
17
+ }
18
+ /**
19
+ * Create ONE global section from a link list and give every named page a
20
+ * reference to it.
21
+ *
22
+ * NOT ATOMIC, and it must not pretend to be: one page that will not take the
23
+ * header does not undo the header. The master exists, the others carry it, and
24
+ * the refusal is reported per page — the same shape `sb_import_site` uses for
25
+ * its own per-page report.
26
+ */
27
+ export async function shareChrome(ctx, session, siteId, kind, links, pages, tokens) {
28
+ const out = { carried: [], failed: [] };
29
+ const spec = navSpec(links, tokens);
30
+ if (!spec) {
31
+ out.skipped = 'no links to put in it';
32
+ return out;
33
+ }
34
+ const made = (await request({
35
+ base: ctx.base,
36
+ method: 'POST',
37
+ path: `/api/sites/${encodeURIComponent(siteId)}/global-sections`,
38
+ token: siteToken(ctx),
39
+ body: {
40
+ name: kind === 'header' ? 'Header' : 'Footer',
41
+ kind,
42
+ document: globalDocumentFrom(spec),
43
+ },
44
+ fetchImpl: ctx.fetchImpl,
45
+ }));
46
+ const gid = made.globalSection?.id;
47
+ if (typeof gid !== 'string' || !gid) {
48
+ out.skipped = 'the platform created no global section';
49
+ return out;
50
+ }
51
+ out.created = gid;
52
+ for (const p of pages) {
53
+ try {
54
+ await session.open(siteId, p.id);
55
+ const doc = session.current();
56
+ // A HEADER GOES IN FIRST AND A FOOTER LAST. ROOT's children must read
57
+ // [header*][middle*][footer*] and the platform refuses EVERY save
58
+ // otherwise — compose turns the reference into a real band, so its index
59
+ // is the band it becomes.
60
+ const at = kind === 'header' ? 0 : doc.doc.nodes[doc.doc.root_node_id]?.data?.nodes?.length ?? 0;
61
+ const { patches } = addSubtree(doc, doc.doc.root_node_id, { type: 'flex-section', specials: { globalRef: gid, globalKind: kind } }, at);
62
+ await session.applyAndSave(patches);
63
+ out.carried.push(p.slug);
64
+ }
65
+ catch (e) {
66
+ out.failed.push({ page: p.slug, why: e.message.replace(/^sbuilder:\s*/, '').slice(0, 160) });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+ /** Every page this site has, as the menu would name them. */
72
+ export async function sitePages(ctx, siteId) {
73
+ const got = (await request({
74
+ base: ctx.base,
75
+ method: 'GET',
76
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
77
+ token: siteToken(ctx),
78
+ fetchImpl: ctx.fetchImpl,
79
+ }));
80
+ return (got.pages ?? []).map((p) => ({
81
+ id: String(p.id ?? ''),
82
+ slug: String(p.slug ?? ''),
83
+ name: String(p.name ?? p.title ?? p.slug ?? ''),
84
+ isHome: p.isHomepage === true || p.slug === '',
85
+ }));
86
+ }
87
+ /** The link list a menu built from these pages would carry, home first. */
88
+ export function chromeLinks(pages) {
89
+ return [...pages]
90
+ .sort((a, b) => Number(b.isHome) - Number(a.isHome))
91
+ .map((p) => ({ text: menuLabel(p.name), href: p.isHome ? '/' : `/${p.slug}` }));
92
+ }
93
+ export { siteFor, tokensFromPage };
@@ -33,6 +33,8 @@ import { request, redact } from '../transport/http.js';
33
33
  import { siteToken } from './credentialpick.js';
34
34
  import { siteFor } from './context.js';
35
35
  import { genId } from '../domains/site/ids.js';
36
+ import { chromeLinks, hasGlobal, shareChrome, sitePages } from './chrome.js';
37
+ import { tokensFromPage } from '../domains/site/importmap.js';
36
38
  import { CHECKOUT_FORM, CHECKOUT_FORM_DOCUMENT, CHECKOUT_PAGE_DOCUMENT, CHECKOUT_TEXT, FORM_ID_SENTINEL, HEADLINE_SENTINEL, FORM_TEMPLATES, } from '../catalog/checkout.generated.js';
37
39
  /**
38
40
  * The store's live gateways and delivery options.
@@ -274,17 +276,18 @@ async function seedForm(ctx, siteId, key, formName, dryRun) {
274
276
  'what makes a form-document edit visible.',
275
277
  };
276
278
  }
277
- export function registerStoreTools(server, ctx) {
279
+ export function registerStoreTools(server, ctx, session) {
278
280
  server.registerTool('sb_store', {
279
281
  description: 'Run a store flow that must happen in a fixed order. action:"checkout" makes the order ' +
280
282
  'form, configures it, saves its fields with this store\'s real payment and delivery ' +
281
283
  'options, then creates and PUBLISHES the checkout page — /checkout 404s without all ' +
282
284
  'four. action:"form" seeds any of the platform\'s other form templates (login, ' +
283
285
  'register, forgot, reset, verify, contact, subscribe, booking, review and more) with ' +
284
- 'its own field document, which is the part that cannot be guessed. Dry run returns ' +
285
- 'the plan.',
286
+ 'its own field document, which is the part that cannot be guessed. action:"chrome" ' +
287
+ 'gives every page ONE shared header, built from the pages this site already has — the ' +
288
+ 'gap sb_review reports as siteChrome. Dry run returns the plan.',
286
289
  inputSchema: {
287
- action: z.enum(['checkout', 'form']),
290
+ action: z.enum(['checkout', 'form', 'chrome']),
288
291
  site_id: z.string().optional(),
289
292
  language: z.enum(['vi', 'en']).optional().describe('Copy language, default vi'),
290
293
  page_name: z.string().optional(),
@@ -294,11 +297,55 @@ export function registerStoreTools(server, ctx) {
294
297
  .optional()
295
298
  .describe('action:"form" — which of the platform\'s own form templates to seed'),
296
299
  name: z.string().optional().describe('action:"form" — the form\'s name in the merchant\'s list'),
300
+ footer: z
301
+ .boolean()
302
+ .optional()
303
+ .describe('action:"chrome" — build a shared FOOTER instead of a header'),
297
304
  dry_run: z.boolean().optional(),
298
305
  },
299
306
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
300
- }, async ({ action, site_id: given, language, page_name, headline, template, name, dry_run }) => {
307
+ }, async ({ action, site_id: given, language, page_name, headline, template, name, footer, dry_run }) => {
301
308
  const siteId = siteFor(ctx, given);
309
+ if (action === 'chrome') {
310
+ // SKIPPED WHEN THE SITE ALREADY SHARES ONE, because a second header is
311
+ // two headers rather than a menu — and below two pages, because a menu
312
+ // to one page is a link to itself. Both are `sb_import_site`'s own
313
+ // rules, kept because they were right there.
314
+ const kind = footer === true ? 'footer' : 'header';
315
+ if (await hasGlobal(ctx, siteId, kind)) {
316
+ return text({
317
+ skipped: `this site already shares a ${kind} — a second one is two of them, not a menu`,
318
+ });
319
+ }
320
+ const pages = await sitePages(ctx, siteId);
321
+ if (pages.length < 2) {
322
+ return text({ skipped: 'a menu to one page is a link to itself' });
323
+ }
324
+ const links = chromeLinks(pages);
325
+ // RULE 0: the look comes off the HOME page, which is the one page whose
326
+ // pattern the rest of the site already follows. A site with nothing on
327
+ // its home page yields no tokens rather than an invented palette.
328
+ const home = pages.find((p) => p.isHome) ?? pages[0];
329
+ await session.open(siteId, home.id);
330
+ const tokens = tokensFromPage(session.current().doc);
331
+ if (dry_run !== false) {
332
+ return text({
333
+ dry_run: true,
334
+ would_create: kind,
335
+ menu: links,
336
+ onto: pages.map((p) => p.slug),
337
+ tokens_from: Object.keys(tokens).length ? 'the home page' : 'nothing — the home page is blank',
338
+ note: 'Nothing was sent. This creates ONE shared master and gives every page a reference ' +
339
+ 'to it, so the menu becomes one edit instead of one per page.',
340
+ });
341
+ }
342
+ const out = await shareChrome(ctx, session, siteId, kind, links, pages, tokens);
343
+ return text({
344
+ ...out,
345
+ next: 'Publish the pages: a global section reaches a visitor through each page it is ' +
346
+ 'composed onto, so a saved page keeps the old chrome until it is published again.',
347
+ });
348
+ }
302
349
  if (action === 'form') {
303
350
  if (!template) {
304
351
  throw new Error(`sbuilder: action:"form" needs a template. One of: ${FORM_TEMPLATE_KEYS.join(', ')}.`);
@@ -113,8 +113,12 @@ export class UndoLog {
113
113
  */
114
114
  export function registerUndoTools(server, ctx) {
115
115
  server.registerTool('sb_undo', {
116
- description: 'Put back what a PUT through sb_api_call replaced. The platform has no page history ' +
117
- 'or restore, so this is the only way back. No argument lists what is undoable.',
116
+ description: 'Put back what a PUT through sb_api_call replaced — settings, a product, a form, ' +
117
+ 'anything with a shape. IN THIS PROCESS ONLY, capped, and gone when it exits. For a ' +
118
+ 'PAGE the platform keeps its own: GET .../pages/{pageId}/history lists the autosave ' +
119
+ 'checkpoint it writes on every draft save, versions lists the labelled snapshots, and ' +
120
+ 'either restores. That one survives everything and is the better answer whenever the ' +
121
+ 'thing to recover is a page. No argument lists what is undoable here.',
118
122
  inputSchema: {
119
123
  index: z.number().int().min(1).optional().describe('1 is the most recent write'),
120
124
  dry_run: z.boolean().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.33.0",
3
+ "version": "0.35.0",
4
4
  "description": "MCP server that designs and operates a Store Builder site — pages, data, theme and publish — through the platform's own API and live-edit protocol.",
5
5
  "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
6
  "type": "module",