sbuilder-mcp 0.63.0 → 0.63.2

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,19 @@ 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.63.2] - 2026-09-24
10
+
11
+ ### Fixed
12
+ - The live-edit socket now holds frames sent before the server's welcome instead of dropping them, so a section added right after `sb_page_open` reaches an editor watching the page instead of leaving it showing "This page is empty" and risking that empty state being autosaved over the agent's work; the held queue is capped at 500 frames so a long reconnect loop cannot grow memory without bound.
13
+
14
+ ## [0.63.1] - 2026-09-24
15
+
16
+ ### Fixed
17
+ - Live-edit frames now carry the open page's id from the moment a room is joined and whenever a new page is opened afterward, so the editor's own per-page filter no longer drops every ops, cursor, and select frame this server sends.
18
+ - A second `sb_live_join`, or opening a page on a different site while already in a room, now leaves the previous room instead of leaving its socket connected and reconnecting for the rest of the process.
19
+ - Opening a page on a site other than the one the current live room belongs to now leaves that room first, so a failed join can no longer strand the session in the wrong site's room.
20
+ - The server process now exits when the MCP client closes stdin, instead of being kept alive indefinitely by the live-room socket and its reconnect timer.
21
+
9
22
  ## [0.63.0] - 2026-09-21
10
23
 
11
24
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,19 @@ 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.63.2] - 2026-09-24
10
+
11
+ ### Fixed
12
+ - Socket live-edit giờ giữ lại các frame được gửi trước khi server chào (welcome) thay vì loại bỏ chúng, để một section được thêm ngay sau `sb_page_open` đến được với editor đang xem trang thay vì để trang đó hiển thị "This page is empty" và có nguy cơ trạng thái rỗng đó bị autosave đè lên công việc của agent; hàng đợi giữ frame bị giới hạn ở 500 frame để một vòng lặp reconnect kéo dài không thể làm bộ nhớ tăng vô hạn.
13
+
14
+ ## [0.63.1] - 2026-09-24
15
+
16
+ ### Fixed
17
+ - Các frame live-edit giờ mang theo id của trang đang mở ngay từ lúc vào phòng và mỗi khi một trang mới được mở sau đó, để bộ lọc theo từng trang của chính editor không còn loại bỏ mọi frame ops, cursor và select mà server này gửi đi.
18
+ - Gọi `sb_live_join` lần thứ hai, hoặc mở một trang thuộc site khác trong khi đang ở trong một phòng, giờ sẽ rời phòng cũ thay vì để socket của nó tiếp tục kết nối và tự reconnect trong suốt phần đời còn lại của tiến trình.
19
+ - Mở một trang thuộc site khác với site của phòng live hiện tại giờ sẽ rời phòng đó trước, để một lần join thất bại không còn có thể khiến session bị mắc kẹt trong phòng của site sai.
20
+ - Tiến trình server giờ thoát khi client MCP đóng stdin, thay vì bị giữ sống vô thời hạn bởi socket của phòng live và timer reconnect của nó.
21
+
9
22
  ## [0.63.0] - 2026-09-21
10
23
 
11
24
  ### Added
package/dist/index.js CHANGED
@@ -11,6 +11,14 @@ async function main() {
11
11
  }
12
12
  const server = createServer();
13
13
  await server.connect(new StdioServerTransport());
14
+ // THE CLIENT CLOSED STDIN, SO THIS PROCESS IS DONE. The SDK does not exit on
15
+ // its own, and the live-room socket (plus its reconnect timer) keeps the event
16
+ // loop alive — an orphaned process that answers the server's pings, so the
17
+ // agent's avatar stays in the room indefinitely. Exiting closes the socket and
18
+ // the server publishes the leave.
19
+ const done = () => process.exit(0);
20
+ process.stdin.once('end', done);
21
+ process.stdin.once('close', done);
14
22
  // stdout is the MCP channel. Every log line in this repo is console.error.
15
23
  console.error('[sbuilder-mcp] ready on stdio');
16
24
  }
@@ -65,6 +65,10 @@ export class LiveSession {
65
65
  if (this.selfId)
66
66
  this.announcePage();
67
67
  }
68
+ /** Leave the room: the socket closes, the server publishes our `leave`. */
69
+ close() {
70
+ this.socket.close();
71
+ }
68
72
  announcePage() {
69
73
  if (!this.pageId)
70
74
  return;
@@ -261,7 +261,7 @@ export function joinRoom(ctx, session, siteId) {
261
261
  onDesync: (reason) => session.markStale(reason),
262
262
  });
263
263
  socket.connect();
264
- session.attachLive(live);
264
+ session.attachLive(live, siteId);
265
265
  }
