sbuilder-mcp 0.1.1 → 0.1.2

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
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 — 2026-08-29
4
+
5
+ 244625
6
+
3
7
  ## 0.1.1 — 2026-08-28
4
8
 
5
9
  - fix(release): ask for the one-time password instead of dying on it
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { setAgentClient } from './transport/identity.js';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { Session } from './transport/auth.js';
@@ -51,6 +52,15 @@ export function buildContext() {
51
52
  }
52
53
  export function createServer(ctx = buildContext()) {
53
54
  const server = new McpServer({ name: 'sbuilder', version: pkgVersion(), title: 'Store Builder' }, { instructions: INSTRUCTIONS });
55
+ // LEARN WHO LAUNCHED US, at the handshake, before any tool runs.
56
+ //
57
+ // Set here rather than after connect() because the handshake happens DURING
58
+ // connect: a hook attached afterwards is attached to an event that has already
59
+ // fired, and every call would then report an anonymous machine — the exact
60
+ // blindness this exists to remove.
61
+ server.server.oninitialized = () => {
62
+ setAgentClient(server.server.getClientVersion(), pkgVersion());
63
+ };
54
64
  registerSessionTools(server, ctx);
55
65
  registerApiTools(server, ctx);
56
66
  const pageSession = registerPageTools(server, ctx);
@@ -6,6 +6,7 @@ import { previewUrl } from '../vision/preview.js';
6
6
  import { uploadMedia } from '../transport/media.js';
7
7
  import { request } from '../transport/http.js';
8
8
  import { shoot, DEFAULT_WIDTHS } from '../vision/shoot.js';
9
+ import { measure, MEASURE_NOTICE } from '../vision/measure.js';
9
10
  import { RealtimeSocket } from '../transport/socket.js';
10
11
  import { LiveSession } from '../live/session.js';
11
12
  import { siteToken } from './credentialpick.js';
@@ -62,8 +63,9 @@ export function registerLiveTools(server, ctx, session) {
62
63
  });
