sbuilder-mcp 0.48.0 → 0.49.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.49.0] - 2026-09-15
10
+
11
+ ### Added
12
+ - `sb_catalog_search` now returns every element type, grouped by category, when `query` is omitted, so an agent can browse the catalogue instead of only searching for a name it already knows.
13
+ - `sb_review` reports a `handbuilt_menu` finding when a header contains three or more sibling buttons and nothing else, since a row of buttons cannot supply the mobile drawer, sub-level dropdown, or active state that the platform's own `menu` element brings.
14
+ - Opening a page now joins the live-edit room on its own, through `sb_page_open`, `sb_page_create`, and `sb_template_use` alike, instead of requiring a separate `sb_live_join` call that an agent has to remember to make; a write retries the join if an earlier attempt failed, and `sb_page_open` reports when it newly joined the room or could not.
15
+ - `sb_connect` now mentions the live-edit room in its connect note, in the one place an agent is guaranteed to read on the way in.
16
+
17
+ ### Fixed
18
+ - A page whose read came back empty because the fetch failed open no longer risks silently emptying the page on save; `sb_page_open` now reports a `seeded_empty` warning on that page, and `PageSession.save` re-checks the server before writing and refuses the save if the server actually holds content, asking the caller to re-open and reapply instead.
19
+
20
+ ## [0.48.1] - 2026-09-14
21
+
22
+ ### Fixed
23
+ - `sb_review`'s `accountPage` readiness gap no longer tells an agent to put login and register forms directly behind the guest side of the `/account` member-gate; the fix text now calls for a short sign-in prompt there that links to a separate login page, with login, register, and forgot-password built as their own pages via `sb_store action:"form"`, so a signed-out visitor gets a page to bookmark and a header gets a `/login` to link to instead of one crowded page carrying every form at once.
24
+
9
25
  ## [0.48.0] - 2026-09-14
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.49.0] - 2026-09-15
10
+
11
+ ### Added
12
+ - `sb_catalog_search` giờ trả về mọi loại phần tử, nhóm theo category, khi bỏ trống `query`, nhờ đó agent có thể duyệt qua toàn bộ catalog thay vì chỉ tìm kiếm theo tên đã biết trước.
13
+ - `sb_review` giờ báo lỗi `handbuilt_menu` khi một header chứa từ ba nút button anh em trở lên và không có gì khác, vì một hàng button không thể thay thế drawer cho điện thoại, dropdown cho cấp con, hay trạng thái active cho trang khách đang xem — những thứ mà phần tử `menu` của nền tảng mang lại.
14
+ - Việc mở một trang giờ tự động tham gia phòng live-edit, qua cả `sb_page_open`, `sb_page_create` lẫn `sb_template_use`, thay vì phải gọi riêng `sb_live_join` mà agent phải tự nhớ; một lần ghi sẽ thử tham gia lại nếu lần trước thất bại, và `sb_page_open` báo cáo khi nó vừa tham gia phòng thành công hay không thể.
15
+ - `sb_connect` giờ nhắc đến phòng live-edit trong ghi chú kết nối, ngay tại nơi chắc chắn agent sẽ đọc khi mới vào.
16
+
17
+ ### Fixed
18
+ - Một lần đọc trang trả về rỗng vì việc fetch thất bại theo kiểu "mở" giờ không còn nguy cơ âm thầm xoá sạch trang khi lưu; `sb_page_open` giờ báo cảnh báo `seeded_empty` trên trang đó, và `PageSession.save` kiểm tra lại với server trước khi ghi, từ chối lưu nếu server thực sự đang giữ nội dung, và yêu cầu người gọi mở lại trang rồi áp dụng lại thay đổi.
19
+
20
+ ## [0.48.1] - 2026-09-14
21
+
22
+ ### Fixed
23
+ - Lỗi thiếu `accountPage` mà `sb_review` báo giờ không còn bảo agent đặt trực tiếp các form đăng nhập và đăng ký phía sau phần "guests" của member-gate trên `/account`; nội dung hướng dẫn giờ yêu cầu đặt ở đó một lời nhắc đăng nhập ngắn gọn, dẫn liên kết tới một trang đăng nhập riêng, còn đăng nhập, đăng ký và quên mật khẩu được dựng thành các trang riêng qua `sb_store action:"form"`, nhờ đó khách chưa đăng nhập có một trang để lưu lại và header có `/login` để liên kết tới, thay vì dồn hết mọi form vào một trang duy nhất.
24
+
9
25
  ## [0.48.0] - 2026-09-14
