sbuilder-mcp 0.83.2 → 0.84.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.84.0] - 2026-10-11
10
+
11
+ ### Added
12
+ - `sb_api_call` reports `unknown_keys` for body keys the handler's Go struct does not have, which the platform otherwise drops silently (for example `price` on a delivery method saves `feeCents` as 0 and still returns 201).
13
+ - `sb_review` flags `cartLine.*` bindings used outside a cart-order context as `no_data_context`.
14
+
15
+ ### Fixed
16
+ - Undoing an `sb_move` rewrites only the node that changed instead of all five nodes on the page, because document comparisons no longer depend on the key order the platform returns.
17
+ - The request shape of the `/refund` and `/refund-via-gateway` operations is now `refundRequest` with `note`, so `sb_api_call` no longer validates them against the charge shape.
18
+
19
+ ## [0.83.3] - 2026-10-11
20
+
21
+ ### Fixed
22
+ - `sb_page_create` rejects a page `type` that does not exist and lists the valid types, instead of letting the platform silently store it as `page`; a `home` type points to `is_homepage: true`.
23
+ - `sb_page_state` and `sb_page_version` take the site of the open page when `SB_SITE` is not set, instead of refusing with "no site".
24
+
9
25
  ## [0.83.2] - 2026-10-11
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.84.0] - 2026-10-11
10
+
11
+ ### Added
12
+ - `sb_api_call` báo `unknown_keys` cho các key trong body mà struct Go của handler không có, vì platform vốn bỏ chúng lặng lẽ (ví dụ `price` trên phương thức giao hàng lưu `feeCents` bằng 0 mà vẫn trả 201).
13
+ - `sb_review` báo binding `cartLine.*` dùng ngoài ngữ cảnh cart-order là `no_data_context`.
14
+
15
+ ### Fixed
16
+ - Hoàn tác một `sb_move` chỉ ghi lại node đã thay đổi thay vì cả năm node của trang, vì việc so sánh tài liệu không còn phụ thuộc thứ tự key mà platform trả về.
17
+ - Shape request của operation `/refund` và `/refund-via-gateway` giờ là `refundRequest` với `note`, nên `sb_api_call` không còn kiểm tra chúng theo shape của charge.
18
+
19
+ ## [0.83.3] - 2026-10-11
20
+
21
+ ### Fixed
22
+ - `sb_page_create` từ chối `type` trang không tồn tại và liệt kê các loại hợp lệ, thay vì để platform lặng lẽ lưu thành `page`; type `home` được chỉ sang `is_homepage: true`.
23
+ - `sb_page_state` và `sb_page_version` lấy site của trang đang mở khi không có `SB_SITE`, thay vì từ chối với lỗi "no site".
24
+
9
25
  ## [0.83.2] - 2026-10-11
10
26
 
11
27
  ### Added
