sbuilder-mcp 0.46.3 → 0.47.1

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,24 @@ 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.47.1] - 2026-09-14
10
+
11
+ ### Fixed
12
+ - `sb_page_create` with `is_homepage` set on a site that already has one no longer creates a duplicate that steals the star and leaves the platform's own original home page reachable at no address; it now adopts the existing home page instead, renaming it when the caller named it something else, and reports what it did instead of creating anything.
13
+
14
+ ## [0.47.0] - 2026-09-14
15
+
16
+ ### Added
17
+ - `sb_api_find`'s call sheet now names a `summary_covers` list whenever the platform wrote one sentence for several routes at once, so a call sheet is no longer read as if its summary belongs to that route alone; `sb_api_find` adds a one-time directive pointing at the sharpest case, `/refund` versus `/refund-via-gateway`, where reading the shared sentence would record a refund that never pays anybody.
18
+ - `sb_api_find` and `sb_api_call` no longer inline a request body definition for a GET or DELETE operation whenever the document's declared body is a verbatim copy of a write operation's, naming the route it was copied from instead of describing a body the route cannot take.
19
+ - `sb_api_find` and `sb_api_call` now list every path parameter a route actually needs, even when the document's own `@Param` list omits it, so a caller no longer has to make a failing call first to discover an argument the sheet never mentioned.
20
+ - The catalog's legal-value vocabulary grows to cover 18 more elements read directly off the platform's own declarations, including `popup`'s trigger frequency and close position, `tab`'s alignment, `menu`'s expand type and submenu style, `theme-switcher` and `locale-switcher`'s variants, `chat-widget`'s corner, `cart-drawer` and `hamburger-menu`'s slide-in edge, and a new "Product viewer" mode on `spline-scene` for drag-to-turn, pinch-to-zoom inspection of a 3D model.
21
+
22
+ ### Fixed
23
+ - Duplicate `@Param` entries copied onto an operation by a shared doc comment no longer appear more than once in `sb_api_find`'s call sheet.
24
+ - `image-comparison`'s `splitDirection` config key is no longer reported as a dead key, because the platform itself has dropped the seed rather than leaving it unread.
25
+ - The catalog's fallback note for a config or specials value now credits the renderer that actually normalises an unrecognised value, instead of crediting a platform vocabulary declaration that defines the legal words but says nothing about what happens to any other one.
26
+
9
27
  ## [0.46.3] - 2026-09-14
10
28
 
11
29
  ### Fixed
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,24 @@ 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.47.1] - 2026-09-14
10
+
11
+ ### Fixed
12
+ - `sb_page_create` với `is_homepage` được bật trên một site đã có sẵn trang chủ giờ không còn tạo ra một trang trùng lặp cướp mất ngôi sao và khiến trang chủ gốc của nền tảng trở nên không thể truy cập ở bất kỳ địa chỉ nào; giờ nó nhận lại trang chủ hiện có, đổi tên nếu người gọi đặt tên khác, và báo cáo việc đã làm thay vì tạo trang mới.
13
+
14
+ ## [0.47.0] - 2026-09-14
15
+
16
+ ### Added
17
+ - Call sheet của `sb_api_find` giờ nêu danh sách `summary_covers` mỗi khi nền tảng viết chung một câu mô tả cho nhiều route, nhờ đó một call sheet không còn bị đọc như thể câu mô tả đó chỉ dành riêng cho route ấy; `sb_api_find` thêm một chỉ dẫn phát một lần trỏ đến trường hợp gay gắt nhất, `/refund` so với `/refund-via-gateway`, nơi đọc theo câu mô tả dùng chung sẽ ghi nhận một khoản hoàn tiền mà không ai thực sự nhận được.
18
+ - `sb_api_find` và `sb_api_call` không còn chèn nguyên định nghĩa request body cho một operation GET hoặc DELETE mỗi khi body được khai báo trong tài liệu là bản sao y nguyên của một operation ghi dữ liệu, mà thay vào đó nêu tên route đã bị sao chép từ đó thay vì mô tả một body mà route này không thể nhận.
19
+ - `sb_api_find` và `sb_api_call` giờ liệt kê đầy đủ mọi path parameter mà một route thực sự cần, kể cả khi danh sách `@Param` của tài liệu bỏ sót nó, nhờ đó người gọi không còn phải thử gọi thất bại trước mới phát hiện ra một đối số mà call sheet chưa từng nhắc tới.
20
+ - Bảng giá trị hợp lệ của catalog mở rộng để bao phủ thêm 18 phần tử, đọc trực tiếp từ các khai báo của chính nền tảng, gồm tần suất kích hoạt và vị trí nút đóng của `popup`, căn chỉnh của `tab`, kiểu mở rộng và kiểu submenu của `menu`, biến thể của `theme-switcher` và `locale-switcher`, góc đặt của `chat-widget`, cạnh trượt ra của `cart-drawer` và `hamburger-menu`, cùng một chế độ mới "Product viewer" trên `spline-scene` cho phép kéo để xoay, chụm để phóng to khi xem một mô hình 3D.
21
+
22
+ ### Fixed
23
+ - Các mục `@Param` bị trùng do một đoạn chú thích tài liệu dùng chung sao chép vào một operation giờ không còn xuất hiện nhiều lần trong call sheet của `sb_api_find`.
24
+ - Config key `splitDirection` của `image-comparison` không còn bị báo là dead key, vì chính nền tảng đã bỏ luôn giá trị seed đó thay vì để nó không ai đọc tới.
25
+ - Ghi chú fallback của catalog cho một giá trị config hoặc specials giờ ghi công cho đúng renderer thực sự chuẩn hóa một giá trị không nhận diện được, thay vì ghi công cho một khai báo bảng giá trị của nền tảng — vốn chỉ định nghĩa các từ hợp lệ chứ không nói gì về việc điều gì xảy ra với một từ khác.
26
+
9
27
  ## [0.46.3] - 2026-09-14
10
28
 
11
29
  ### Fixed
@@ -1,8 +1,8 @@
1
1
  export const DEAD_KEY_SOURCE = {
2
- "files": 3966,
3
- "identifiers": 76235,
2
+ "files": 3974,
3
+ "identifiers": 76582,
4
4
  "seededKeys": 384,
5
- "dead": 1
5
+ "dead": 0
6
6
  };