10
26
 
11
27
  ### Added
@@ -3,6 +3,39 @@ import { animationValues, animationVocabulary, elementVocabularies, sharedVocabu
3
3
  import { neverTranslatedOn, translatableSpecials } from '../domains/site/translate.js';
4
4
  /** Eight to choose from; the hints for the chosen one come with sb_traits_for. */
5
5
  export const DEFAULT_CATALOG_LIMIT = 8;
6
+ /**
7
+ * EVERY element there is, grouped the way the palette files them.
8
+ *
9
+ * `catalogMatches` below is a SEARCH, and a search only returns what the caller
10
+ * already thought to ask for. Measured on a store built with these tools: the
11
+ * home page used 17 element types out of the hundred-odd here, and the ones it
12
+ * reached for were the five primitives any agent already knows — flex-block,
13
+ * text, heading, button, image. Four category cards were hand-assembled out of
14
+ * them; so were four feature blocks and a stats row. The header's navigation
15
+ * was SIX BUTTONS in a flex-block, on a platform carrying menu, menu-item,
16
+ * menu-dropdown, menu-panel, menu-drawer and hamburger-menu — so that site has
17
+ * no mobile menu at all, and nothing said so.
18
+ *
19
+ * None of that is the agent being careless. It is a catalogue you can only
20
+ * query by name refusing to tell anyone what is in it: rating-stars, carousel,
21
+ * tab, google-map, image-comparison, text-marquee, video, quickview,
22
+ * currency-switcher and popup cannot be searched for by someone who does not
23
+ * know they exist.
24
+ *
25
+ * TYPE AND LABEL ONLY, no descriptions. This is the list you read to find out
26
+ * what is POSSIBLE; `catalogMatches(type)` gives the four fields to choose by
27
+ * and `sb_traits_for` the hints, so repeating a description per element here
28
+ * would pay for the whole catalogue to answer a question about one element.
29
+ */
30
+ export function catalogBrowse() {
31
+ const out = {};
32
+ for (const el of Object.values(ELEMENTS)) {
33
+ (out[el.category] ??= []).push(`${el.type} — ${el.label}`);
34
+ }
35
+ for (const k of Object.keys(out))
36
+ out[k].sort();
37
+ return out;
38
+ }
6
39
  /**
7
40
  * Four fields to CHOOSE by.
8
41
  *
@@ -57,7 +57,9 @@ export class PageDoc {
57
57
  // The genuinely broken case is still refused below: a document that HAS
58
58
  // nodes but whose root_node_id names none of them is damage, not emptiness,
59
59
  // and inventing a root there would strand every existing node as an orphan.
60
+ let seeded = false;
60
61
  if (!d.root_node_id && Object.keys(d.nodes).length === 0) {
62
+ seeded = true;
61
63
  d.root_node_id = 'ROOT';
62
64
  d.nodes.ROOT = {
63
65
  id: 'ROOT',
@@ -105,6 +107,7 @@ export class PageDoc {
105
107
  }
106
108
  const out = new PageDoc(d);
107
109
  out.adoptedRootKey = adopted;
110
+ out.seededRoot = seeded;
108
111
  return out;
109
112
  }
110
113
  /**
@@ -114,6 +117,25 @@ export class PageDoc {
114
117
  * a fact no other surface reports, so the tool layer says it out loud.
115
118
  */
116
119
  adoptedRootKey;
120
+ /**
121
+ * This ROOT was INVENTED, not read — the source came back `{ root_node_id: "",
122
+ * nodes: {} }` and the empty-document branch above seeded one.
123
+ *
124
+ * For a page the caller has just created that is the whole point, and nothing
125
+ * downstream needs to care. For a page that HAS content it is a read that
126
+ * failed open: the session now holds a blank tree it believes is the page, and
127
+ * the first save writes that blank tree over whatever the server holds. The
128
+ * two are indistinguishable AT READ TIME — the bytes are identical — so the
129
+ * distinction cannot be made here and must survive to the one place that can
130
+ * check it cheaply, which is `PageSession.save`.
131
+ *
132
+ * MEASURED: a product template carrying 24 nodes plus a shared header and
133
+ * footer came back bare to one session and was stored bare. The published copy
134
+ * was untouched, so all 19 product pages kept rendering while the draft the
135
+ * editor opens was blank — one publish from that draft would have taken every
136
+ * one of them down.
137
+ */
138
+ seededRoot;
117
139
  get rev() {
118
140
  return this.revision;
119
141
  }
@@ -42,6 +42,10 @@ export const FIX = {
42
42
  'label-to-control gap INSIDE one field.',
43
43
  unlinked_form: 'Point it at a real form: sb_set id "<id>", namespace specials, keys ' +
44
44
  '{ "formId": "<a form id from sb_api_find \'list forms\'>" }.',
45
+ handbuilt_menu: 'Replace it with the real thing: sb_add a "menu" in place of the container "<id>", one ' +
46
+ '"menu-item" per link, and delete the buttons with sb_remove. Add a "menu-drawer" (with a ' +
47
+ '"hamburger-menu" trigger) for the phone, and "menu-dropdown" where a link has a sub-level. ' +
48
+ 'sb_traits_for "menu" has the controls.',
45
49
  dead_menu_link: 'Write the entries the renderer actually reads: sb_set id "<id>", namespace specials, keys ' +
46
50
  '{ "menuItems": [{ "id": "mi-1", "label": "Shop", "href": "/shop" }] }. Setting menuId alone ' +
47
51
  'publishes an empty nav — the Go renderer never reads it.',
@@ -221,6 +221,18 @@ export function readinessGaps(input) {
221
221
  // between the store and a PAID ORDER: a shop with no account page still takes
222
222
  // money. They stand between it and a finished website, which is the next
223
223
  // question a merchant asks.
224
+ //
225
+ // WHAT /account OWES A SIGNED-OUT VISITOR IS A WAY IN, NOT EVERY FORM AT ONCE.
226
+ // This fix used to read "put login and register forms behind a member-gate
227
+ // with audience guests", and agents did exactly that — one page carrying the
228
+ // profile, the login form and the register form, with nothing for a header to
229
+ // link to and no /login to bookmark. Reported from a built store as the pages
230
+ // coming out "ngáo": the rule was the cause, not the agent.
231
+ //
232
+ // The gate itself is load-bearing and stays: membersOnlyRedirectTarget sends
233
+ // every gated visitor to /account (and to "/" when there is none), so that
234
+ // page MUST answer a signed-out visitor with something. A prompt and a link
235
+ // is that something.
224
236
  for (const [type, id, what, fix] of [
225
237
  [
226
238
  'account',
@@ -228,8 +240,14 @@ export function readinessGaps(input) {
228
240
  '/account 404s. A shopper has no way to see their orders, addresses or saved items, and ' +
229
241
  'the account elements (account-info, address-book, wishlist-list, points-card) have ' +
230
242
  'nowhere to live.',
231
- 'Create a page of type "account" and publish it. Put login and register forms behind a ' +
232
- 'member-gate with audience "guests", and the profile behind audience "members".',
243
+ 'Create a page of type "account" and publish it: the profile behind a member-gate with ' +
244
+ 'audience "members", and behind audience "guests" a short sign-in prompt LINKING to the ' +
245
+ 'login page — not the forms themselves. Login, register and forgot-password are three ' +
246
+ 'ordinary pages of type "page", each seeded by sb_store action:"form" with template ' +
247
+ '"login", "register" or "forgot". Putting all three inside /account hands a shopper one ' +
248
+ 'crowded page and hands a header nothing to link to: a popup is a fine way to SIGN IN, ' +
249
+ 'but only a page has an address, and /account is the address this platform already ' +
250
+ 'sends every gated visitor to.',
233
251
  ],
234
252
  [
235
253
  'search',
@@ -1,8 +1,25 @@
1
1
  import { childrenOf, childrenWithSatellites, isOverlay, pageChildren, appBlockRoot, SPEC_GLOBAL_REF, SPEC_APP_BLOCK_REF } from '../../core/tree.js';
2
2
  import { STUCK_STATE, stickyBlockedBy, stuckHostOf, isPinnedNode } from './sticky.js';
3
+ import { bandOf } from './traps.js';
3
4
  import { HOVER_STATE, hoverHome } from './hover.js';
4
5
  import { ELEMENTS, BINDING_SOURCES, BOUND_SPECIALS, FIRST_CHILD_ONLY, SATELLITE_RULES, ELEMENT_SEEDS } from '../../catalog/elements.generated.js';
5
6
  import { fill } from './findings.js';
7
+ /**
8
+ * A defect somebody looking at the page would see.
9
+ *
10
+ * Distinct from `validateForSave`, which asks "will the platform store this
11
+ * tree" — dangling ids, orphans, band order. This asks the question a person
12
+ * asks: does the page WORK. A document can be perfectly storable and render as a
13
+ * blank band saying "Enter your text here".
14
+ *
15
+ * Every finding names the fix, because a warning that only states a problem is
16
+ * one the reader has to re-derive.
17
+ */
18
+ /**
19
+ * How many buttons in a row read as a navigation rather than as a pair of calls
20
+ * to action. Three: two side by side is the ordinary hero shape.
21
+ */
22
+ const HANDBUILT_MENU_MIN = 3;
6
23
  /**
7
24
  * Shipped with every non-empty finding list.
8
25
  *
@@ -174,6 +191,61 @@ export function reviewDesign(doc) {
174
191
  go(k, inner, overlay || overlayIds.has(k));
175
192
  };
176
193
  go(d.root_node_id);
194
+ // WHICH BAND EACH NODE LIVES IN, taken from the top-level section it descends
195
+ // from. `bandOf` answers for a ROOT child; everything below one inherits it.
196
+ const band = new Map();
197
+ for (const sectionId of pageChildren(d)) {
198
+ const b = bandOf(d, sectionId);
199
+ const stack = [sectionId];
200
+ while (stack.length) {
201
+ const id = stack.pop();
202
+ if (band.has(id))
203
+ continue;
204
+ band.set(id, b);
205
+ for (const k of childrenWithSatellites(d, id))
206
+ stack.push(k);
207
+ }
208
+ }
209
+ // BUILT BY HAND OUT OF PRIMITIVES, WHERE THE PLATFORM HAS THE ELEMENT.
210
+ //
211
+ // Every other check here asks whether something is BROKEN. This one asks
212
+ // whether it was built with the wrong thing, which is the defect no amount of
213
+ // looking at the page reveals: it renders correctly on the screen the author
214
+ // is looking at, and fails on the one they are not.
215
+ //
216
+ // Measured on a store built with these tools: the header's navigation was six
217
+ // `button` nodes in a flex-block. It looks right on a desktop canvas. On a
218
+ // phone there is no drawer, because a drawer is something `menu` brings and a
219
+ // row of buttons does not — so the site shipped with no mobile navigation and
220
+ // nothing anywhere said so.
221
+ //
222
+ // A TABLE, because this will not be the only one. The next pattern is a row
223
+ // here, not a second branch somewhere else.
224
+ //
225
+ // DELIBERATELY NARROW. Three or more buttons, ALL the container's children,
226
+ // all leaves, and only in the HEADER band. A footer's link column is the same
227
+ // shape and is legitimately a list of links; a two-button pair is a call to
228
+ // action, not a menu. The check that fires on those is one an author learns to
229
+ // ignore, which costs more than the one it catches.
230
+ for (const [containerId, childIds] of [...Object.keys(d.nodes)].map((id) => [id, childrenOf(d, id)])) {
231
+ if (band.get(containerId) !== 'header')
232
+ continue;
233
+ if (childIds.length < HANDBUILT_MENU_MIN)
234
+ continue;
235
+ const allLeafButtons = childIds.every((k) => d.nodes[k]?.data.type === 'button' && childrenOf(d, k).length === 0);
236
+ if (!allLeafButtons)
237
+ continue;
238
+ out.push({
239
+ code: 'handbuilt_menu',
240
+ nodeId: containerId,
241
+ type: d.nodes[containerId]?.data.type ?? '',
242
+ problem: `${childIds.length} buttons in a row in the header is a navigation built by hand. It ` +
243
+ 'renders correctly on a wide canvas and has no drawer on a phone, no dropdown for a ' +
244
+ 'sub-level, and no active state on the page the visitor is already reading — all three ' +
245
+ 'are things the menu element brings and a row of buttons cannot.',
246
+ fix: fill('handbuilt_menu', {}),
247
+ });
248
+ }
177
249
  for (const id of walkOrder) {
178
250
  if (id === d.root_node_id)
179
251
  continue;
@@ -208,7 +208,38 @@ export function liveTokenFor(ctx) {
208
208
  // carries the whole account.
209
209
  return () => (ctx.apiKey ? ctx.apiKey : ctx.session.token());
210
210
  }
211
+ /**
212
+ * Open the room and hand it to the page session.
213
+ *
214
+ * EXTRACTED so joining is not something an agent has to REMEMBER. `sb_live_join`
215
+ * was opt-in and one call, which reads as cheap and is not: an agent that never
216
+ * makes it builds an entire site the watching merchant cannot see happening —
217
+ * no peer, no cursor, no element appearing as it lands. Nothing fails, so
218
+ * nothing prompts the question, and the person who asked for an agent watches a
219
+ * static canvas and concludes it is not working.
220
+ *
221
+ * Measured here: one session built 17 pages and 19 products over two hours with
222
+ * an editor open beside it and never joined.
223
+ *
224
+ * `PageSession.ensureLive` calls this on the first page open, which is where
225
+ * designing starts. The explicit tool stays, for a caller who wants to join a
226
+ * different site or re-join after a drop.
227
+ */
228
+ export function joinRoom(ctx, session, siteId) {
229
+ const tokenFn = liveTokenFor(ctx);
230
+ const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
231
+ const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(siteId)}`, tokenFn);
232
+ const live = new LiveSession(socket, {
233
+ onRemote: (patches) => session.applyRemote(patches),
234
+ onDesync: (reason) => session.markStale(reason),
235
+ });
236
+ socket.connect();
237
+ session.attachLive(live);
238
+ }
211
239
  export function registerLiveTools(server, ctx, session) {
240
+ // The page session joins on its own at the first sb_page_open. Registered
241
+ // here because this module owns the socket and the session must not import it.
242
+ session.setLiveJoiner((siteId) => joinRoom(ctx, session, siteId));
212
243
  server.registerTool('sb_live_join', {
213
244
  description: "Join the site's live-edit room as a visible peer: every write then appears in any open " +
214
245
  'editor as it happens, with the agent shown by the API key\'s own name rather than a ' +
@@ -218,15 +249,7 @@ export function registerLiveTools(server, ctx, session) {
218
249
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
219
250
  }, async ({ site_id: given }) => {
220
251
  const site_id = siteFor(ctx, given);
221
- const tokenFn = liveTokenFor(ctx);
222
- const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
223
- const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, tokenFn);
224
- const live = new LiveSession(socket, {
225
- onRemote: (patches) => session.applyRemote(patches),
226
- onDesync: (reason) => session.markStale(reason),
227
- });
228
- socket.connect();
229
- session.attachLive(live);
252
+ joinRoom(ctx, session, site_id);
230
253
  return text({
231
254
  joined: site_id,
232
255
  note: 'Edits now publish to the room as they are made. Call sb_page_open next.',
@@ -19,7 +19,7 @@ import { compactFindings } from '../domains/site/findings.js';
19
19
  import { readinessGaps, READINESS_NOTICE } from '../domains/site/readiness.js';
20
20
  import { gatherReadiness } from '../domains/site/readiness-fetch.js';
21
21
  import { globalWarning, restampPatches, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
22
- import { catalogMatches, traitsFor } from '../catalog/element-search.js';
22
+ import { catalogBrowse, catalogMatches, traitsFor } from '../catalog/element-search.js';
23
23
  import { LAYOUT_PATTERNS, PATTERN_BY_ID, THEME_TOKENS, } from '../domains/site/patterns.js';
24
24
  import { tokensFromPage } from '../domains/site/importmap.js';
25
25
  import { middleEnd } from '../domains/site/traps.js';
@@ -67,6 +67,8 @@ export class PageSession {
67
67
  * fence `restampPatches` exists to keep honest.
68
68
  */
69
69
  savedRev = -1;
70
+ /** The open read handed back an empty document and a ROOT was invented for it. */
71
+ openedSeeded = false;
70
72
  warnings = [];
71
73
  boxes = [];
72
74
  constructor(ctx) {
@@ -75,6 +77,49 @@ export class PageSession {
75
77
  attachLive(live) {
76
78
  this.live = live;
77
79
  }
80
+ /**
81
+ * How to open the live-edit room, handed over by `registerLiveTools`.
82
+ *
83
+ * A CALLBACK rather than an import, because `live.ts` already imports this
84
+ * class and the reverse edge would be a cycle. The direction that matters is
85
+ * the one the design has: the room knows about the page session, not the other
86
+ * way round.
87
+ */
88
+ liveJoiner = null;
89
+ setLiveJoiner(fn) {
90
+ this.liveJoiner = fn;
91
+ }
92
+ /**
93
+ * JOIN BEFORE THE FIRST EDIT, rather than when an agent remembers to.
94
+ *
95
+ * `sb_live_join` is one call and reads as cheap, which is exactly why it gets
96
+ * skipped: nothing fails without it. The room is simply empty, so a merchant
97
+ * watching their own site being built sees a static canvas and concludes the
98
+ * agent is not working. Measured here — one session built 17 pages and 19
99
+ * products over two hours with an editor open beside it and never joined.
100
+ *
101
+ * Opening a page is where designing starts, so that is where this fires. The
102
+ * room ALWAYS YIELDS to a human (see the yield rule), so joining early costs
103
+ * a socket and risks nothing.
104
+ *
105
+ * FAILURE IS NOT FATAL. No credential, no network, a server without the
106
+ * endpoint — none of those are reasons to refuse to open a page. The caller is
107
+ * told, and goes on designing with nobody watching, which is the old behaviour
108
+ * rather than a new one.
109
+ */
110
+ async ensureLive(siteId) {
111
+ if (this.live)
112
+ return 'already';
113
+ if (!this.liveJoiner)
114
+ return 'no live transport is registered';
115
+ try {
116
+ this.liveJoiner(siteId);
117
+ return this.live ? 'joined' : 'the live transport did not attach';
118
+ }
119
+ catch (e) {
120
+ return e.message.replace(/^sbuilder:\s*/, '').slice(0, 160);
121
+ }
122
+ }
78
123
  location() {
79
124
  this.current();
80
125
  return { siteId: this.siteId, pageId: this.pageId };
@@ -126,6 +171,19 @@ export class PageSession {
126
171
  */
127
172
  async applyAndSave(patches) {
128
173
  const d = this.current();
174
+ // EVERY WRITE CHECKS THE ROOM, not only the first page open.
175
+ //
176
+ // Joining once at open is right until the once fails. No network for a
177
+ // moment, a credential that arrived late, a server that had not brought the
178
+ // endpoint up yet — any of those leave a session that edits for an hour with
179
+ // nobody watching and no second attempt, which is the same silence this
180
+ // whole path exists to end.
181
+ //
182
+ // FREE WHEN JOINED: `ensureLive` returns on a null check, so the steady state
183
+ // costs one branch per write. A dropped socket is NOT this method's problem —
184
+ // `RealtimeSocket` owns its own reconnect with backoff, and re-attaching a
185
+ // second LiveSession over a live one would be the bug, not the fix.
186
+ await this.ensureLive(this.siteId);
129
187
  const before = new Set(validateForSave(d));
130
188
  const after = validateForSave(d.preview(patches));
131
189
  const introduced = after.filter((p) => !before.has(p));
@@ -174,8 +232,24 @@ export class PageSession {
174
232
  this.warnings = composeWarnings(src.warnings);
175
233
  // Freshly pulled IS the stored state.
176
234
  this.savedRev = this.doc.rev;
235
+ // WHAT THE READ ACTUALLY RETURNED, kept so `save` can tell a page this
236
+ // session emptied from a page that arrived empty because the read failed
237
+ // open. `PageDoc.from` seeds a ROOT for `{ root_node_id: "", nodes: {} }`,
238
+ // which is right for a page just created and catastrophic for one that has
239
+ // content — and the two are identical bytes, so only the save can judge.
240
+ this.openedSeeded = this.doc.seededRoot === true;
241
+ // A PAGE IS NOW ON THE CANVAS, so the room is joined here rather than in the
242
+ // one tool that happens to be the usual way in. `sb_page_create`,
243
+ // `sb_template_use` and `shareChrome` all open pages too, and a join wired to
244
+ // sb_page_open alone leaves every one of those editing unseen.
245
+ this.liveState = await this.ensureLive(siteId);
177
246
  return this.doc.outline();
178
247
  }
248
+ /** What the last open's join attempt did, for the tool that reports it. */
249
+ liveState = 'already';
250
+ liveStatus() {
251
+ return this.liveState;
252
+ }
179
253
  /** What the server said it could not compose when this page was opened. */
180
254
  composeWarnings() {
181
255
  return this.warnings;
@@ -245,6 +319,48 @@ export class PageSession {
245
319
  if (problems.length > 0) {
246
320
  throw new Error(`sbuilder: refusing to save — ${problems.join(' ')}`);
247
321
  }
322
+ // A READ THAT FAILED OPEN MUST NOT BECOME A WRITE THAT EMPTIES THE PAGE.
323
+ //
324
+ // `PageDoc.from` seeds a ROOT when the source comes back
325
+ // `{ root_node_id: "", nodes: {} }`. For a page the caller has just created
326
+ // that is the whole point. For a page that HAS content it is a blank tree
327
+ // this session now believes is the page, and the first save stores it over
328
+ // whatever the server holds — silently, because every later gate agrees a
329
+ // bare ROOT is a valid document.
330
+ //
331
+ // MEASURED, not reasoned about. A product template carrying 24 nodes plus a
332
+ // shared header and footer came back bare to one session and was stored
333
+ // bare. The PUBLISHED copy was untouched, so all 19 product pages kept
334
+ // rendering while the draft the editor opens was blank; the damage was
335
+ // invisible until a person opened the page, and one publish from that draft
336
+ // would have taken every one of those 19 down at once.
337
+ //
338
+ // So when the read was seeded, this asks the server what it actually holds
339
+ // before writing. The round trip is paid ONLY here — a seeded read is the
340
+ // first save of a new page or this bug, and nothing else. A page the server
341
+ // also reports as empty is genuinely new and the write goes through.
342
+ //
343
+ // Refusing rather than re-pulling and merging: the session cannot know which
344
+ // of its edits belong on the real tree, and a merge that guesses is how a
345
+ // caller ends up with a page that is neither what it built nor what was
346
+ // there. The caller re-opens and reapplies, which is the yield rule's answer
347
+ // to every other divergence and is already the reflex these tools teach.
348
+ if (this.openedSeeded) {
349
+ const stored = await loadSource(this.ctx, this.siteId, this.pageId);
350
+ const held = (stored.document ?? {});
351
+ const heldCount = Object.keys(held.nodes ?? {}).length;
352
+ if (heldCount > 0) {
353
+ this.openedSeeded = false;
354
+ await this.open(this.siteId, this.pageId);
355
+ throw new Error(`sbuilder: refusing to save — this page was read as EMPTY and a ROOT was invented for ` +
356
+ `it, but the server holds ${heldCount} node(s). Writing would have erased the page. ` +
357
+ 'It has been re-loaded from the server; re-read it with sb_outline and reapply your ' +
358
+ 'change.');
359
+ }
360
+ // The server agrees the page is empty, so the seeded ROOT is this page's
361
+ // first real tree and every save after this one is an ordinary save.
362
+ this.openedSeeded = false;
363
+ }
248
364
  const saved = await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
249
365
  // RE-STAMP THE FENCE, or lose every edit after this one.
250
366
  //
@@ -377,6 +493,8 @@ export function registerPageTools(server, ctx) {
377
493
  annotations: { readOnlyHint: true },
378
494
  }, async ({ site_id: given, page_id }) => {
379
495
  const outline = await session.open(siteFor(ctx, given), page_id);
496
+ // `open` joined the room on the way through — this only reports what it did.
497
+ const live = session.liveStatus();
380
498
  const doc = session.current();
381
499
  // A page whose stored document named its root under the app-block key
382
500
  // renders as an empty <body> and says nothing about why. Nobody else can
@@ -386,10 +504,28 @@ export function registerPageTools(server, ctx) {
386
504
  'the renderer finds no root and publishes an EMPTY BODY. The next save from here writes ' +
387
505
  'the canonical key and fixes it; publish afterwards.'
388
506
  : undefined;
507
+ // AN EMPTY READ IS REPORTED, because the caller is the only one who knows
508
+ // whether this page is supposed to be empty. A page just created reads
509
+ // this and carries on; a page that was built reads it and stops — which is
510
+ // the whole difference between noticing now and noticing after a publish.
511
+ const seeded_empty = doc.seededRoot
512
+ ? 'This page came back EMPTY and a ROOT was seeded for it. That is expected for a page ' +
513
+ 'you just created. If this page HAD content, do not edit or publish it — the draft ' +
514
+ 'read is blank, not the page: re-open it, and if it is still blank restore it from ' +
515
+ 'GET /api/sites/{siteId}/pages/{pageId}/history.'
516
+ : undefined;
389
517
  const warnings = session.composeWarnings();
390
518
  return text({
391
519
  outline,
520
+ // Said ONLY when it is news. 'already' is the steady state after the
521
+ // first open and repeating it on every page is the shape that drifts.
522
+ ...(live === 'joined'
523
+ ? { live: 'Joined the live-edit room — anyone with this site open sees these edits as they land.' }
524
+ : live === 'already'
525
+ ? {}
526
+ : { live_unavailable: live }),
392
527
  ...(blank_page_repair ? { blank_page_repair } : {}),
528
+ ...(seeded_empty ? { seeded_empty } : {}),
393
529
  ...(warnings.length ? { compose_warnings: warnings } : {}),
394
530
  ...reviewField(ctx, doc),
395
531
  });
@@ -420,15 +556,27 @@ export function registerPageTools(server, ctx) {
420
556
  return text({ node, ...(preset ? { preset } : {}), ...(warn ? { warning: warn } : {}) });
421
557
  });
422
558
  server.registerTool('sb_catalog_search', {
423
- description: 'Find an element type by what you want it to do. Four fields per match; pass detail:true ' +
424
- "for the platform's AI hints, or read them with sb_traits_for once you have chosen.",
559
+ description: 'Find an element type by what it does — or OMIT query to browse every type, the only ' +
560
+ 'way to meet one you would not have searched for. detail:true adds the AI hints, as ' +
561
+ 'does sb_traits_for.',
425
562
  inputSchema: {
426
- query: z.string(),
563
+ query: z.string().optional(),
427
564
  limit: z.number().int().min(1).max(30).optional().describe('Default 8'),
428
565
  detail: z.boolean().optional().describe('Include useWhen / avoidWhen / contentTips per match'),
429
566
  },
430
567
  annotations: { readOnlyHint: true },
431
- }, async ({ query, limit, detail }) => text(catalogMatches(query, { limit, detail })));
568
+ },
569
+ // A SEARCH CANNOT INTRODUCE YOU TO ANYTHING. Omitting the query browses the
570
+ // whole catalogue instead — see catalogBrowse for the measurement that made
571
+ // this necessary: a store built with these tools used 17 element types and
572
+ // hand-assembled what a dozen purpose-built ones already do.
573
+ async ({ query, limit, detail }) => text(query && query.trim()
574
+ ? catalogMatches(query, { limit, detail })
575
+ : {
576
+ elements: catalogBrowse(),
577
+ note: 'Every element type, grouped as the palette groups them. Pass one as query for ' +
578
+ 'the fields to choose by, then sb_traits_for for its controls and hints.',
579
+ }));
432
580
  server.registerTool('sb_traits_for', {
433
581
  description: "This element's INSPECTOR, as a person sees it: tabs, groups, and every control name — " +
434
582
  'with what each DECLARED control writes, and the AI hints for using the element. Read ' +
@@ -34,6 +34,22 @@ export async function connect(ctx, args) {
34
34
  'pass that site id to sb_page_open, or set SB_SITE once and leave site_id out. Set ' +
35
35
  'SB_EMAIL and SB_PASSWORD as well if you want account-level calls (listing sites, ' +
36
36
  'members, roles), which a key cannot make.',
37
+ // SAID HERE BECAUSE HERE IS WHERE IT IS STILL FREE.
38
+ //
39
+ // `sb_live_join` is opt-in and one call, and an agent that never makes it
40
+ // builds an entire site the watching merchant cannot see happening: no
41
+ // peer in the room, no cursor, no element appearing as it lands. Nothing
42
+ // fails, so nothing prompts the question — the room is simply empty, and
43
+ // the person who asked for an agent watches a static canvas and concludes
44
+ // the agent is not working.
45
+ //
46
+ // MEASURED: one session built 17 pages and 19 products over two hours with
47
+ // an editor open beside it and never joined, because no surface an agent
48
+ // reads on the way in mentions the room. The tool's own description says
49
+ // what it does; nothing said WHEN. Connect is that when.
50
+ live: 'Nobody watching an open editor sees these edits until sb_live_join is called. Call it ' +
51
+ 'now if a person has the site open — it takes this key\'s own name, always yields to a ' +
52
+ 'human, and is safe to leave on for the whole session.',
37
53
  };
38
54
  }
39
55
  if (!email || !password) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.48.0",
3
+ "version": "0.49.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",