@@ -5869,30 +5869,22 @@ export const REQUEST_SHAPES = {
5869
5869
  "post:/api/sites/{siteId}/payment-transactions/{transactionId}/refund": {
5870
5870
  "fields": [
5871
5871
  {
5872
- "name": "orderId",
5873
- "type": "string"
5874
- },
5875
- {
5876
- "name": "provider",
5872
+ "name": "note",
5877
5873
  "type": "string"
5878
5874
  }
5879
5875
  ],
5880
5876
  "source": "go",
5881
- "goType": "chargeRequest"
5877
+ "goType": "refundRequest"
5882
5878
  },
5883
5879
  "post:/api/sites/{siteId}/payment-transactions/{transactionId}/refund-via-gateway": {
5884
5880
  "fields": [
5885
5881
  {
5886
- "name": "orderId",
5887
- "type": "string"
5888
- },
5889
- {
5890
- "name": "provider",
5882
+ "name": "note",
5891
5883
  "type": "string"
5892
5884
  }
5893
5885
  ],
5894
5886
  "source": "go",
5895
- "goType": "chargeRequest"
5887
+ "goType": "refundRequest"
5896
5888
  },
5897
5889
  "post:/api/sites/{id}/plan-checkout": {
5898
5890
  "fields": [
@@ -1,3 +1,11 @@
1
+ /**
2
+ * The document patch primitive, mirrored from the editor's live-edit channel
3
+ * (editor/src/history/patchRecorder.ts + features/liveedit/ops.ts).
4
+ *
5
+ * The shape has to match exactly: these patches go on the wire to peers running
6
+ * the editor's own code, and a shape it does not recognise is dropped silently.
7
+ */
8
+ import { isDeepStrictEqual } from 'node:util';
1
9
  /** Segments that must never appear in a path we apply or send. */
2
10
  const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
3
11
  /**
@@ -149,7 +157,10 @@ export function applyPatches(state, patches) {
149
157
  export function documentPatches(before, after) {
150
158
  const out = [];
151
159
  for (const [id, node] of Object.entries(after.nodes)) {
152
- if (JSON.stringify(before.nodes[id]) !== JSON.stringify(node))
160
+ // Not JSON.stringify: a document read back from the platform (Postgres
161
+ // jsonb) orders keys differently from one built here, so every node
162
+ // "differed" — undoing one sb_move rewrote all five nodes of a page, live.
163
+ if (!isDeepStrictEqual(before.nodes[id], node))
153
164
  out.push({ op: 'set', path: ['nodes', id], value: node });
154
165
  }
155
166
  for (const id of Object.keys(before.nodes)) {
@@ -103,7 +103,8 @@ export const FIX = {
103
103
  'renderer resolves a page id), { "id": "<popup id>" } for popup.',
104
104
  no_data_context: 'Give it a record: put it inside a list-dataset / dataset-block, pin one with sb_bind ' +
105
105
  '(target id), or move it to a page whose type supplies "<key>" (product, category, course, ' +
106
- 'post, blog). Off those it renders its placeholder for ever.',
106
+ 'post, blog). A cartLine field goes inside a cart-order instead. Off those it renders its ' +
107
+ 'placeholder for ever.',
107
108
  unread_value: 'Write a value the renderer reads: sb_traits_for "<type>" lists them; sb_set id "<id>" ' +
108
109
  'with one, at the same breakpoint it is stored at.',
109
110
  unknown_icon: 'Write a real icon name: sb_set id "<id>", namespace <ns>, keys { "<key>": "<name>" } — the ' +
@@ -131,7 +131,20 @@ function drawsItsOwnContent(type) {
131
131
  return (BOUND_SPECIALS[type] ?? []).some((k) => !LINK_SPECIALS.has(k));
132
132
  }
133
133
  /** Binding-source prefixes that name a RECORD (not `site.*`, which every page has). */
134
- const RECORD_ENTITIES = new Set(['product', 'category', 'course', 'article', 'blogCategory', 'brand']);
134
+ // cartLine: only a cart-order's line row supplies it (render/nodes/cart-order,
135
+ // scope.PlaceholderCartLine) — see ROW_SUPPLIER. searchRow is left out: no
136
+ // element supplies it yet, so there is no right place to point at.
137
+ const RECORD_ENTITIES = new Set(['product', 'category', 'course', 'article', 'blogCategory', 'brand', 'cartLine']);
138
+ /** A record only one element type's rows supply, which is not a dataset repeater. */
139
+ const ROW_SUPPLIER = { cartLine: 'cart-order' };
140
+ /** Does this node sit inside an element of `type`? */
141
+ function insideType(d, id, type) {
142
+ for (let x = d.nodes[id]?.data.parent; x && d.nodes[x]; x = d.nodes[x].data.parent) {
143
+ if (d.nodes[x].data.type === type)
144
+ return true;
145
+ }
146
+ return false;
147
+ }
135
148
  /**
136
149
  * Which records a page TYPE supplies outside any repeater — read off the
137
150
  * platform's own seeds (STORE_PAGE_SEEDS bind exactly these at top level).
@@ -443,13 +456,16 @@ opts = {}) {
443
456
  const missing = (n.bindings ?? [])
444
457
  .map((b) => String(b?.source ?? '').split('.')[0])
445
458
  .find((e) => RECORD_ENTITIES.has(e) && !entities.includes(e));
446
- if (missing && !pinnedAbove(d, id)) {
459
+ const supplied = missing && ROW_SUPPLIER[missing] ? insideType(d, id, ROW_SUPPLIER[missing]) : false;
460
+ if (missing && !supplied && !pinnedAbove(d, id)) {
447
461
  out.push({
448
462
  code: 'no_data_context',
449
463
  nodeId: id,
450
464
  type,
451
- problem: `Bound to a ${missing} record, but it sits in no repeater and a "${opts.pageType}" ` +
452
- 'page supplies none — it renders its placeholder on the published page.',
465
+ problem: (ROW_SUPPLIER[missing]
466
+ ? `Bound to a ${missing} field, but it sits outside any ${ROW_SUPPLIER[missing]} — only its line rows supply one, so it renders its placeholder on the published page.`
467
+ : `Bound to a ${missing} record, but it sits in no repeater and a "${opts.pageType}" ` +
468
+ 'page supplies none — it renders its placeholder on the published page.'),
453
469
  key: missing,
454
470
  fix: fill('no_data_context', { id, key: missing }),
455
471
  });
package/dist/tools/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { z } from 'zod';
2
3
  import { API_OPERATIONS, SWAGGER_SOURCE } from '../catalog/api.generated.js';
3
4
  import { REQUEST_SHAPES } from '../catalog/shapes.generated.js';
@@ -54,12 +55,33 @@ export function deepMerge(base, over) {
54
55
  out[k] = deepMerge(base[k], v);
55
56
  return out;
56
57
  }
58
+ /**
59
+ * Top-level body keys the handler's Go struct has no field for. encoding/json
60
+ * DROPS them without a word, so a misspelt field is a write that "worked":
61
+ * measured, `price: 30000` on a shipping method stored `feeCents: 0`. Matched
62
+ * case-insensitively, as Go's decoder does. Silent when the shape is unknown or
63
+ * partial — a warning there would be a guess.
64
+ */
65
+ export function unknownBodyKeys(opId, body) {
66
+ const shape = REQUEST_SHAPES[opId];
67
+ if (!shape || shape.partial || !isPlain(body))
68
+ return undefined;
69
+ const known = new Set(shape.fields.map((f) => f.name.toLowerCase()));
70
+ const keys = Object.keys(body).filter((k) => !known.has(k.toLowerCase()));
71
+ if (!keys.length)
72
+ return undefined;
73
+ return {
74
+ keys,
75
+ note: `Not fields of ${shape.goType}: the platform ignores them silently. ` +
76
+ `Its fields: ${shape.fields.map((f) => f.name).join(', ')}.`,
77
+ };
78
+ }
57
79
  /** Leaf paths where `b` differs from `a` (arrays compared whole). */
58
80
  function changedPaths(a, b, at = '') {
59
81
  if (isPlain(a) && isPlain(b)) {
60
82
  return [...new Set([...Object.keys(a), ...Object.keys(b)])].flatMap((k) => changedPaths(a[k], b[k], at ? `${at}.${k}` : k));
61
83
  }
62
- return JSON.stringify(a) === JSON.stringify(b) ? [] : [at];
84
+ return isDeepStrictEqual(a, b) ? [] : [at];
63
85
  }
64
86
  const isEmpty = (v) => v === null || v === undefined || v === '' || v === false || v === 0 ||
65
87
  (Array.isArray(v) && v.length === 0) || (isPlain(v) && Object.keys(v).length === 0);
@@ -604,6 +626,7 @@ export async function callOperation(ctx, args) {
604
626
  `${dryRun ? 'would be' : 'were'} erased: ${listPaths(erased)}. ` +
605
627
  (dryRun ? 'Pass merge:true to keep them.' : 'sb_undo puts them back.')
606
628
  : undefined;
629
+ const unknownKeys = unknownBodyKeys(op.id, args.file ? undefined : args.body);
607
630
  // If-Match: given, or — on a route that refuses a write without one — the
608
631
  // version the GET answers right now.
609
632
  const headers = {};
@@ -672,6 +695,7 @@ export async function callOperation(ctx, args) {
672
695
  ...(download ? { would_save: args.file.path } : {}),
673
696
  ...(merged ? { merge: merged } : {}),
674
697
  ...(replaceWarning ? { replace_warning: replaceWarning } : {}),
698
+ ...(unknownKeys ? { unknown_keys: unknownKeys } : {}),
675
699
  // Shaping is part of what the call WOULD do, so the preview says it;
676
700
  // otherwise a dry run reads identically whether or not it was asked for.
677
701
  ...(args.pick !== undefined || args.max_items !== undefined || args.item_offset !== undefined
@@ -745,6 +769,7 @@ export async function callOperation(ctx, args) {
745
769
  ...(ifMatchRead ? { if_match: { sent: headers['If-Match'], read_from: `GET ${ifMatchRead}` } } : {}),
746
770
  ...(merged ? { merge: merged } : {}),
747
771
  ...(replaceWarning ? { replace_warning: replaceWarning } : {}),
772
+ ...(unknownKeys ? { unknown_keys: unknownKeys } : {}),
748
773
  };
749
774
  const told = Object.keys(extra).length && isPlain(answer) ? { ...answer, ...extra } : answer;
750
775
  if (!op.raw)
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { z } from 'zod';
2
3
  import { composeWarnings } from '../domains/site/findings.js';
3
4
  import { text } from '../mcp/response.js';
@@ -681,7 +682,7 @@ export class PageSession {
681
682
  const d = this.current();
682
683
  // Rev when both sides have one; an older platform reports none, and the
683
684
  // document is then compared whole — a false refusal is the safe failure.
684
- const moved = fence.rev > 0 && this.sourceRev > 0 ? this.sourceRev !== fence.rev : JSON.stringify(d.doc) !== fence.json;
685
+ const moved = fence.rev > 0 && this.sourceRev > 0 ? this.sourceRev !== fence.rev : !isDeepStrictEqual(d.doc, JSON.parse(fence.json));
685
686
  if (moved) {
686
687
  throw new Error(`sbuilder: refusing to undo — page ${pageId}'s draft moved since this process last saved it ` +
687
688
  `(rev ${this.sourceRev}, last stored here ${fence.rev}): someone else saved it, and restoring ` +
@@ -2227,6 +2228,14 @@ export function registerPageTools(server, ctx) {
2227
2228
  // its own type is what gives it its icon and tells the next reader what it
2228
2229
  // is for. Only when the caller named no type; an explicit one wins.
2229
2230
  const askedType = type;
2231
+ // AN UNKNOWN TYPE IS STORED AS "page", SILENTLY (page.go validType). Measured:
2232
+ // type "home" created a plain page at /home and answered 201, so the site
2233
+ // still had no homepage and nothing said so.
2234
+ if (type !== undefined && !PAGE_TYPES.some((t) => t.type === type)) {
2235
+ throw new Error(`sbuilder: page type "${type}" does not exist — the platform would store it as "page". ` +
2236
+ (/^home(page)?$/i.test(type) ? 'A homepage is is_homepage:true on any page. ' : '') +
2237
+ `Types: ${PAGE_TYPES.map((t) => t.type).join(', ')}.`);
2238
+ }
2230
2239
  type ??= purposeTypeForName(name);
2231
2240
  // TYPE IS THE ROUTE for several kinds of page: /checkout and
2232
2241
  // /products/{slug} resolve to the site's PUBLISHED page of that type and
@@ -2590,7 +2599,9 @@ export function registerPageTools(server, ctx) {
2590
2599
  },
2591
2600
  annotations: { readOnlyHint: true },
2592
2601
  }, async ({ site_id: given, page_id }) => {
2593
- const site_id = siteFor(ctx, given);
2602
+ // The open page's site, like its page id: with no SB_SITE this refused a
2603
+ // call on the page it had just been pointed at.
2604
+ const site_id = siteFor(ctx, given ?? (page_id ? undefined : session.page()?.siteId));
2594
2605
  // No open page is fine when page_id names one — `location()` throws.
2595
2606
  const here = session.page() ?? { siteId: '', pageId: '' };
2596
2607
  const pageId = page_id ?? here.pageId;
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from 'node:util';
1
2
  import { z } from 'zod';
2
3
  import { text } from '../mcp/response.js';
3
4
  import { request, redact } from '../transport/http.js';
@@ -87,7 +88,7 @@ export function registerPageUpdateTools(server, ctx) {
87
88
  // courseGate, courseLesson and keys unknown here — so the write is always the
88
89
  // whole old blob with only the named keys moved, as the editor's PageSeoDialog does.
89
90
  const changes = {};
90
- const differs = (a, b) => JSON.stringify(a) !== JSON.stringify(b);
91
+ const differs = (a, b) => !isDeepStrictEqual(a, b);
91
92
  const settings = { ...old };
92
93
  for (const [k, v] of Object.entries(seo ?? {})) {
93
94
  if (differs(old[k], v))
@@ -30,8 +30,8 @@ export function registerPageVersionTools(server, ctx, session) {
30
30
  },
31
31
  annotations: { readOnlyHint: false, destructiveHint: true },
32
32
  }, async ({ action, site_id: given, page_id, label, version_id, history_id, dry_run }) => {
33
- const siteId = siteFor(ctx, given);
34
33
  const open = session.page();
34
+ const siteId = siteFor(ctx, given ?? (page_id ? undefined : open?.siteId));
35
35
  const pageId = page_id ?? (open?.siteId === siteId ? open.pageId : undefined);
36
36
  if (!pageId)
37
37
  throw new Error('sbuilder: sb_page_version needs page_id, or a page opened with sb_page_open.');
@@ -34,6 +34,7 @@
34
34
  * `app.ts`) and are dispatched from here; the checkout and `form` are written
35
35
  * out below, because they came first.
36
36
  */
37
+ import { isDeepStrictEqual } from 'node:util';
37
38
  import { z } from 'zod';
38
39
  import { text } from '../mcp/response.js';
39
40
  import { request, redact, ApiError } from '../transport/http.js';
@@ -121,7 +122,7 @@ export function syncOrderDocument(doc, fresh, shipping) {
121
122
  if (sp?.name === 'payment_method') {
122
123
  const had = new Map((sp.methods ?? []).map((m) => [m.value, m]));
123
124
  const next = fresh.map((m) => had.get(m.value) ?? m);
124
- if (JSON.stringify(next) !== JSON.stringify(sp.methods ?? [])) {
125
+ if (!isDeepStrictEqual(next, sp.methods ?? [])) {
125
126
  sp.methods = next;
126
127
  if (!next.some((m) => m.value === sp.defaultValue))
127
128
  sp.defaultValue = next[0]?.value ?? '';
@@ -130,7 +131,7 @@ export function syncOrderDocument(doc, fresh, shipping) {
130
131
  out.payment_methods = next.map((m) => m.value);
131
132
  }
132
133
  if (sp?.name === 'shipping_method') {
133
- if (JSON.stringify(sp.options ?? []) !== JSON.stringify(shipping)) {
134
+ if (!isDeepStrictEqual(sp.options ?? [], shipping)) {
134
135
  sp.options = [...shipping];
135
136
  out.changed = true;
136
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.83.2",
3
+ "version": "0.84.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",