7
7
  /**
8
8
  * Keys an element SEEDS and no renderer anywhere reads.
@@ -16,14 +16,4 @@ export const DEAD_KEY_SOURCE = {
16
16
  * because the platform accepts the value and a refusal here would invent a rule
17
17
  * it does not have.
18
18
  */
19
- export const DEAD_KEYS = {
20
- "splitDirection": {
21
- "key": "splitDirection",
22
- "namespaces": [
23
- "config"
24
- ],
25
- "seededBy": [
26
- "image-comparison"
27
- ]
28
- }
29
- };
19
+ export const DEAD_KEYS = {};
@@ -3832,7 +3832,8 @@ export const ELEMENTS = {
3832
3832
  "modelUrl": "",
3833
3833
  "posterUrl": "",
3834
3834
  "sceneControls": [],
3835
- "sceneGallery": "podium"
3835
+ "sceneGallery": "podium",
3836
+ "sceneViewer": "off"
3836
3837
  },
3837
3838
  "style": {
3838
3839
  "width": "100%",
@@ -3899,6 +3900,13 @@ export const ELEMENTS = {
3899
3900
  "scene_model"
3900
3901
  ]
3901
3902
  },
3903
+ {
3904
+ "key": "scene_viewer",
3905
+ "label": "Product viewer",
3906
+ "controls": [
3907
+ "scene_viewer"
3908
+ ]
3909
+ },
3902
3910
  {
3903
3911
  "key": "scene_display",
3904
3912
  "label": "Display",
@@ -3966,6 +3974,7 @@ export const ELEMENTS = {
3966
3974
  "scene_effect",
3967
3975
  "scene_gallery",
3968
3976
  "scene_model",
3977
+ "scene_viewer",
3969
3978
  "scene_display",
3970
3979
  "scene_controls",
3971
3980
  "border",
@@ -3993,7 +4002,8 @@ export const ELEMENTS = {
3993
4002
  "Effect: four shaders (gradient mesh, floating particles, waves, aurora) with speed, intensity and a grain toggle. Its colours follow the site theme until you switch them to custom",
3994
4003
  "Spline: paste the link from Spline — Export → Viewer → copy link (…/scene.splinecode)",
3995
4004
  "Add a poster image so the box is not blank while a Spline scene loads; an effect draws immediately and needs none",
3996
- "Scene controls (rotate on scroll, tilt with the mouse) drive named objects, so they apply to a Spline scene and not to an effect, which has no objects"
4005
+ "Scene controls (rotate on scroll, tilt with the mouse) drive named objects, so they apply to a Spline scene and not to an effect, which has no objects",
4006
+ "For letting a shopper INSPECT a product, turn on Product viewer instead of building scene controls — it is drag to turn, wheel or pinch to zoom, and a slow idle spin, and it works on a phone where the mouse triggers do not. It is offered on the built-in scenes and on your own model, and it replaces the scene controls rather than joining them: both move the same object"
3997
4007
  ],
3998
4008
  "semantics": [
3999
4009
  "3d",
@@ -9777,7 +9787,6 @@ export const ELEMENTS = {
9777
9787
  "afterSize": "cover",
9778
9788
  "afterPosition": "center center",
9779
9789
  "splitPosition": 50,
9780
- "splitDirection": "horizontal",
9781
9790
  "imageRatio": "auto"
9782
9791
  }
9783
9792
  },
@@ -36916,6 +36925,24 @@ export const ELEMENT_VALUES = {
36916
36925
  ],
36917
36926
  "fallback": "",
36918
36927
  "readBy": "nodes/popup/html.go:triggerType"
36928
+ },
36929
+ "triggerFreq": {
36930
+ "target": "config",
36931
+ "writeKey": "triggerFreq",
36932
+ "values": [
36933
+ "always",
36934
+ "once"
36935
+ ],
36936
+ "readBy": "POPUP_TRIGGER_FREQS (VOCAB in schema/src/elements/popup/meta.ts)"
36937
+ },
36938
+ "closePos": {
36939
+ "target": "config",
36940
+ "writeKey": "closePos",
36941
+ "values": [
36942
+ "inside",
36943
+ "outside"
36944
+ ],
36945
+ "readBy": "POPUP_CLOSE_POSITIONS (VOCAB in schema/src/elements/popup/meta.ts)"
36919
36946
  }
36920
36947
  },
36921
36948
  "pricing-dataset": {
@@ -36944,6 +36971,15 @@ export const ELEMENT_VALUES = {
36944
36971
  "open": true,
36945
36972
  "readBy": "nodes/product-image-feature/css.go:featureRatioCss"
36946
36973
  },
36974
+ "featureClickAction": {
36975
+ "target": "config",
36976
+ "writeKey": "featureClickAction",
36977
+ "values": [
36978
+ "open_gallery",
36979
+ "open_product_page"
36980
+ ],
36981
+ "readBy": "FEATURE_CLICK_ACTIONS (VOCAB in schema/src/elements/product-image-feature/meta.ts)"
36982
+ },
36947
36983
  "main_image_source": {
36948
36984
  "target": "config",
36949
36985
  "writeKey": "mainImageSource",
@@ -36964,11 +37000,49 @@ export const ELEMENT_VALUES = {
36964
37000
  "right",
36965
37001
  "top"
36966
37002
  ],
37003
+ "readBy": "TAB_POSITIONS (VOCAB in schema/src/elements/tab/meta.ts)",
36967
37004
  "fallback": "top",
36968
- "readBy": "nodes/tab/html.go:position"
37005
+ "fallbackReadBy": "nodes/tab/html.go:position"
37006
+ },
37007
+ "tabAlign": {
37008
+ "target": "config",
37009
+ "writeKey": "tabAlign",
37010
+ "values": [
37011
+ "center",
37012
+ "left",
37013
+ "right"
37014
+ ],
37015
+ "readBy": "TAB_ALIGNS (VOCAB in schema/src/elements/tab/meta.ts)"
36969
37016
  }
36970
37017
  },
