sbuilder-mcp 0.36.0 → 0.37.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,18 @@ 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.37.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - sb_import and sb_import_site now take a `nav_timeout_ms` argument (5,000-120,000ms, default 30,000) to raise how long a capture waits for a slow origin to answer at all.
13
+
14
+ ### Fixed
15
+ - sb_import_site's timeout failure now reports the number of seconds the page actually waited and suggests raising `nav_timeout_ms`, instead of always naming the unmodified default even after a caller had already raised it.
16
+
17
+ ## [0.36.1] - 2026-09-11
18
+
19
+ - fix(import,api): a capture waited on iframes, and a recovery list was unreadable
20
+
9
21
  ## [0.36.0] - 2026-09-11
10
22
 
11
23
  ### Added
package/CHANGELOG.vi.md CHANGED
@@ -6,6 +6,14 @@ 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.37.0] - 2026-09-11
10
+
11
+ ### Added
12
+ - sb_import và sb_import_site giờ nhận thêm tham số `nav_timeout_ms` (5.000-120.000ms, mặc định 30.000) để tăng thời gian chờ trang trả lời khi nguồn phản hồi chậm.
13
+
14
+ ### Fixed
15
+ - Lỗi timeout của sb_import_site giờ báo đúng số giây trang thực sự đã chờ và gợi ý tăng `nav_timeout_ms`, thay vì luôn nêu giá trị mặc định chưa đổi ngay cả khi caller đã tự tăng nó.
16
+
9
17
  ## [0.36.0] - 2026-09-11
10
18
 
11
19
  ### Added
