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.
@@ -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: 'Connected with an API key alone. It is bound to one site, so there is no site list — ' +
28
- 'pass that site id to sb_page_open. Set SB_EMAIL and SB_PASSWORD as well if you want ' +
29
- 'account-level calls (listing sites, members, roles), which a key cannot make.',
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 () => text(await request({
74
- base: ctx.base,
75
- method: 'GET',
76
- path: '/api/sites',
77
- token: ctx.session.token(),
78
- fetchImpl: ctx.fetchImpl,
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
  }
@@ -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 res = await doFetch(`${ctx.base.replace(/\/$/, '')}/api/media/${encodeURIComponent(siteId)}`, {
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: the platform refused this API key for the upload. It accepts a key, so the ' +
87
- 'cause is the key itself: it needs the media permission, and it must belong to THIS ' +
88
- 'site — a key minted for another site is refused before the upload is read. Check the ' +
89
- "key's scopes and its site, or set SB_EMAIL / SB_PASSWORD to upload as a person.");
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
@@ -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)
@@ -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.2.2",
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",