36971
37018
  "spline-scene": {
37019
+ "eventsTarget": {
37020
+ "target": "config",
37021
+ "writeKey": "eventsTarget",
37022
+ "values": [
37023
+ "global",
37024
+ "local"
37025
+ ],
37026
+ "readBy": "SCENE_EVENTS_TARGETS (VOCAB in schema/src/elements/spline-scene/meta.ts)"
37027
+ },
37028
+ "mobileMode": {
37029
+ "target": "config",
37030
+ "writeKey": "mobileMode",
37031
+ "values": [
37032
+ "poster",
37033
+ "scene"
37034
+ ],
37035
+ "readBy": "SCENE_MOBILE_MODES (VOCAB in schema/src/elements/spline-scene/meta.ts)"
37036
+ },
37037
+ "effectColors": {
37038
+ "target": "config",
37039
+ "writeKey": "effectColors",
37040
+ "values": [
37041
+ "custom",
37042
+ "theme"
37043
+ ],
37044
+ "readBy": "SCENE_COLOR_MODES (VOCAB in schema/src/elements/spline-scene/meta.ts)"
37045
+ },
36972
37046
  "source": {
36973
37047
  "target": "specials",
36974
37048
  "writeKey": "source",
@@ -36980,6 +37054,15 @@ export const ELEMENT_VALUES = {
36980
37054
  ],
36981
37055
  "readBy": "SCENE_SOURCES (schema/src/elements/spline-scene/meta.ts)"
36982
37056
  },
37057
+ "sceneViewer": {
37058
+ "target": "specials",
37059
+ "writeKey": "sceneViewer",
37060
+ "values": [
37061
+ "off",
37062
+ "on"
37063
+ ],
37064
+ "readBy": "SCENE_VIEWER_MODES (schema/src/elements/spline-scene/meta.ts)"
37065
+ },
36983
37066
  "effect": {
36984
37067
  "target": "config",
36985
37068
  "writeKey": "effect",
@@ -37012,7 +37095,7 @@ export const ELEMENT_VALUES = {
37012
37095
  "normal",
37013
37096
  "slow"
37014
37097
  ],
37015
- "readBy": "SCENE_SPEEDS (schema/src/elements/spline-scene/meta.ts)"
37098
+ "readBy": "SCENE_SPEEDS (VOCAB in schema/src/elements/spline-scene/meta.ts)"
37016
37099
  },
37017
37100
  "intensity": {
37018
37101
  "target": "config",
@@ -37022,16 +37105,41 @@ export const ELEMENT_VALUES = {
37022
37105
  "soft",
37023
37106
  "strong"
37024
37107
  ],
37025
- "readBy": "SCENE_INTENSITIES (schema/src/elements/spline-scene/meta.ts)"
37026
- },
37027
- "effectColors": {
37108
+ "readBy": "SCENE_INTENSITIES (VOCAB in schema/src/elements/spline-scene/meta.ts)"
37109
+ }
37110
+ },
37111
+ "text-marquee": {
37112
+ "marqueeDirection": {
37028
37113
  "target": "config",
37029
- "writeKey": "effectColors",
37114
+ "writeKey": "marqueeDirection",
37030
37115
  "values": [
37031
- "custom",
37032
- "theme"
37116
+ "left",
37117
+ "right"
37118
+ ],
37119
+ "readBy": "MARQUEE_DIRECTIONS (VOCAB in schema/src/elements/text-marquee/meta.ts)"
37120
+ }
37121
+ },
37122
+ "google-map": {
37123
+ "mapType": {
37124
+ "target": "specials",
37125
+ "writeKey": "mapType",
37126
+ "values": [
37127
+ "code",
37128
+ "location"
37033
37129
  ],
37034
- "readBy": "COLOR_MODES (editor/src/components/inspector/ScenePaletteRows.vue)"
37130
+ "readBy": "MAP_TYPES (VOCAB in schema/src/elements/google-map/meta.ts)"
37131
+ }
37132
+ },
37133
+ "form-file": {
37134
+ "fileFormat": {
37135
+ "target": "specials",
37136
+ "writeKey": "fileFormat",
37137
+ "values": [
37138
+ "image",
37139
+ "pdf",
37140
+ "video"
37141
+ ],
37142
+ "readBy": "FILE_FORMATS (VOCAB in schema/src/elements/form-file/meta.ts)"
37035
37143
  }
37036
37144
  },
37037
37145
  "form-select": {
@@ -37060,6 +37168,17 @@ export const ELEMENT_VALUES = {
37060
37168
  "readBy": "OPTION_SOURCES (schema/src/elements/formOptionSource.ts)"
37061
37169
  }
37062
37170
  },
