sbuilder-mcp 0.83.3 → 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 +10 -0
- package/CHANGELOG.vi.md +10 -0
- package/dist/catalog/shapes.generated.js +4 -12
- package/dist/core/patch.js +12 -1
- package/dist/domains/site/findings.js +2 -1
- package/dist/domains/site/review.js +20 -4
- package/dist/tools/api.js +26 -1
- package/dist/tools/page.js +2 -1
- package/dist/tools/pageupdate.js +2 -1
- package/dist/tools/store.js +3 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,16 @@ 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
|
+
|
|
9
19
|
## [0.83.3] - 2026-10-11
|
|
10
20
|
|
|
11
21
|
### Fixed
|
package/CHANGELOG.vi.md
CHANGED
|
@@ -6,6 +6,16 @@ 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
|
+
|
|
9
19
|
## [0.83.3] - 2026-10-11
|
|
10
20
|
|
|
11
21
|
### Fixed
|
|
@@ -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": "
|
|
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": "
|
|
5877
|
+
"goType": "refundRequest"
|
|
5882
5878
|
},
|
|
5883
5879
|
"post:/api/sites/{siteId}/payment-transactions/{transactionId}/refund-via-gateway": {
|
|
5884
5880
|
"fields": [
|
|
5885
5881
|
{
|
|
5886
|
-
"name": "
|
|
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": "
|
|
5887
|
+
"goType": "refundRequest"
|
|
5896
5888
|
},
|
|
5897
5889
|
"post:/api/sites/{id}/plan-checkout": {
|
|
5898
5890
|
"fields": [
|
package/dist/core/patch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
452
|
-
|
|
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
|
|
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)
|
package/dist/tools/page.js
CHANGED
|
@@ -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 :
|
|
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 ` +
|
package/dist/tools/pageupdate.js
CHANGED
|
@@ -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) =>
|
|
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))
|
package/dist/tools/store.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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.
|
|
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",
|