sbuilder-mcp 0.48.1 → 0.50.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,23 @@ 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.50.0] - 2026-09-15
10
+
11
+ ### Changed
12
+ - `sb_catalog_search` raises its `limit` ceiling from 30 to 60 so a category can be read whole; the `basic` category alone holds 39 element types, and a 30-cap silently dropped the nine an agent had never used, the same failure browsing exists to fix.
13
+ - `sb_catalog_search`'s browse note now tells the caller to pass a category name with `limit` 60 to read that whole group's descriptions at once, on top of passing a type for its fields.
14
+
15
+ ## [0.49.0] - 2026-09-15
16
+
17
+ ### Added
18
+ - `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.
19
+ - `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.
20
+ - 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.
21
+ - `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.
22
+
23
+ ### Fixed
24
+ - 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.
25
+
9
26
  ## [0.48.1] - 2026-09-14
10
27
 
11
28
  ### Fixed
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,23 @@ 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.50.0] - 2026-09-15
10
+
11
+ ### Changed
12
+ - `sb_catalog_search` nâng trần `limit` từ 30 lên 60 để có thể đọc trọn một category; riêng category `basic` đã có 39 loại phần tử, và mức trần 30 âm thầm bỏ sót chín phần tử mà agent chưa từng dùng đến — đúng lỗi mà tính năng duyệt catalog vốn sinh ra để khắc phục.
13
+ - Ghi chú duyệt catalog của `sb_catalog_search` giờ hướng dẫn người gọi truyền tên một category cùng `limit` 60 để đọc toàn bộ mô tả của nhóm đó một lần, bên cạnh việc truyền một loại phần tử để xem các trường lựa chọn của nó.
14
+
15
+ ## [0.49.0] - 2026-09-15
16
+
17
+ ### Added
18
+ - `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.
19
+ - `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.
20
+ - 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ể.
21
+ - `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.
22
+
23
+ ### Fixed
24
+ - 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.
25
+
9
26
  ## [0.48.1] - 2026-09-14
10
27
 
11
28
  ### Fixed
@@ -3,6 +3,51 @@ 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
+ }
39
+ /**
40
+ * The biggest category, which is what `limit` has to clear for a caller to read
41
+ * one group WHOLE.
42
+ *
43
+ * A group query that silently returns its first 30 of 39 is the worst answer
44
+ * available here: it looks like the whole group, and the nine it dropped are
45
+ * exactly the elements nobody knew to look for. `catalog-browse.test.ts` fails
46
+ * when a category outgrows the cap, which is the only way anyone would notice.
47
+ */
48
+ export function largestCategorySize() {
49
+ return Math.max(...Object.values(catalogBrowse()).map((g) => g.length));
50
+ }
6
51
  /**
7
52
  * Four fields to CHOOSE by.
8
53
  *
@@ -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.',
@@ -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,29 @@ 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(),
427
- limit: z.number().int().min(1).max(30).optional().describe('Default 8'),
563
+ query: z.string().optional(),
564
+ limit: z.number().int().min(1).max(60).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 a TYPE as query ' +
578
+ 'for the fields to choose by, or a CATEGORY NAME with limit 60 to read that ' +
579
+ "whole group's descriptions at once — which is how you find out what the ones " +
580
+ 'you have never used are for. sb_traits_for then has the controls and hints.',
581
+ }));
432
582
  server.registerTool('sb_traits_for', {
433
583
  description: "This element's INSPECTOR, as a person sees it: tabs, groups, and every control name — " +
434
584
  '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.1",
3
+ "version": "0.50.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",