37171
+ "form-payment": {
37172
+ "logoShape": {
37173
+ "target": "specials",
37174
+ "writeKey": "logoShape",
37175
+ "values": [
37176
+ "auto",
37177
+ "square"
37178
+ ],
37179
+ "readBy": "PAY_LOGO_SHAPES (VOCAB in schema/src/elements/form-payment/meta.ts)"
37180
+ }
37181
+ },
37063
37182
  "form-checkbox": {
37064
37183
  "optionSource": {
37065
37184
  "target": "config",
@@ -37116,6 +37235,94 @@ export const ELEMENT_VALUES = {
37116
37235
  "readBy": "DATE_PICKERS (schema/src/elements/form-calendar/meta.ts)"
37117
37236
  }
37118
37237
  },
37238
+ "locale-switcher": {
37239
+ "variant": {
37240
+ "target": "specials",
37241
+ "writeKey": "variant",
37242
+ "values": [
37243
+ "dropdown",
37244
+ "inline",
37245
+ "panel",
37246
+ "select"
37247
+ ],
37248
+ "readBy": "LOCALE_SWITCH_VARIANTS (VOCAB in schema/src/elements/locale-switcher/meta.ts)"
37249
+ },
37250
+ "locale_switch_label": {
37251
+ "target": "specials",
37252
+ "writeKey": "labelMode",
37253
+ "values": [
37254
+ "code",
37255
+ "name",
37256
+ "both",
37257
+ "currency"
37258
+ ],
37259
+ "readBy": "locale_switch_label (editor picker)"
37260
+ }
37261
+ },
37262
+ "theme-switcher": {
37263
+ "variant": {
37264
+ "target": "specials",
37265
+ "writeKey": "variant",
37266
+ "values": [
37267
+ "icon",
37268
+ "segmented",
37269
+ "switch"
37270
+ ],
37271
+ "readBy": "THEME_SWITCH_VARIANTS (VOCAB in schema/src/elements/theme-switcher/meta.ts)"
37272
+ }
37273
+ },
37274
+ "image-marquee": {
37275
+ "marqueeDirection": {
37276
+ "target": "config",
37277
+ "writeKey": "marqueeDirection",
37278
+ "values": [
37279
+ "left",
37280
+ "right"
37281
+ ],
37282
+ "readBy": "MARQUEE_DIRECTIONS (VOCAB in schema/src/elements/image-marquee/meta.ts)"
37283
+ }
37284
+ },
37285
+ "menu": {
37286
+ "expandType": {
37287
+ "target": "config",
37288
+ "writeKey": "expandType",
37289
+ "values": [
37290
+ "click",
37291
+ "hover"
37292
+ ],
37293
+ "readBy": "EXPAND_TYPES (VOCAB in schema/src/elements/menu/meta.ts)"
37294
+ },
37295
+ "submenuStyle": {
37296
+ "target": "config",
37297
+ "writeKey": "submenuStyle",
37298
+ "values": [
37299
+ "cascade",
37300
+ "collapse",
37301
+ "dropdown"
37302
+ ],
37303
+ "readBy": "SUBMENU_STYLES (VOCAB in schema/src/elements/menu/meta.ts)"
37304
+ }
37305
+ },
37306
+ "chat-widget": {
37307
+ "side": {
37308
+ "target": "config",
37309
+ "writeKey": "side",
37310
+ "values": [
37311
+ "left",
37312
+ "right"
37313
+ ],
37314
+ "readBy": "CHAT_HSIDES (VOCAB in schema/src/elements/chat-widget/meta.ts)"
37315
+ },
37316
+ "vside": {
37317
+ "target": "config",
37318
+ "writeKey": "vside",
37319
+ "values": [
37320
+ "bottom",
37321
+ "top"
37322
+ ],
37323
+ "readBy": "CHAT_VSIDES (VOCAB in schema/src/elements/chat-widget/meta.ts)"
37324
+ }
37325
+ },
37119
37326
  "*": {
37120
37327
  "backgroundSceneSource": {
37121
37328
  "target": "config",
@@ -37180,7 +37387,7 @@ export const ELEMENT_VALUES = {
37180
37387
  "custom",
37181
37388
  "theme"
37182
37389
  ],
37183
- "readBy": "COLOR_MODES (editor/src/components/inspector/ScenePaletteRows.vue)"
37390
+ "readBy": "SCENE_COLOR_MODES (schema/src/elements/spline-scene/meta.ts)"
37184
37391
  }
37185
37392
  },
37186
37393
  "filter-checkbox": {
@@ -37487,19 +37694,6 @@ export const ELEMENT_VALUES = {
37487
37694
  "readBy": "cart_total_part (editor picker)"
37488
37695
  }
37489
37696
  },
37490
- "locale-switcher": {
37491
- "locale_switch_label": {
37492
- "target": "specials",
37493
- "writeKey": "labelMode",
37494
- "values": [
37495
- "code",
37496
- "name",
37497
- "both",
37498
- "currency"
37499
- ],
37500
- "readBy": "locale_switch_label (editor picker)"
37501
- }
37502
- },
37503
37697
  "member-field": {
37504
37698
  "member_field_source": {
37505
37699
  "target": "specials",
@@ -37635,6 +37829,32 @@ export const ELEMENT_VALUES = {
37635
37829
  ],
37636
37830
  "readBy": "qr_source (editor picker)"
37637
37831
  }
37832
+ },
37833
+ "cart-drawer": {
37834
+ "direct": {
37835
+ "target": "config",
37836
+ "writeKey": "direct",
37837
+ "values": [
37838
+ "bottom",
37839
+ "left",
37840
+ "right",
37841
+ "top"
37842
+ ],
37843
+ "readBy": "DRAWER_EDGES (VOCAB in schema/src/elements/cart-drawer/meta.ts)"
37844
+ }
37845
+ },
37846
+ "hamburger-menu": {
37847
+ "direct": {
37848
+ "target": "config",
37849
+ "writeKey": "direct",
37850
+ "values": [
37851
+ "bottom",
37852
+ "left",
37853
+ "right",
37854
+ "top"
37855
+ ],
37856
+ "readBy": "DRAWER_EDGES (VOCAB in schema/src/elements/hamburger-menu/meta.ts)"
37857
+ }
37638
37858
  }
37639
37859
  };
37640
37860
  /**
@@ -76,6 +76,199 @@ export function searchOperations(query, opts = {}) {
76
76
  * Saying "no body" for the second group would be the silent failure — the model
77
77
  * would send an empty PUT and wipe a page. So the two get different words.
78
78
  */