package/dist/tools/api.js CHANGED
@@ -45,6 +45,33 @@ function pickFields(item, fields) {
45
45
  * it would have been and how to narrow the call. A non-list answer is never
46
46
  * cut: there is no honest place to stop inside one object.
47
47
  */
48
+ /**
49
+ * LIST OPERATIONS WHOSE EVERY ROW CARRIES A WHOLE PAGE DOCUMENT.
50
+ *
51
+ * The page recovery surface answers with the documents themselves — which is
52
+ * right, since a restore has to have something to restore from — and useless to
53
+ * read. MEASURED against a live server: one version of a TWO-NODE page is 1,690
54
+ * bytes, so a realistic 120-node page runs about 70 KB per version and a listing
55
+ * of twenty is **1.4 MB in one answer**. An agent choosing which version to
56
+ * restore would be handed a truncated blob and no reliable way to pick.
57
+ *
58
+ * `sb_publish` already had this exact problem and the same answer: a published
59
+ * row carries `document`, `html` and `css` for every page the cascade touched,
60
+ * so it PROJECTS the rows. This is that, applied where the caller cannot know to
61
+ * ask — and it is a DEFAULT rather than a rule: an explicit `pick` still wins,
62
+ * so the document is one argument away for a caller that wants to read one.
63
+ */
64
+ const LIST_PROJECTIONS = {
65
+ 'get:/api/sites/{siteId}/pages/{pageId}/versions': [
66
+ 'id',
67
+ 'versionNo',
68
+ 'label',
69
+ 'createdBy',
70
+ 'createdAt',
71
+ 'isLive',
72
+ ],
73
+ 'get:/api/sites/{siteId}/pages/{pageId}/history': ['id', 'createdBy', 'createdAt'],
74
+ };
48
75
  export function shapeResponse(raw, opts) {
49
76
  const asked = opts.pick !== undefined || opts.max_items !== undefined;
50
77
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -271,7 +298,10 @@ export async function callOperation(ctx, args) {
271
298
  if (raw === null || raw === undefined) {
272
299
  return { ok: true, method: op.method, path, note: 'The platform answered with no content.' };
273
300
  }
274
- return shapeResponse(raw, { pick: args.pick, max_items: args.max_items });
301
+ // The caller's own `pick` outranks the default: asking for `document` is how
302
+ // you read a version rather than merely choose one.
303
+ const projection = args.pick ?? LIST_PROJECTIONS[op.id];
304
+ return shapeResponse(raw, { pick: projection, max_items: args.max_items });
275
305
  }
276
306
  export function registerApiTools(server, ctx) {
277
307
  server.registerTool('sb_api_find', {
@@ -193,6 +193,13 @@ export function registerImportTools(server, ctx, session) {
193
193
  .max(100)
194
194
  .optional()
195
195
  .describe('Default 24 — every image is an upload'),
196
+ nav_timeout_ms: z
197
+ .number()
198
+ .int()
199
+ .min(5_000)
200
+ .max(120_000)
201
+ .optional()
202
+ .describe('How long to wait for a page to answer at all. Default 30000; raise it for a slow origin'),
196
203
  max_nodes: z
197
204
  .number()
198
205
  .int()
@@ -207,7 +214,7 @@ export function registerImportTools(server, ctx, session) {
207
214
  dry_run: z.boolean().optional(),
208
215
  },
209
216
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
210
- }, async ({ url, site_id: given, max_sections, max_images, max_nodes, upload_images, dry_run }) => {
217
+ }, async ({ url, site_id: given, max_sections, max_images, max_nodes, nav_timeout_ms, upload_images, dry_run }) => {
211
218
  const siteId = siteFor(ctx, given);
212
219
  // THE TARGET PAGE MUST BE OPEN, and not only because that is where the
213
220
  // nodes go: its own heading, button and section are where the tokens come
@@ -217,6 +224,7 @@ export function registerImportTools(server, ctx, session) {
217
224
  maxSections: max_sections,
218
225
  maxImages: max_images,
219
226
  maxNodes: max_nodes,
227
+ navTimeoutMs: nav_timeout_ms,
220
228
  });
221
229
  const tokens = tokensFromPage(doc.doc);
222
230
  const images = imageSources(shot.sections);
@@ -332,13 +340,20 @@ export function registerImportTools(server, ctx, session) {
332
340
  exclude: z.array(z.string()).optional(),
333
341
  max_images: z.number().int().min(0).max(200).optional().describe('Default 24, whole import'),
334
342
  max_nodes: z.number().int().min(1).max(1000).optional().describe('Per page, default 300'),
343
+ nav_timeout_ms: z
344
+ .number()
345
+ .int()
346
+ .min(5_000)
347
+ .max(120_000)
348
+ .optional()
349
+ .describe('How long to wait for a page to answer at all. Default 30000; raise it for a slow origin'),
335
350
  upload_images: z.boolean().optional(),
336
351
  homepage: z.boolean().optional().describe("Entry into this site's home page, default true"),
337
352
  nav: z.boolean().optional().describe('Shared header linking the new pages, default true'),
338
353
  dry_run: z.boolean().optional(),
339
354
  },
340
355
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
341
- }, async ({ url, site_id: given, max_pages, depth, include, exclude, max_images, max_nodes, upload_images, homepage, nav, dry_run, }) => {
356
+ }, async ({ url, site_id: given, max_pages, depth, include, exclude, max_images, max_nodes, nav_timeout_ms, upload_images, homepage, nav, dry_run, }) => {
342
357
  const siteId = siteFor(ctx, given);
343
358
  const entry = normalizeUrl(url);
344
359
  if (!entry) {
@@ -470,7 +485,7 @@ export function registerImportTools(server, ctx, session) {
470
485
  // 9,829px of catalogue across a dozen collection bands, and 300 cut it
471
486
  // off in the middle of the third. A cap is here to stop a runaway page,
472
487
  // not to decide how much of an ordinary one survives.
473
- { maxImages: max_images ?? 60, maxNodes: max_nodes ?? 900 });
488
+ { maxImages: max_images ?? 60, maxNodes: max_nodes ?? 900, navTimeoutMs: nav_timeout_ms });
474
489
  const byUrl = new Map(shots.map((s) => [s.url, s]));
475
490
  // ONE UPLOAD PER IMAGE FOR THE WHOLE SITE, not per page. A logo, a payment
476
491
  // strip and a footer badge appear on every page of a real site, and
@@ -1075,6 +1075,19 @@ function capturePage(limits) {
1075
1075
  }
1076
1076
  restore(bestState);
1077
1077
  }
1078
+ // WHAT FRACTION OF THE PAGE A READER SEES SURVIVED, reported rather than
1079
+ // assumed. Both halves are already computed above for the fallback decision,
1080
+ // so this costs nothing and closes the one failure a capture cannot express:
1081
+ // SETTLING IS NOT FAILING. MEASURED on ttgshop.vn while the origin was taking
1082
+ // 45s to answer — 6 text nodes and 114 characters of a page holding 2,396,
1083
+ // 4.8%, `skipped` empty, no error anywhere. A thin import that says so is one
1084
+ // a caller retries; a silent one ships.
1085
+ //
1086
+ // Against the honest denominator this file already argues for: page chrome is
1087
+ // skipped ON PURPOSE, so counting it would make every correct import of a
1088
+ // nav-heavy site look broken.
1089
+ const kept = textOf(sections);
1090
+ const coverage = contentChars > 0 ? Math.round((kept / contentChars) * 100) : 100;
1078
1091
  const link = Array.from(document.querySelectorAll('link[rel="canonical"]'))[0];
1079
1092
  const canonical = link ? (link.getAttribute('href') ?? '') : '';
1080
1093
  return {
@@ -1084,6 +1097,7 @@ function capturePage(limits) {
1084
1097
  ...(forms.length ? { forms } : {}),
1085
1098
  sections,
1086
1099
  skipped,
1100
+ coverage,
1087
1101
  };
1088
1102
  }
1089
1103
  /**
@@ -1123,18 +1137,49 @@ async function withBrowser(fn) {
1123
1137
  * `shoot.ts` gives: a page with a poller never goes idle, and waiting for that
1124
1138
  * spends the whole budget on a timeout that cannot resolve.
1125
1139
  */
1126
- async function readPage(browser, url, width, work) {
1140
+ /**
1141
+ * How long to wait for a page to answer AT ALL, and why it is a knob.
1142
+ *
1143
+ * Thirty seconds is right for the web and wrong for one site on one afternoon.
1144
+ * MEASURED on ttgshop.vn, the same shop this importer was built against: it
1145
+ * answered in 4.6s all morning and then took 45 SECONDS for 151 KB, with the
1146
+ * next request not answering inside 45s at all. Nothing about the page changed
1147
+ * — the origin was simply having a bad day, which is the ordinary condition of
1148
+ * the sites a merchant actually asks to import.
1149
+ *
1150
+ * The default stays 30s, because a caller who says nothing wants a bound rather
1151
+ * than a hang. What was wrong was that the number could not be raised at all:
1152
+ * an import of your OWN slow site had no recourse, and the failure arrives as a
1153
+ * bare Playwright timeout that names the browser rather than the remedy.
1154
+ */
1155
+ const NAV_TIMEOUT_MS = 30_000;
1156
+ async function readPage(browser, url, width, work, navMs = NAV_TIMEOUT_MS, settleMs) {
1127
1157
  let page;
1128
1158
  try {
1129
1159
  page = await browser.newPage({ viewport: { width, height: 900 } });
1130
- await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
1160
+ // `domcontentloaded`, NOT `load`, and `settleDom` does the rest.
1161
+ //
1162
+ // `load` waits for every SUBRESOURCE — including third-party iframes, which
1163
+ // an import has no use for: this walk reads the iframe's `src` ATTRIBUTE and
1164
+ // never needs the frame to render. So a page carrying an ad frame, a chat
1165
+ // widget or a slow video embed stalled the whole capture for up to thirty
1166
+ // seconds and then THREW, losing an import whose DOM had been ready the
1167
+ // entire time. Caught by this repo's own test, whose fixture embeds real
1168
+ // YouTube, Vimeo and Google Maps frames: it started failing at exactly 30s
1169
+ // with nothing about the page having changed.
1170
+ //
1171
+ // The same lesson `sb_look` already paid for with `networkidle`, one wait
1172
+ // earlier: the right question is "has the DOM stopped changing", and
1173
+ // `settleDom` answers it directly and bounded. A page that genuinely needs
1174
+ // its images is the SHOOT path's problem, and that one still waits.
1175
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: navMs });
1131
1176
  // THE SAME SETTLE `sb_look` USES, not a flat sleep. A fixed 600ms is wrong
1132
1177
  // at both ends: example.com is finished long before it, and a page that
1133
1178
  // builds itself with scripts is not finished after it — which is exactly the
1134
1179
  // page an import is most likely to be pointed at. `settleDom` asks the
1135
1180
  // question actually being asked (has the page stopped changing) and answers
1136
1181
  // when it becomes true, bounded so a page that never settles is still read.
1137
- await settleDom(page);
1182
+ await settleDom(page, settleMs);
1138
1183
  return await work(page);
1139
1184
  }
1140
1185
  finally {
@@ -1149,10 +1194,32 @@ function limitsFrom(opts) {
1149
1194
  maxNodes: opts.maxNodes ?? 400,
1150
1195
  };
1151
1196
  }
1197
+ /**
1198
+ * A read that failed, said in the caller's terms.
1199
+ *
1200
+ * Playwright's own timeout names the browser and the wait — "page.goto: Timeout
1201
+ * 30000ms exceeded" — which is true and tells a merchant nothing they can act
1202
+ * on. The remedy is a number they can raise, so the message carries it.
1203
+ */
1204
+ function readFailure(e) {
1205
+ const msg = e.message ?? String(e);
1206
+ // THE NUMBER COMES OUT OF THE ERROR, never out of the default. The first
1207
+ // version of this printed NAV_TIMEOUT_MS, so a caller who had already raised
1208
+ // the budget to 90s was told the page "did not answer within 30s" — a
1209
+ // confidently wrong number, and one that sends them to change a setting they
1210
+ // had just changed.
1211
+ const hit = /Timeout (\d+)ms exceeded/.exec(msg);
1212
+ if (hit) {
1213
+ const secs = Math.round(Number(hit[1]) / 1000);
1214
+ return (`the page did not answer within ${secs}s. A slow origin is ordinary — raise ` +
1215
+ 'nav_timeout_ms and try again, or import fewer pages at once.');
1216
+ }
1217
+ return msg.slice(0, 160);
1218
+ }
1152
1219
  /** Open a URL and capture it. */
1153
1220
  export async function capture(url, opts = {}) {
1154
1221
  const limits = limitsFrom(opts);
1155
- return withBrowser((browser) => readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits)));
1222
+ return withBrowser((browser) => readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits), opts.navTimeoutMs, opts.settleMs));
1156
1223
  }