63
64
  server.tool('sb_look', "Save the open page, render it through the platform's own renderer, and return " +
64
65
  'screenshots at desktop, tablet and mobile widths — plus the measured bounding box of ' +
65
- 'every node. Pass node_id to frame ONE element instead of the whole page. Judge your ' +
66
- 'own work from these rather than guessing.', {
66
+ 'every node plus any LAYOUT defect measured on the render: content past the ' +
67
+ 'viewport, elements overlapping, text too small to read. Pass node_id to frame ONE ' +
68
+ 'element instead of the whole page. Judge your own work from these rather than guessing.', {
67
69
  widths: z.array(z.number().int().min(320).max(2560)).optional(),
68
70
  with_boxes: z.boolean().optional(),
69
71
  node_id: z
@@ -81,11 +83,15 @@ export function registerLiveTools(server, ctx, session) {
81
83
  // by rule are the same act, and separating them is how the second one gets
82
84
  // skipped.
83
85
  const findings = reviewDesign(session.current());
86
+ // Measured on the render, not read off the document — a card that spills
87
+ // at 390px is invisible to every check that only reads the tree.
88
+ const visual = node_id ? [] : measure(shots);
84
89
  return images(shots.map((s) => ({ dataBase64: s.pngBase64 })), {
85
90
  widths: shots.map((s) => s.width),
86
91
  ...(node_id ? { framed: node_id } : {}),
87
92
  ...(with_boxes === false ? {} : { boxes: shots[0]?.boxes ?? [] }),
88
93
  ...(findings.length > 0 ? { findings, findings_notice: REVIEW_NOTICE } : {}),
94
+ ...(visual.length > 0 ? { layout: visual, layout_notice: MEASURE_NOTICE } : {}),
89
95
  });
90
96
  });
91
97
  server.tool('sb_media_list', "The site's media library — reuse an image that is already there before adding another. " +
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The one HTTP path to the platform.
3
+ *
4
+ * The platform writes exactly ONE error shape — {"error": "...", "code": "..."} —
5
+ * through httpx.WriteError, and never plain text. So an error is parsed, not
6
+ * stringified: `code` is the branchable half, and reading `res.statusText`
7
+ * instead throws it away. A bare "Conflict" reaching the model is the failure
8
+ * this file exists to prevent.
9
+ */
10
+ import { identityHeaders } from './identity.js';
1
11
  export class ApiError extends Error {
2
12
  status;
3
13
  code;
@@ -44,7 +54,12 @@ export function buildUrl(base, path, query) {
44
54
  }
45
55
  export async function request(opts) {
46
56
  const doFetch = opts.fetchImpl ?? fetch;
47
- const headers = { Accept: 'application/json' };
57
+ // Identity rides on EVERY call rather than on a handshake of its own. There is
58
+ // no "connect" request to hang it off — the first thing this server does is
59
+ // whatever the agent asked for — and a separate announcement call would be one
60
+ // more thing that can fail while the real work succeeds, leaving a working
61
+ // install invisible on the operator's screen.
62
+ const headers = { Accept: 'application/json', ...identityHeaders() };
48
63
  if (opts.token)
49
64
  headers.Authorization = `Bearer ${opts.token}`;
50
65
  if (opts.body !== undefined)
@@ -0,0 +1,58 @@
1
+ import { hostname } from 'node:os';
2
+ let current = { client: '', clientVersion: '', host: '', server: '' };
3
+ /**
4
+ * Record who launched this server.
5
+ *
6
+ * Called once, after the MCP handshake, with the `clientInfo` the client sent.
7
+ * Before that — and if a client sends nothing, which is legal — the identity
8
+ * stays empty and the platform records an anonymous connection rather than
9
+ * inventing a name. An invented name would be worse than no name: it would look
10
+ * like a machine somebody could go and check.
11
+ */
12
+ export function setAgentClient(info, serverVersion) {
13
+ current = {
14
+ client: info?.name ?? '',
15
+ clientVersion: info?.version ?? '',
16
+ host: machineName(),
17
+ server: serverVersion ? `sbuilder-mcp/${serverVersion}` : '',
18
+ };
19
+ }
20
+ /** The current identity, for tests and for the smoke check. */
21
+ export function agentIdentity() {
22
+ return current;
23
+ }
24
+ /**
25
+ * The machine's name.
26
+ *
27
+ * `os.hostname()` throws on some locked-down sandboxes rather than returning
28
+ * something useless, and a telemetry label is never worth failing a request
29
+ * over.
30
+ */
31
+ function machineName() {
32
+ try {
33
+ return hostname();
34
+ }
35
+ catch {
36
+ return '';
37
+ }
38
+ }
39
+ /**
40
+ * The headers that carry it.
41
+ *
42
+ * Empty fields are OMITTED rather than sent blank, so the platform's
43
+ * "identified itself / did not" distinction survives the wire. A header set to
44
+ * the empty string and a header that is absent must not mean different things
45
+ * here and there.
46
+ */
47
+ export function identityHeaders(id = current) {
48
+ const out = {};
49
+ if (id.client)
50
+ out['X-Agent-Client'] = id.client;
51
+ if (id.clientVersion)
52
+ out['X-Agent-Client-Version'] = id.clientVersion;
53
+ if (id.host)
54
+ out['X-Agent-Host'] = id.host;
55
+ if (id.server)
56
+ out['X-Agent-Server'] = id.server;
57
+ return out;
58
+ }
@@ -1,3 +1,4 @@
1
+ import { identityHeaders } from './identity.js';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  import { basename } from 'node:path';
3
4
  import { ApiError } from './http.js';
@@ -46,7 +47,14 @@ export async function uploadMedia(ctx, siteId, source) {
46
47
  // make a valid upload unparseable at the other end.
47
48
  const res = await doFetch(`${ctx.base.replace(/\/$/, '')}/api/media/${encodeURIComponent(siteId)}`, {
48
49
  method: 'POST',
49
- headers: { Authorization: `Bearer ${siteToken(ctx)}`, Accept: 'application/json' },
50
+ // Content-Type stays ABSENT fetch writes it with the boundary it just
51
+ // built — but the identity headers belong here as much as on any other call:
52
+ // an install whose only traffic is image uploads is still an install.
53
+ headers: {
54
+ Authorization: `Bearer ${siteToken(ctx)}`,
55
+ Accept: 'application/json',
56
+ ...identityHeaders(),
57
+ },
50
58
  body: form,
51
59
  });
52
60
  const raw = await res.text();
@@ -0,0 +1,109 @@
1
+ /** Below this, body text is uncomfortable on a phone. */
2
+ const MIN_BODY_PX = 12;
3
+ /** A few pixels of overlap is a rounding artefact, not a defect. */
4
+ const SLOP = 2;
5
+ function overlaps(a, b) {
6
+ return (a.x < b.x + b.w - SLOP &&
7
+ b.x < a.x + a.w - SLOP &&
8
+ a.y < b.y + b.h - SLOP &&
9
+ b.y < a.y + a.h - SLOP);
10
+ }
11
+ /**
12
+ * Everything measurably wrong with how one shot laid out.
13
+ *
14
+ * Reported per width, because these defects are per width: a card that fits at
15
+ * 1440 and spills at 390 is the ordinary responsive failure, and saying which
16
+ * width it happened at is most of the fix.
17
+ */
18
+ export function measureShot(shot) {
19
+ const out = [];
20
+ const byId = new Map(shot.boxes.map((b) => [b.id, b]));
21
+ const seen = new Set();
22
+ for (const b of shot.boxes) {
23
+ if (b.id === 'ROOT')
24
+ continue;
25
+ // OFF-CANVAS. A negative x, or a right edge past the viewport, is content
26
+ // the visitor cannot reach — and on a phone it also drags a horizontal
27
+ // scrollbar across the whole page.
28
+ if (b.x < -SLOP || b.x + b.w > shot.width + SLOP) {
29
+ out.push({
30
+ code: 'off_canvas',
31
+ nodeId: b.id,
32
+ width: shot.width,
33
+ problem: `Extends past the ${shot.width}px viewport (${b.x} → ${b.x + b.w}).`,
34
+ fix: `Give it a width that can shrink — sb_set id "${b.id}", namespace style, keys { "maxWidth": "100%" } at this breakpoint.`,
35
+ });
36
+ }
37
+ // TEXT TOO SMALL. Only where text actually renders: a container inherits a
38
+ // font size it never shows, and reporting that would be noise.
39
+ if (b.hasText && b.fontPx && b.fontPx < MIN_BODY_PX) {
40
+ out.push({
41
+ code: 'text_too_small',
42
+ nodeId: b.id,
43
+ width: shot.width,
44
+ problem: `Renders at ${b.fontPx}px at ${shot.width}px wide — below the ${MIN_BODY_PX}px a phone can read comfortably.`,
45
+ fix: `Raise it for this breakpoint: sb_set id "${b.id}", namespace style, keys { "fontSize": "16px" }.`,
46
+ });
47
+ }
48
+ }
49
+ // OVERLAP, between siblings only. Any parent overlaps its children by
50
+ // definition, and an absolutely-positioned decoration over a band is a design
51
+ // choice — but two elements in the same flow row sitting on top of each other
52
+ // is one of them being unreadable.
53
+ for (const a of shot.boxes) {
54
+ for (const b of shot.boxes) {
55
+ if (a.id >= b.id || a.id === 'ROOT' || b.id === 'ROOT')
56
+ continue;
57
+ if (a.w === 0 || a.h === 0 || b.w === 0 || b.h === 0)
58
+ continue;
59
+ const pairKey = `${a.id}|${b.id}`;
60
+ if (seen.has(pairKey))
61
+ continue;
62
+ // Skip ancestry: a box containing another is nesting, not collision.
63
+ if (contains(a, b) || contains(b, a))
64
+ continue;
65
+ if (!overlaps(a, b))
66
+ continue;
67
+ seen.add(pairKey);
68
+ out.push({
69
+ code: 'overlap',
70
+ nodeId: a.id,
71
+ width: shot.width,
72
+ problem: `Overlaps ${b.id} at ${shot.width}px wide — one of them is unreadable.`,
73
+ fix: 'Check the two for a fixed height or a negative margin at this breakpoint; sb_look with node_id on each shows which one is out of place.',
74
+ });
75
+ }
76
+ }
77
+ void byId;
78
+ return out;
79
+ }
80
+ function contains(outer, inner) {
81
+ return (outer.x - SLOP <= inner.x &&
82
+ outer.y - SLOP <= inner.y &&
83
+ outer.x + outer.w + SLOP >= inner.x + inner.w &&
84
+ outer.y + outer.h + SLOP >= inner.y + inner.h);
85
+ }
86
+ /**
87
+ * Measure every width, and report each defect once.
88
+ *
89
+ * A card that spills at three widths is one problem, not three — but WHICH
90
+ * widths it spills at is the useful part, so the widths are collected onto the
91
+ * single finding rather than repeated as separate ones.
92
+ */
93
+ export function measure(shots) {
94
+ const merged = new Map();
95
+ for (const shot of shots) {
96
+ for (const f of measureShot(shot)) {
97
+ const key = `${f.code}|${f.nodeId}`;
98
+ const existing = merged.get(key);
99
+ if (existing)
100
+ existing.widths.push(f.width);
101
+ else
102
+ merged.set(key, { ...f, widths: [f.width] });
103
+ }
104
+ }
105
+ return [...merged.values()];
106
+ }
107
+ export const MEASURE_NOTICE = 'These were MEASURED on the rendered page, not read off the document — they are what a ' +
108
+ 'visitor meets at those widths. Fix them at the breakpoint named; a defect at 390px and ' +
109
+ 'not at 1440px is a responsive failure, not a broken element.';
@@ -54,6 +54,8 @@ export async function shoot(url, opts = {}) {
54
54
  const wb = String(el.className || '')
55
55
  .split(/\s+/)
56
56
  .find((c) => c.startsWith('wb-'));
57
+ const cs = getComputedStyle(el);
58
+ const own = (el.textContent ?? '').trim();
57
59
  return {
58
60
  id: el.id,
59
61
  type: wb ? wb.slice(3) : '',
@@ -61,6 +63,8 @@ export async function shoot(url, opts = {}) {
61
63
  y: Math.round(r.y),
62
64
  w: Math.round(r.width),
63
65
  h: Math.round(r.height),
66
+ fontPx: Math.round(parseFloat(cs.fontSize) || 0),
67
+ hasText: own.length > 0,
64
68
  };
65
69
  })));
66
70
  // ZOOM. A designer does not judge a card by looking at the whole page, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sbuilder-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",