266
266
  export function registerLiveTools(server, ctx, session) {
267
267
  // The page session joins on its own at the first sb_page_open. Registered
@@ -61,6 +61,8 @@ export class PageSession {
61
61
  siteId = '';
62
62
  pageId = '';
63
63
  live = null;
64
+ /** Which site's room `live` is in — a page on another site needs another room. */
65
+ liveSite = '';
64
66
  stale = null;
65
67
  /**
66
68
  * The document revision this session last stored, so an unchanged document is
@@ -79,8 +81,28 @@ export class PageSession {
79
81
  constructor(ctx) {
80
82
  this.ctx = ctx;
81
83
  }
82
- attachLive(live) {
84
+ /**
85
+ * ONE ROOM AT A TIME. A second join (sb_live_join again, or a page on another
86
+ * site) used to overwrite `live` and leave the old socket reconnecting for the
87
+ * life of the process — a second robot in the old site's room, forever.
88
+ *
89
+ * And the page is announced HERE as well as on open: `start` was never called
90
+ * anywhere, so every ops/cursor/select frame went out with `pageId: ''` and
91
+ * the editor, which filters by page, dropped all of them.
92
+ */
93
+ attachLive(live, siteId) {
94
+ if (this.live !== live)
95
+ this.live?.close();
83
96
  this.live = live;
97
+ this.liveSite = siteId;
98
+ if (this.pageId && this.siteId === siteId)
99
+ live.start(this.pageId);
100
+ }
101
+ /** Leave the room, if in one. */
102
+ leaveLive() {
103
+ this.live?.close();
104
+ this.live = null;
105
+ this.liveSite = '';
84
106
  }
85
107
  /**
86
108
  * How to open the live-edit room, handed over by `registerLiveTools`.
@@ -113,10 +135,13 @@ export class PageSession {
113
135
  * rather than a new one.
114
136
  */
115
137
  async ensureLive(siteId) {
116
- if (this.live)
138
+ if (this.live && this.liveSite === siteId)
117
139
  return 'already';
118
140
  if (!this.liveJoiner)
119
141
  return 'no live transport is registered';
142
+ // Out of the old site's room first, so a failed join below cannot leave
143
+ // this process sitting in a room for a site it is no longer editing.
144
+ this.leaveLive();
120
145
  try {
121
146
  this.liveJoiner(siteId);
122
147
  return this.live ? 'joined' : 'the live transport did not attach';
@@ -314,6 +339,11 @@ export class PageSession {
314
339
  // `sb_template_use` and `shareChrome` all open pages too, and a join wired to
315
340
  // sb_page_open alone leaves every one of those editing unseen.
316
341
  this.liveState = await this.ensureLive(siteId);
342
+ // Tell the room this page. A fresh join already did (attachLive); an
343
+ // existing one has only heard of the previous page. Without it the editor
344
+ // drops every frame this session sends.
345
+ if (this.liveState === 'already')
346
+ this.live?.start(pageId);
317
347
  return this.doc.outline();
318
348
  }
319
349
  /** What the last open's join attempt did, for the tool that reports it. */
@@ -11,6 +11,8 @@ import { agentIdentity } from './identity.js';
11
11
  const OPEN = 1;
12
12
  const RETRY_BASE_MS = 500;
13
13
  const RETRY_MAX_MS = 15_000;
14
+ /** Frames held while not ready. A reconnect loop must not grow memory forever. */
15
+ const QUEUE_MAX = 500;
14
16
  export class RealtimeSocket {
15
17
  url;
16
18
  token;
@@ -20,6 +22,19 @@ export class RealtimeSocket {
20
22
  attempt = 0;
21
23
  handlers = [];
22
24
  timer = null;
25
+ /** The server has welcomed THIS connection — frames sent now are read. */
26
+ ready = false;
27
+ /**
28
+ * Frames sent while not ready, flushed on the welcome.
29
+ *
30
+ * `sb_page_open` starts the connection and returns; the `sb_add` right after
31
+ * it ran before the handshake finished, and a send that DROPPED on a
32
+ * not-open socket lost it without a trace. The editor watching the page never
33
+ * saw the section appear — it showed an empty page, and its next autosave
34
+ * could write that empty page over the agent's work. Same for every edit made
35
+ * during a reconnect.
36
+ */
37
+ queue = [];
23
38
  constructor(url,
24
39
  /**
25
40
  * A GETTER, read per attempt. The access token lives ~15 minutes and
@@ -44,6 +59,7 @@ export class RealtimeSocket {
44
59
  const ws = this.factory(this.url);
45
60
  this.ws = ws;
46
61
  this.attempt += 1;
62
+ this.ready = false;
47
63
  ws.onopen = () => {
48
64
  // Auth is the FIRST message, never a header (a browser cannot set one on a
49
65
  // WebSocket) and never a query parameter (a token in a URL lands in logs).
@@ -92,10 +108,17 @@ export class RealtimeSocket {
92
108
  catch {
93
109
  return; // a malformed frame must never throw into the socket
94
110
  }
111
+ // Ready BEFORE the handlers, flushed AFTER them: the page announcement
112
+ // the welcome triggers goes out directly, ahead of the ops held for it.
113
+ const held = this.ready ? [] : this.queue.splice(0);
114
+ this.ready = true;
95
115
  for (const h of this.handlers)
96
116
  h(parsed);
117
+ for (const f of held)
118
+ ws.send(f);
97
119
  };
98
120
  ws.onclose = () => {
121
+ this.ready = false;
99
122
  if (this.closed)
100
123
  return;
101
124
  const wait = Math.min(RETRY_BASE_MS * 2 ** this.attempt, RETRY_MAX_MS);
@@ -109,12 +132,21 @@ export class RealtimeSocket {
109
132
  send(e) {
110
133
  if (this.closed)
111
134
  return;
112
- if (this.ws?.readyState !== OPEN)
135
+ const frame = JSON.stringify(e);
136
+ if (this.ready && this.ws?.readyState === OPEN) {
137
+ this.ws.send(frame);
113
138
  return;
114
- this.ws.send(JSON.stringify(e));
139
+ }
140
+ if (this.queue.length >= QUEUE_MAX) {
141
+ // ponytail: drops the oldest past the cap; the peer re-pulls on the gap.
142
+ console.error('[sbuilder-mcp] live queue full, dropping the oldest frame');
143
+ this.queue.shift();
144
+ }
145
+ this.queue.push(frame);
115
146
  }
116
147
  close() {
117
148
  this.closed = true;
149
+ this.queue = [];
118
150
  if (this.timer)
119
151
  clearTimeout(this.timer);
120
152
  this.ws?.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.63.0",
3
+ "version": "0.63.2",
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",