1157
1224
  /**
1158
1225
  * Capture several pages through one browser.
@@ -1169,11 +1236,11 @@ export async function captureMany(urls, opts = {}) {
1169
1236
  const out = [];
1170
1237
  for (const url of urls) {
1171
1238
  try {
1172
- const result = (await readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits)));
1239
+ const result = (await readPage(browser, url, opts.width ?? 1440, (page) => page.evaluate(capturePage, limits), opts.navTimeoutMs, opts.settleMs));
1173
1240
  out.push({ url, ok: true, result });
1174
1241
  }
1175
1242
  catch (e) {
1176
- out.push({ url, ok: false, why: e.message.slice(0, 160) });
1243
+ out.push({ url, ok: false, why: readFailure(e) });
1177
1244
  }
1178
1245
  }
1179
1246
  return out;
@@ -172,7 +172,7 @@ export async function shoot(url, opts = {}) {
172
172
  * polling widget from holding the shot forever. A page that never settles is
173
173
  * photographed anyway — a late picture beats none.
174
174
  */
175
- export async function settleDom(page) {
175
+ export async function settleDom(page, capMs = 2_000) {
176
176
  await page
177
177
  .evaluate(({ quiet, cap }) => new Promise((resolve) => {
178
178
  const start = Date.now();
@@ -194,7 +194,14 @@ export async function settleDom(page) {
194
194
  resolve();
195
195
  }
196
196
  }, 50);
197
- }), { quiet: 250, cap: 2_000 })
197
+ }),
198
+ // The CAP is a knob because 2,000 ms is right for a healthy origin and
199
+ // wrong for a struggling one. MEASURED on ttgshop.vn while it was taking
200
+ // 45s to answer: the capture returned 6 text nodes and 114 characters of
201
+ // a page that holds 2,396 — 4.8% — and reported NO error, because
202
+ // settling is not failing. A silent thin import is worse than a refused
203
+ // one, and the shoot path keeps the default it was tuned for.
204
+ { quiet: 250, cap: capMs })
198
205
  .catch(() => { });
199
206
  }
200
207
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.36.0",
3
+ "version": "0.37.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",