79
+ /**
80
+ * ONE DOC BLOCK OVER SEVERAL `@Router` LINES, AND THE SUMMARY IS THE HALF
81
+ * NOTHING CORRECTED.
82
+ *
83
+ * This file already records the defect for BODIES — swag attaches one comment
84
+ * block's `@Param body` to every route beneath it, so a listing GET claimed a
85
+ * body it does not take — and the fix was to read the decode site instead. The
86
+ * SUMMARY is copied by the same mechanism and has no such correction: 302 of
87
+ * 524 operations share a summary with another operation.
88
+ *
89
+ * MOST OF THAT IS HARMLESS AND MUST NOT BE FLAGGED. "List, create, update or
90
+ * delete a course's lessons" on all four CRUD routes is an UMBRELLA — it names
91
+ * the whole group and is true of each member, and 105 of the 119 shared
92
+ * summaries are that shape. Flagging them would bury the ones that matter.
93
+ *
94
+ * THE DISCRIMINATOR IS THE TRAILING LITERAL SEGMENT, which is the same signal
95
+ * `scripts/shapes.ts` already trusts to pick a handler out of a dispatcher that
96
+ * routes by path ("the route's own trailing literal segment picks the callee").
97
+ * Operations sharing a summary while differing only by METHOD or by a trailing
98
+ * `{param}` are one resource; ones whose literal tails DIFFER are separate
99
+ * ACTIONS, and one sentence can describe at most one of them. That leaves 14
100
+ * groups and 41 operations.
101
+ *
102
+ * WHY THIS IS WORTH A FIELD. The costliest is money: this platform documents
103
+ * `/refund` as RECORDING a refund made elsewhere and `/refund-via-gateway` as
104
+ * ASKING the gateway to send it, and the catalog gives BOTH — plus `POST
105
+ * /payment-transactions`, which opens a pay link — the single summary "ASK the
106
+ * gateway to send the money back". An agent asked to refund a customer reaches
107
+ * for the obvious name, reads a sentence promising the money moves, and records
108
+ * a refund that never pays anybody. `POST .../versions` (snapshot) and
109
+ * `.../restore` share one summary while running in OPPOSITE directions, and
110
+ * `/invitations/{id}/accept` and `/decline` are both described as "Withdraw an
111
+ * invitation".
112
+ *
113
+ * IT REPORTS THE COVERAGE AND NEVER GUESSES THE MISSING SENTENCE. The per-route
114
+ * text does exist — in each package's route-map comment — but those maps write
115
+ * paths relatively, abbreviate methods (`PATCH/DEL`), carry query strings and
116
+ * often no description at all, so recovering a summary from them would invent
117
+ * exactly the kind of confident wrong sentence this exists to remove. Naming
118
+ * the routes the sentence covers is knowable, is enough for the agent to read
119
+ * the path instead, and cannot be wrong.
120
+ *
121
+ * Derived from the catalog rather than generated into it: it is a fact ABOUT
122
+ * the document, not one read off the platform, so generating it would ship a
123
+ * table restating data the same file already carries.
124
+ */
125
+ const summaryCoverage = (() => {
126
+ let byId = null;
127
+ /** The last segment that is not a `{param}` — an action tail, or a resource. */
128
+ const tail = (path) => {
129
+ const literal = path.split('/').filter((seg) => seg && !seg.startsWith('{'));
130
+ return literal[literal.length - 1] ?? '';
131
+ };
132
+ const build = () => {
133
+ const groups = new Map();
134
+ for (const op of API_OPERATIONS) {
135
+ if (!op.summary)
136
+ continue;
137
+ const g = groups.get(op.summary);
138
+ if (g)
139
+ g.push(op);
140
+ else
141
+ groups.set(op.summary, [op]);
142
+ }
143
+ const out = new Map();
144
+ for (const members of groups.values()) {
145
+ if (members.length < 2)
146
+ continue;
147
+ if (new Set(members.map((m) => tail(m.path))).size < 2)
148
+ continue;
149
+ for (const op of members) {
150
+ out.set(op.id, members.filter((m) => m.id !== op.id).map((m) => `${m.method} ${m.path}`));
151
+ }
152
+ }
153
+ return out;
154
+ };
155
+ return (id) => (byId ??= build()).get(id);
156
+ })();
157
+ /** Whether this operation's summary is one the platform wrote for a group. */
158
+ export function summaryIsShared(id) {
159
+ return summaryCoverage(id) !== undefined;
160
+ }
161
+ /**
162
+ * THE PARAMS, ONCE EACH.
163
+ *
164
+ * The stacked block copies `@Param` lines as well as prose, so an operation in
165
+ * a group of four carries `siteId` four times: 29 operations list a parameter
166
+ * more than once, and `POST /payment-transactions` reports `siteId` three
167
+ * times. It is noise in the one field an agent reads to build the call, and it
168
+ * costs tokens on every search line.
169
+ *
170
+ * Deduplicated by NAME AND LOCATION, which is what identifies a parameter on
171
+ * the wire — two entries agreeing on both are one parameter however many blocks
172
+ * declared it. The first is kept, so a description is never invented or merged.
173
+ * No operation claims a path param its own path lacks (measured), so the
174
+ * deduplication only ever removes a repeat.
175
+ *
176
+ * AND THE PATH IS THE AUTHORITY ON WHAT THE CALL NEEDS, which is the other
177
+ * direction and was costing a round trip. THIRTY operations declare a `{param}`
178
+ * in their path that the document lists under no `@Param` at all —
179
+ * `POST /api/sites/{siteId}/pages` lists NONE of its own, and
180
+ * `PUT /api/sites/{siteId}/courses/{id}/questions/{questionId}` lists every one
181
+ * but `questionId`. `callOperation` already ignores this list and walks the
182
+ * PATH, so the call is not broken; what breaks is the agent, which reads
183
+ * `params`, sends what it says, and is told it is missing an argument the sheet
184
+ * never mentioned. Synthesised here so the sheet says what the call demands,
185
+ * and marked so nobody mistakes it for something the platform described.
186
+ */
187
+ function visibleParams(op) {
188
+ const seen = new Set();
189
+ const out = op.params.filter((p) => {
190
+ if (p.in === 'body')
191
+ return false;
192
+ const id = `${p.in}:${p.name}`;
193
+ if (seen.has(id))
194
+ return false;
195
+ seen.add(id);
196
+ return true;
197
+ });
198
+ for (const m of op.path.matchAll(/\{([^}]+)\}/g)) {
199
+ if (seen.has(`path:${m[1]}`))
200
+ continue;
201
+ seen.add(`path:${m[1]}`);
202
+ out.push({
203
+ name: m[1],
204
+ in: 'path',
205
+ required: true,
206
+ type: 'string',
207
+ description: 'In the path, and undocumented — recovered from the route itself.',
208
+ });
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * A READ THAT CLAIMS A BODY IS CLAIMING ITS NEIGHBOUR'S.
214
+ *
215
+ * The same stacking that copies a summary copies every `@Param` in the block,
216
+ * so 90 GET and DELETE operations declare a request body. `describeOperation`
217
+ * believed them and inlined the DEFINITION: measured, ~83 KB of body schema
218
+ * across those 90 call sheets — about 927 bytes each — describing a body the
219
+ * route cannot take. `GET /api/sites/{siteId}/customers`, a listing, ships the
220
+ * whole customer object and reports `body: "described"`.
221
+ *
222
+ * THE DECODE SITES CANNOT ANSWER THIS ONE, and reading their silence as a no
223
+ * would be the mistake this repo already records in another form ("the absence
224
+ * of a string is evidence about the string, not about the behaviour").
225
+ * `scripts/shapes.ts` sets `WRITE_METHODS = POST | PUT | PATCH` and never looks
226
+ * at a read, so "0 of 90 confirmed" is structural, not a finding.
227
+ *
228
+ * SO THE TEST IS THE MECHANISM ITSELF, which is visible in the document. A
229
+ * stacked block gives every route the SAME `@Param`, byte for byte — name, type
230
+ * and description. MEASURED: all 90 carry a body param identical to some write
231
+ * operation's, and for 86 that donor is on the same path or the same resource,
232
+ * which is a doc block covering several methods. That is the copy caught in the
233
+ * act, not an assumption about what a GET may carry.
234
+ *
235
+ * It answers only for reads. A WRITE's body claim stays believed: this file
236
+ * already records that most writes are UNDER-annotated, and `PUT
237
+ * /pages/{id}/source` carries a whole page document while declaring nothing —
238
+ * so the risk there runs the other way and a shared `@Param` may be the only
239
+ * description of a real body.
240
+ */
241
+ const copiedBodyDonor = (() => {
242
+ let byId = null;
243
+ const sig = (p) => JSON.stringify([p.name, p.type, p.description]);
244
+ const build = () => {
245
+ const donors = new Map();
246
+ for (const op of API_OPERATIONS) {
247
+ if (op.method !== 'POST' && op.method !== 'PUT' && op.method !== 'PATCH')
248
+ continue;
249
+ for (const p of op.params)
250
+ if (p.in === 'body' && !donors.has(sig(p)))
251
+ donors.set(sig(p), op);
252
+ }
253
+ const out = new Map();
254
+ for (const op of API_OPERATIONS) {
255
+ if (op.method !== 'GET' && op.method !== 'DELETE')
256
+ continue;
257
+ const bodies = op.params.filter((p) => p.in === 'body');
258
+ if (!bodies.length)
259
+ continue;
260
+ // EVERY one must be accounted for. A read carrying one copied param and
261
+ // one of its own would be a shape nothing here has seen, and the honest
262
+ // answer to that is to say nothing and leave the document's claim alone.
263
+ const found = bodies.map((p) => donors.get(sig(p)));
264
+ if (found.some((d) => d === undefined))
265
+ continue;
266
+ out.set(op.id, `${found[0].method} ${found[0].path}`);
267
+ }
268
+ return out;
269
+ };
270
+ return (id) => (byId ??= build()).get(id);
271
+ })();
79
272
  export function describeOperation(op) {
80
273
  const out = {
81
274
  id: op.id,
@@ -84,8 +277,14 @@ export function describeOperation(op) {
84
277
  summary: op.summary,
85
278
  tags: op.tags,
86
279
  credential: op.credential,
87
- params: op.params.filter((p) => p.in !== 'body'),
280
+ params: visibleParams(op),
88
281
  };
282
+ // SAY WHO ELSE THE SENTENCE ABOVE IS ABOUT. Placed directly under `summary`
283
+ // because that is the field it qualifies: an agent that has read the sentence
284
+ // and moved on has already taken the wrong operation.
285
+ const covers = summaryCoverage(op.id);
286
+ if (covers)
287
+ out.summary_covers = covers;
89
288
  const hasBody = op.params.some((p) => p.in === 'body');
90
289
  const isWrite = op.method === 'POST' || op.method === 'PUT' || op.method === 'PATCH';
91
290
  // THE HANDLER OUTRANKS THE DOCUMENT. `swagger.json` describes 46 of 212 write
@@ -172,6 +371,16 @@ export function describeOperation(op) {
172
371
  out.body_shape = shape;
173
372
  return out;
174
373
  }
374
+ // Said BEFORE the body branches, because the whole point is not to inline a
375
+ // definition for a body this route does not take.
376
+ const copiedFrom = copiedBodyDonor(op.id);
377
+ if (copiedFrom) {
378
+ out.body_note =
379
+ `The document declares a request body here, and it is a VERBATIM copy of ${copiedFrom}'s ` +
380
+ '— one doc comment over several @Router lines gives every route in the block the same ' +
381
+ '@Param. Send no body; the schema is that other route\'s.';
382
+ return out;
383
+ }
175
384
  if (hasBody && op.bodyDescribed && op.bodyRef) {
176
385
  out.body_schema = API_DEFINITIONS[op.bodyRef];
177
386
  }
@@ -209,9 +418,16 @@ export function summarizeOperation(op) {
209
418
  path: op.path,
210
419
  summary: op.summary,
211
420
  credential: op.credential,
212
- params: op.params.filter((p) => p.in !== 'body').map((p) => (p.required ? p.name : `?${p.name}`)),
421
+ params: visibleParams(op).map((p) => (p.required ? p.name : `?${p.name}`)),
213
422
  };
214
- if (hasBody)
423
+ // ONE BOOLEAN, because this line is where the CHOICE is made and a search hit
424
+ // must stay a line. It says only "this sentence was written for a group of
425
+ // routes, so read the path"; the call sheet names the group.
426
+ if (summaryIsShared(op.id))
427
+ out.summary_shared = true;
428
+ // A copied body is not this route's, so the line says nothing rather than
429
+ // `described` — see `copiedBodyDonor`.
430
+ if (hasBody && !copiedBodyDonor(op.id))
215
431
  out.body = op.bodyDescribed && op.bodyRef ? 'described' : 'undescribed';
216
432
  else if (isWrite)
217
433
  out.body = 'none_declared';
@@ -4,7 +4,7 @@ export const SHAPE_SOURCE = {
4
4
  "fromHandlers": 175,
5
5
  "fromSwaggerOnly": 0,
6
6
  "withReadOnly": 26,
7
- "structsRead": 1660
7
+ "structsRead": 1661
8
8
  };
9
9
  export const REQUEST_SHAPES = {
10
10
  "post:/api/sites/{siteId}/api-keys": {
@@ -3,7 +3,7 @@
3
3
  export const TRANSLATION_SOURCE = {
4
4
  "elements": 58,
5
5
  "pairs": 142,
6
- "neverKeys": 163,
6
+ "neverKeys": 164,
7
7
  "entityTypes": 12
8
8
  };
9
9
  /** Every entity type a translation record can name, including "node". */
@@ -417,6 +417,7 @@ export const NEVER_TRANSLATED = [
417
417
  "sceneControls",
418
418
  "sceneGallery",
419
419
  "sceneUrl",
420
+ "sceneViewer",
420
421
  "searchBehavior",
421
422
  "searchClearSwap",
422
423
  "searchDebounceMs",
@@ -128,10 +128,15 @@ export function vocabularyForWrite(type, namespace, key) {
128
128
  * `mediaImageRatio: "4 / 5"` is CORRECT and reporting it would send a caller
129
129
  * to "fix" a working page — the same cost this repo already records for the
130
130
  * `category` alias. Nothing is said at all.
131
- * - A FALLBACK IS ONLY CLAIMED WHERE THE SOURCE SAYS ONE. The editor's picker
132
- * proves what an author may choose and is silent on what the renderer does
133
- * with anything else. Naming a fallback there would be the invention this
134
- * table exists to remove, so the note says what it knows and stops.
131
+ * - A FALLBACK IS ONLY CLAIMED WHERE A SOURCE SAYS ONE, AND IT IS CREDITED TO
132
+ * THAT SOURCE. The editor's picker proves what an author may choose and is
133
+ * silent on what the renderer does with anything else. Naming a fallback
134
+ * there would be the invention this table exists to remove, so the note says
135
+ * what it knows and stops. Where a schema declaration has SUPERSEDED a Go
136
+ * reading the two halves come from different places — the list says which
137
+ * words mean something, the renderer's `default:` arm says what an unknown
138
+ * one renders as — so the sentence names `fallbackReadBy`, not the
139
+ * declaration, which normalises nothing.
135
140
  */
136
141
  export function unknownWriteNote(type, namespace, key, value) {
137
142
  const vocab = vocabularyForWrite(type, namespace, key);
@@ -144,7 +149,7 @@ export function unknownWriteNote(type, namespace, key, value) {
144
149
  const list = vocab.values.map((v) => (v === '' ? '"" (unset)' : v)).join(', ');
145
150
  return (`${namespace}.${key} = ${JSON.stringify(value)} is not a value ${type}'s renderer knows. ` +
146
151
  (vocab.fallback !== undefined
147
- ? `${vocab.readBy} normalises anything unrecognised to ${vocab.fallback === '' ? '"" (unset)' : `"${vocab.fallback}"`}, so this stores, saves and publishes with no error and renders as that. `
152
+ ? `${vocab.fallbackReadBy ?? vocab.readBy} normalises anything unrecognised to ${vocab.fallback === '' ? '"" (unset)' : `"${vocab.fallback}"`}, so this stores, saves and publishes with no error and renders as that. `
148
153
  : `${vocab.readBy} is the source, and it is silent on what the renderer does with a word ` +
149
154
  'outside the list — every comparable key in this catalog normalises silently rather ' +
150
155
  'than erroring. ') +
package/dist/tools/api.js CHANGED
@@ -303,6 +303,20 @@ export async function callOperation(ctx, args) {
303
303
  const projection = args.pick ?? LIST_PROJECTIONS[op.id];
304
304
  return shapeResponse(raw, { pick: projection, max_items: args.max_items });
305
305
  }
306
+ /**
307
+ * WHY A SUMMARY CAN BE ABOUT A DIFFERENT ROUTE.
308
+ *
309
+ * The platform stacks ONE doc comment over several `@Router` lines and swag
310
+ * copies it to each, so the summary — and the description, which it also merges
311
+ * across blocks — belongs to the group rather than to the route. This repo
312
+ * already corrects the same defect for BODIES by reading the decode site; there
313
+ * is no such correction for prose, and no per-route sentence exists to recover.
314
+ */
315
+ const SHARED_SUMMARY_NOTICE = 'summary_covers means the platform wrote that one sentence for several routes at once (one ' +
316
+ 'doc comment over several @Router lines), so it may describe one of the listed routes rather ' +
317
+ 'than this one — trust the PATH and the body shape, not the sentence. The sharpest case is ' +
318
+ 'money: /payment-transactions/{id}/refund only RECORDS a refund made elsewhere, while ' +
319
+ '/refund-via-gateway is the one that asks the gateway to actually send it.';
306
320
  export function registerApiTools(server, ctx) {
307
321
  server.registerTool('sb_api_find', {
308
322
  description: 'Search platform API operations by intent (query: one line per match), or read one ' +
@@ -327,7 +341,13 @@ export function registerApiTools(server, ctx) {
327
341
  const op = findOperation(id);
328
342
  if (!op)
329
343
  throw new Error(`sbuilder: unknown operation "${id}" — search with query first`);
330
- return text(describeOperation(op));
344
+ const sheet = describeOperation(op);
345
+ // The WHY is a directive, so it is said once per process; the routes it
346
+ // covers are DATA and ride on every sheet that has them.
347
+ const why = sheet.summary_covers
348
+ ? ctx.notices.once('shared_summary', SHARED_SUMMARY_NOTICE)
349
+ : undefined;
350
+ return text(why ? { ...sheet, directive: why } : sheet);
331
351
  }
332
352
  if (!query) {
333
353
  throw new Error('sbuilder: sb_api_find needs a query (search) or an id (call sheet)');
@@ -311,6 +311,29 @@ async function siteChrome(ctx, siteId) {
311
311
  return {};
312
312
  }
313
313
  }
314
+ /**
315
+ * The site's own home page, or null when it genuinely has none.
316
+ *
317
+ * THROWS RATHER THAN GUESSES. Answering "none" for a listing that could not be
318
+ * read is the one wrong answer available here: it is indistinguishable from an
319
+ * empty site, and the caller acts on it by creating a home page the site
320
+ * already had. `siteChrome` above may swallow its read because a page created
321
+ * without chrome is a page a person can fix; this one may not, because the page
322
+ * it creates cannot be un-created and takes the star off whichever page held it.
323
+ */
324
+ async function existingHomepage(ctx, siteId) {
325
+ const listed = (await request({
326
+ base: ctx.base,
327
+ method: 'GET',
328
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
329
+ token: siteToken(ctx),
330
+ fetchImpl: ctx.fetchImpl,
331
+ }));
332
+ const home = (listed.pages ?? []).find((p) => p.isHomepage === true);
333
+ if (!home || typeof home.id !== 'string' || !home.id)
334
+ return null;
335
+ return { id: home.id, name: typeof home.name === 'string' ? home.name : '' };
336
+ }
314
337
  export function registerPageTools(server, ctx) {
315
338
  const session = new PageSession(ctx);
316
339
  server.registerTool('sb_page_open', {
@@ -1012,7 +1035,85 @@ export function registerPageTools(server, ctx) {
1012
1035
  // fine, while `siteChrome` asks whether the SITE has globals and it does.
1013
1036
  // Measured: three pages built with these tools, every one of them bare,
1014
1037
  // beside a store page carrying its header as ROOT's first child.
1015
- const wear = chrome !== false ? await siteChrome(ctx, site_id) : {};
1038
+ // A SITE HAS ONE HOME PAGE, AND BY THE TIME AN AGENT ASKS FOR ONE IT
1039
+ // USUALLY EXISTS ALREADY.
1040
+ //
1041
+ // `isHomepage: true` does not mean "make this the home page" to the
1042
+ // platform. It means MOVE THE STAR: CreatePage demotes whoever holds it
1043
+ // and promotes this one. An agent building a store reads the flag the
1044
+ // first way and asks for it on a site the editor already gave a home page
1045
+ // to, so the site ends up with two — the new one on "/", the old one
1046
+ // demoted and holding nothing. Measured on a real store: pg_439cb121
1047
+ // "Home" and pg_237719d4 "Trang chủ", both slug "", both path "/", the
1048
+ // first reachable at no address at all and still listed as a page.
1049
+ //
1050
+ // So the flag is honoured as what the caller meant — the site's home page
1051
+ // — which is the one it already has. Adopted, renamed when the caller
1052
+ // named it something else, never duplicated. The same rule sb_import_site
1053
+ // already follows when its entry page lands on a site with a home page.
1054
+ //
1055
+ // REPLACING the home page with a DIFFERENT page stays possible and stays
1056
+ // explicit: create it without the flag, build it, then PATCH isHomepage
1057
+ // through sb_api_call. That is a decision, and it should read like one.
1058
+ const adopt = is_homepage === true ? await existingHomepage(ctx, site_id) : null;
1059
+ const rename = adopt && name.trim() !== '' && name !== adopt.name ? name : '';
1060
+ // Read AFTER the adopt decision and skipped when it holds: siteChrome
1061
+ // reads the header and footer off the home page, so asking it which
1062
+ // chrome to dress the home page in is two requests to answer "its own".
1063
+ const wear = chrome !== false && !adopt ? await siteChrome(ctx, site_id) : {};
1064
+ if (adopt && dry_run !== false) {
1065
+ return text({
1066
+ dry_run: true,
1067
+ into: 'the existing home page',
1068
+ page: adopt,
1069
+ ...(rename ? { would_rename: { from: adopt.name, to: rename } } : {}),
1070
+ note: 'Nothing would be created. This site already has a home page and a site has ' +
1071
+ 'exactly one, so a create carrying isHomepage would have taken the star off ' +
1072
+ `${JSON.stringify(adopt.name)} and left it with no address. Open ${adopt.id} ` +
1073
+ 'with sb_page_open and build it.',
1074
+ });
1075
+ }
1076
+ if (adopt) {
1077
+ // THE RENAME IS THE ONLY WRITE. Not the seed — this page may already be
1078
+ // the site's front door, and overwriting a document nobody asked to
1079
+ // replace is the one thing worse than the duplicate this branch exists
1080
+ // to prevent. Not the chrome either: siteChrome reads the header and
1081
+ // footer OFF the home page, so this page is where they came from.
1082
+ let renamed_to;
1083
+ let rename_failed;
1084
+ if (rename) {
1085
+ try {
1086
+ await request({
1087
+ base: ctx.base,
1088
+ method: 'PATCH',
1089
+ path: `${path}/${encodeURIComponent(adopt.id)}`,
1090
+ token: siteToken(ctx),
1091
+ body: { name: rename },
1092
+ fetchImpl: ctx.fetchImpl,
1093
+ });
1094
+ renamed_to = rename;
1095
+ }
1096
+ catch (e) {
1097
+ // The page is still the right one to build on, so a failed rename
1098
+ // is reported, never raised — the caller asked for a home page and
1099
+ // this is it, under its old name.
1100
+ rename_failed = e.message.replace(/^sbuilder:\s*/, '').slice(0, 160);
1101
+ }
1102
+ }
1103
+ return text({
1104
+ into: 'the existing home page',
1105
+ page: { id: adopt.id, name: renamed_to ?? adopt.name },
1106
+ ...(renamed_to ? { renamed: { from: adopt.name, to: renamed_to } } : {}),
1107
+ ...(rename_failed ? { rename_failed } : {}),
1108
+ ...(slug ? { slug_ignored: 'A home page is served at "/" and carries no slug.' } : {}),
1109
+ ...(type && type !== 'page' ? { type_ignored: `Kept as it is; adopting does not retype a page to "${type}".` } : {}),
1110
+ note: 'Nothing was created. This site already had a home page and a site has exactly ' +
1111
+ 'one, so creating another would have taken the star off this page and left it ' +
1112
+ `with no address. Open ${adopt.id} with sb_page_open and build it. To hand the ` +
1113
+ 'home page over to a DIFFERENT page instead, create that page WITHOUT ' +
1114
+ 'is_homepage and then PATCH isHomepage on it through sb_api_call.',
1115
+ });
1116
+ }
1016
1117
  if (dry_run !== false) {
1017
1118
  return text({
1018
1119
  dry_run: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.46.3",
3
+ "version": "0.47.1",
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",