sbuilder-mcp 0.2.2 → 0.3.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 +22 -0
- package/CHANGELOG.vi.md +22 -0
- package/README.md +11 -4
- package/README.vi.md +11 -4
- package/dist/catalog/api.generated.js +1350 -127
- package/dist/catalog/elements.generated.js +345 -2
- package/dist/core/tree.js +9 -0
- package/dist/domains/site/builder.js +5 -9
- package/dist/domains/site/document.js +34 -2
- package/dist/domains/site/node.js +50 -7
- package/dist/domains/site/readiness.js +41 -0
- package/dist/domains/site/review.js +23 -2
- package/dist/domains/site/traps.js +38 -1
- package/dist/install/index.js +40 -0
- package/dist/server.js +7 -1
- package/dist/tools/api.js +15 -1
- package/dist/tools/context.js +18 -1
- package/dist/tools/live.js +169 -15
- package/dist/tools/page.js +29 -17
- package/dist/tools/session.js +35 -10
- package/dist/transport/media.js +26 -5
- package/dist/vision/measure.js +19 -3
- package/dist/vision/shoot.js +39 -0
- package/package.json +1 -1
package/dist/tools/session.js
CHANGED
|
@@ -23,10 +23,16 @@ export async function connect(ctx, args) {
|
|
|
23
23
|
user: 'api key',
|
|
24
24
|
sites: [],
|
|
25
25
|
api_key: 'present',
|
|
26
|
+
...(ctx.siteId ? { site: ctx.siteId } : {}),
|
|
26
27
|
operations: API_OPERATIONS.length,
|
|
27
|
-
note:
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
note: ctx.siteId
|
|
29
|
+
? `Connected with an API key alone, on site ${ctx.siteId} (SB_SITE). Every tool ` +
|
|
30
|
+
'defaults to it, so site_id is optional. Set SB_EMAIL and SB_PASSWORD as well if ' +
|
|
31
|
+
'you want account-level calls (listing sites, members, roles), which a key cannot make.'
|
|
32
|
+
: 'Connected with an API key alone. It is bound to one site, so there is no site list — ' +
|
|
33
|
+
'pass that site id to sb_page_open, or set SB_SITE once and leave site_id out. Set ' +
|
|
34
|
+
'SB_EMAIL and SB_PASSWORD as well if you want account-level calls (listing sites, ' +
|
|
35
|
+
'members, roles), which a key cannot make.',
|
|
30
36
|
};
|
|
31
37
|
}
|
|
32
38
|
if (!email || !password) {
|
|
@@ -70,11 +76,30 @@ export function registerSessionTools(server, ctx) {
|
|
|
70
76
|
description: 'List the sites this account can operate.',
|
|
71
77
|
inputSchema: {},
|
|
72
78
|
annotations: { readOnlyHint: true },
|
|
73
|
-
}, async () =>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
}, async () => {
|
|
80
|
+
// A KEY CANNOT ANSWER THIS, and saying "not logged in — call sb_connect
|
|
81
|
+
// first" was the wrong instruction: sb_connect SUCCEEDS on a key-only
|
|
82
|
+
// install and this call still fails, so the caller retries the thing that
|
|
83
|
+
// already worked. `GET /api/sites` means "this human's account", which a
|
|
84
|
+
// key is deliberately not.
|
|
85
|
+
if (!ctx.session.loggedIn()) {
|
|
86
|
+
if (ctx.apiKey) {
|
|
87
|
+
throw new Error('sbuilder: listing sites is an ACCOUNT call and an API key addresses one site, so ' +
|
|
88
|
+
'the platform refuses it by design — sb_connect will not change that. ' +
|
|
89
|
+
(ctx.siteId
|
|
90
|
+
? `This install works on ${ctx.siteId} (SB_SITE).`
|
|
91
|
+
: 'Set SB_SITE to the site you mean.') +
|
|
92
|
+
' Set SB_EMAIL and SB_PASSWORD if you genuinely need the account-level list.');
|
|
93
|
+
}
|
|
94
|
+
throw new Error('sbuilder: no credential. Set SB_TOKEN to an API key, or SB_EMAIL and SB_PASSWORD ' +
|
|
95
|
+
'and call sb_connect.');
|
|
96
|
+
}
|
|
97
|
+
return text(await request({
|
|
98
|
+
base: ctx.base,
|
|
99
|
+
method: 'GET',
|
|
100
|
+
path: '/api/sites',
|
|
101
|
+
token: ctx.session.token(),
|
|
102
|
+
fetchImpl: ctx.fetchImpl,
|
|
103
|
+
}));
|
|
104
|
+
});
|
|
80
105
|
}
|
package/dist/transport/media.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
45
45
|
// No Content-Type header: fetch must set it itself so the multipart boundary
|
|
46
46
|
// matches the body it just built. Setting it by hand is the classic way to
|
|
47
47
|
// make a valid upload unparseable at the other end.
|
|
48
|
-
const
|
|
48
|
+
const post = (path) => doFetch(`${ctx.base.replace(/\/$/, '')}${path}`, {
|
|
49
49
|
method: 'POST',
|
|
50
50
|
// Content-Type stays ABSENT — fetch writes it with the boundary it just
|
|
51
51
|
// built — but the identity headers belong here as much as on any other call:
|
|
@@ -57,6 +57,25 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
57
57
|
},
|
|
58
58
|
body: form,
|
|
59
59
|
});
|
|
60
|
+
let res = await post(`/api/media/${encodeURIComponent(siteId)}`);
|
|
61
|
+
// THE PARTNER SURFACE IS A SECOND DOOR, and it is the key's own.
|
|
62
|
+
//
|
|
63
|
+
// `/api/media` takes a key only since `feat(media): a wbk_ API key may upload`
|
|
64
|
+
// — so against a deployment older than that commit it answers 401 to a key
|
|
65
|
+
// that is perfectly valid, and the message below then blamed the key's scopes.
|
|
66
|
+
// Measured: a key holding media.read + media.write, on its own site, refused
|
|
67
|
+
// by a server binary 26 minutes older than the fix, while `POST /api/v1/media`
|
|
68
|
+
// — multipart too, and keyed BY DEFINITION, since /api/v1 accepts nothing else
|
|
69
|
+
// — answered 201 for the same bytes.
|
|
70
|
+
//
|
|
71
|
+
// So a refusal here is not the end of the road, and retrying costs one request
|
|
72
|
+
// on a path that was failing anyway. Only when BOTH doors refuse is the key
|
|
73
|
+
// itself the suspect.
|
|
74
|
+
if ((res.status === 401 || res.status === 403) && ctx.apiKey) {
|
|
75
|
+
const viaPartner = await post('/api/v1/media');
|
|
76
|
+
if (viaPartner.ok)
|
|
77
|
+
res = viaPartner;
|
|
78
|
+
}
|
|
60
79
|
const raw = await res.text();
|
|
61
80
|
let parsed;
|
|
62
81
|
try {
|
|
@@ -83,10 +102,12 @@ export async function uploadMedia(ctx, siteId, source) {
|
|
|
83
102
|
// credential was used, and the old text sent people to fix something that
|
|
84
103
|
// was never broken.
|
|
85
104
|
if ((res.status === 401 || res.status === 403) && ctx.apiKey && !ctx.session.loggedIn()) {
|
|
86
|
-
throw new ApiError(res.status, 'media_key_refused', 'sbuilder:
|
|
87
|
-
'
|
|
88
|
-
'site
|
|
89
|
-
|
|
105
|
+
throw new ApiError(res.status, 'media_key_refused', 'sbuilder: both upload doors refused this API key — /api/media and the partner surface ' +
|
|
106
|
+
'/api/v1/media. Two causes fit. The key: it needs media.write and must belong to THIS ' +
|
|
107
|
+
'site, since a key minted for another one is refused before the upload is read. Or the ' +
|
|
108
|
+
'DEPLOYMENT: /api/media took keys only from "feat(media): a wbk_ API key may upload", ' +
|
|
109
|
+
'so a server older than that refuses a perfectly good key — check the running build ' +
|
|
110
|
+
"before changing the key. Failing both, set SB_EMAIL / SB_PASSWORD to upload as a person.");
|
|
90
111
|
}
|
|
91
112
|
const env = (parsed ?? {});
|
|
92
113
|
const fieldText = env.fields
|
package/dist/vision/measure.js
CHANGED
|
@@ -16,13 +16,22 @@ function overlaps(a, b) {
|
|
|
16
16
|
* 1440 and spills at 390 is the ordinary responsive failure, and saying which
|
|
17
17
|
* width it happened at is most of the fix.
|
|
18
18
|
*/
|
|
19
|
-
export function measureShot(shot) {
|
|
19
|
+
export function measureShot(shot, skip = new Set()) {
|
|
20
20
|
const out = [];
|
|
21
21
|
const byId = new Map(shot.boxes.map((b) => [b.id, b]));
|
|
22
22
|
const seen = new Set();
|
|
23
23
|
for (const b of shot.boxes) {
|
|
24
24
|
if (b.id === 'ROOT')
|
|
25
25
|
continue;
|
|
26
|
+
// AN OVERLAY IS OFF-SCREEN ON PURPOSE. The cart drawer is parked outside
|
|
27
|
+
// the viewport until a shopper opens it, so every node inside it measures
|
|
28
|
+
// as off-canvas — 24 findings on one page, on every page carrying the
|
|
29
|
+
// drawer, at every width. They are also not this page's to fix: an overlay
|
|
30
|
+
// is composed onto ROOT on read and stripped on write (trap 1), which is
|
|
31
|
+
// why `reviewDesign` already skips them and this had to as well. A list
|
|
32
|
+
// that is two dozen false positives long is a list nobody reads.
|
|
33
|
+
if (skip.has(b.id))
|
|
34
|
+
continue;
|
|
26
35
|
// OFF-CANVAS. A negative x, or a right edge past the viewport, is content
|
|
27
36
|
// the visitor cannot reach — and on a phone it also drags a horizontal
|
|
28
37
|
// scrollbar across the whole page.
|
|
@@ -52,9 +61,16 @@ export function measureShot(shot) {
|
|
|
52
61
|
// choice — but two elements in the same flow row sitting on top of each other
|
|
53
62
|
// is one of them being unreadable.
|
|
54
63
|
for (const a of shot.boxes) {
|
|
64
|
+
if (skip.has(a.id))
|
|
65
|
+
continue;
|
|
55
66
|
for (const b of shot.boxes) {
|
|
56
67
|
if (a.id >= b.id || a.id === 'ROOT' || b.id === 'ROOT')
|
|
57
68
|
continue;
|
|
69
|
+
// Same reason as above, and it needs saying twice because the pair loop is
|
|
70
|
+
// its own pass: a drawer parked off-screen overlaps whatever the page has
|
|
71
|
+
// at those coordinates, and neither half of that pair is a defect.
|
|
72
|
+
if (skip.has(b.id))
|
|
73
|
+
continue;
|
|
58
74
|
if (a.w === 0 || a.h === 0 || b.w === 0 || b.h === 0)
|
|
59
75
|
continue;
|
|
60
76
|
const pairKey = `${a.id}|${b.id}`;
|
|
@@ -91,10 +107,10 @@ function contains(outer, inner) {
|
|
|
91
107
|
* widths it spills at is the useful part, so the widths are collected onto the
|
|
92
108
|
* single finding rather than repeated as separate ones.
|
|
93
109
|
*/
|
|
94
|
-
export function measure(shots) {
|
|
110
|
+
export function measure(shots, skip = new Set()) {
|
|
95
111
|
const merged = new Map();
|
|
96
112
|
for (const shot of shots) {
|
|
97
|
-
for (const f of measureShot(shot)) {
|
|
113
|
+
for (const f of measureShot(shot, skip)) {
|
|
98
114
|
const key = `${f.code}|${f.nodeId}`;
|
|
99
115
|
const existing = merged.get(key);
|
|
100
116
|
if (existing)
|
package/dist/vision/shoot.js
CHANGED
|
@@ -138,6 +138,44 @@ export async function shoot(url, opts = {}) {
|
|
|
138
138
|
}
|
|
139
139
|
}));
|
|
140
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* WALK THE PAGE so its lazy images load, then come back to the top.
|
|
143
|
+
*
|
|
144
|
+
* `fullPage: true` does NOT scroll: Playwright resizes the capture, and an
|
|
145
|
+
* `<img loading="lazy">` below the fold never enters the viewport, never
|
|
146
|
+
* fetches, and photographs as an empty box. The renderer marks every image
|
|
147
|
+
* below the first screen lazy, so this hit the one thing the vision loop exists
|
|
148
|
+
* to judge — measured on a real storefront: 4 of the page's images unloaded
|
|
149
|
+
* before the walk, 0 after. The agent saw four blank product cards on a page a
|
|
150
|
+
* shopper sees four photos on, and the honest reading of that picture is "the
|
|
151
|
+
* images are broken", which would send it to fix something that works.
|
|
152
|
+
*
|
|
153
|
+
* BOUNDED at every step, because a shot that never happens is worse than one
|
|
154
|
+
* taken a beat early: the walk is capped, and the wait for decoding gives up
|
|
155
|
+
* rather than hanging on an image the server will never send.
|
|
156
|
+
*/
|
|
157
|
+
async function settleLazyImages(page) {
|
|
158
|
+
await page
|
|
159
|
+
.evaluate(async () => {
|
|
160
|
+
const step = window.innerHeight || 900;
|
|
161
|
+
const end = document.body.scrollHeight;
|
|
162
|
+
// A very long page is walked in bigger strides rather than not at all.
|
|
163
|
+
const stride = Math.max(step, Math.ceil(end / 40));
|
|
164
|
+
for (let y = 0; y < end; y += stride) {
|
|
165
|
+
window.scrollTo(0, y);
|
|
166
|
+
await new Promise((r) => setTimeout(() => r(undefined), 60));
|
|
167
|
+
}
|
|
168
|
+
window.scrollTo(0, 0);
|
|
169
|
+
})
|
|
170
|
+
.catch(() => { });
|
|
171
|
+
// Give what the walk started a chance to arrive. `complete` is false while a
|
|
172
|
+
// fetch is in flight; a decoded-but-broken image reports complete with a zero
|
|
173
|
+
// natural width, and that is a REAL defect the shot should show, so it is not
|
|
174
|
+
// waited on.
|
|
175
|
+
await page
|
|
176
|
+
.waitForFunction(() => [...document.querySelectorAll('img')].every((i) => i.complete === true), undefined, { timeout: 3_000 })
|
|
177
|
+
.catch(() => { });
|
|
178
|
+
}
|
|
141
179
|
async function shootOne(page, url, width, format, opts) {
|
|
142
180
|
// LOAD, then a BOUNDED settle — never `networkidle` alone.
|
|
143
181
|
//
|
|
@@ -150,6 +188,7 @@ async function shootOne(page, url, width, format, opts) {
|
|
|
150
188
|
// waits for it; if it never does, the shot happens anyway.
|
|
151
189
|
await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
|
|
152
190
|
await page.waitForLoadState('networkidle', { timeout: 2_500 }).catch(() => { });
|
|
191
|
+
await settleLazyImages(page);
|
|
153
192
|
// A RENDERED page carries its node ids as the HTML `id` attribute — not as
|
|
154
193
|
// `data-node-id`, which is the editor CANVAS's hook and never reaches the
|
|
155
194
|
// renderer. Selecting the canvas attribute here returned an empty box list
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sbuilder-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|