sbuilder-mcp 0.1.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +142 -0
  4. package/README.vi.md +137 -0
  5. package/dist/catalog/api.generated.js +11938 -0
  6. package/dist/catalog/element-types.js +1 -0
  7. package/dist/catalog/elements.generated.js +14761 -0
  8. package/dist/catalog/search.js +87 -0
  9. package/dist/catalog/types.js +1 -0
  10. package/dist/core/patch.js +110 -0
  11. package/dist/core/tree.js +69 -0
  12. package/dist/domains/site/builder.js +224 -0
  13. package/dist/domains/site/document.js +112 -0
  14. package/dist/domains/site/ids.js +27 -0
  15. package/dist/domains/site/node.js +43 -0
  16. package/dist/domains/site/review.js +141 -0
  17. package/dist/domains/site/traps.js +98 -0
  18. package/dist/domains/site/validate.js +49 -0
  19. package/dist/index.js +20 -0
  20. package/dist/install/index.js +108 -0
  21. package/dist/install/paths.js +83 -0
  22. package/dist/install/write.js +97 -0
  23. package/dist/live/session.js +164 -0
  24. package/dist/mcp/response.js +40 -0
  25. package/dist/server.js +59 -0
  26. package/dist/smoke.js +106 -0
  27. package/dist/tools/api.js +96 -0
  28. package/dist/tools/context.js +1 -0
  29. package/dist/tools/credentialpick.js +11 -0
  30. package/dist/tools/live.js +104 -0
  31. package/dist/tools/page.js +383 -0
  32. package/dist/tools/session.js +72 -0
  33. package/dist/transport/auth.js +61 -0
  34. package/dist/transport/credential.js +7 -0
  35. package/dist/transport/http.js +77 -0
  36. package/dist/transport/pages.js +51 -0
  37. package/dist/transport/socket.js +85 -0
  38. package/dist/vision/preview.js +30 -0
  39. package/dist/vision/shoot.js +103 -0
  40. package/package.json +66 -0
@@ -0,0 +1,85 @@
1
+ /** WebSocket.OPEN per spec, as a literal so this module never reads a global. */
2
+ const OPEN = 1;
3
+ const RETRY_BASE_MS = 500;
4
+ const RETRY_MAX_MS = 15_000;
5
+ export class RealtimeSocket {
6
+ url;
7
+ token;
8
+ factory;
9
+ ws = null;
10
+ closed = false;
11
+ attempt = 0;
12
+ handlers = [];
13
+ timer = null;
14
+ constructor(url,
15
+ /**
16
+ * A GETTER, read per attempt. The access token lives ~15 minutes and
17
+ * rotates; a socket holding the string it was constructed with replays an
18
+ * expired token on every reconnect, and the failure is SILENT — a rejected
19
+ * auth still fires `onopen`, so no error event ever surfaces.
20
+ */
21
+ token, factory = (u) => new WebSocket(u)) {
22
+ this.url = url;
23
+ this.token = token;
24
+ this.factory = factory;
25
+ }
26
+ get attempts() {
27
+ return this.attempt;
28
+ }
29
+ on(handler) {
30
+ this.handlers.push(handler);
31
+ }
32
+ connect() {
33
+ if (this.closed)
34
+ return;
35
+ const ws = this.factory(this.url);
36
+ this.ws = ws;
37
+ this.attempt += 1;
38
+ ws.onopen = () => {
39
+ // Auth is the FIRST message, never a header (a browser cannot set one on a
40
+ // WebSocket) and never a query parameter (a token in a URL lands in logs).
41
+ // The server enforces a 5s deadline, so it goes out immediately.
42
+ ws.send(JSON.stringify({ t: 'auth', token: this.token() }));
43
+ };
44
+ ws.onmessage = (ev) => {
45
+ // A frame ARRIVING is the only evidence this connection is usable. An open
46
+ // proves nothing: a refused auth opens and then closes. Resetting the
47
+ // counter on open instead produced open → attempt=0 → auth → close →
48
+ // 500ms → forever: a 2 Hz reconnect storm with the backoff never engaging,
49
+ // no onerror (a policy close is not an error), and nothing in any UI.
50
+ this.attempt = 0;
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(String(ev.data));
54
+ }
55
+ catch {
56
+ return; // a malformed frame must never throw into the socket
57
+ }
58
+ for (const h of this.handlers)
59
+ h(parsed);
60
+ };
61
+ ws.onclose = () => {
62
+ if (this.closed)
63
+ return;
64
+ const wait = Math.min(RETRY_BASE_MS * 2 ** this.attempt, RETRY_MAX_MS);
65
+ this.timer = setTimeout(() => this.connect(), wait);
66
+ };
67
+ ws.onerror = () => {
68
+ // Genuinely rare: a policy close is not an error. Nothing to do here —
69
+ // onclose runs either way and owns the reconnect.
70
+ };
71
+ }
72
+ send(e) {
73
+ if (this.closed)
74
+ return;
75
+ if (this.ws?.readyState !== OPEN)
76
+ return;
77
+ this.ws.send(JSON.stringify(e));
78
+ }
79
+ close() {
80
+ this.closed = true;
81
+ if (this.timer)
82
+ clearTimeout(this.timer);
83
+ this.ws?.close();
84
+ }
85
+ }
@@ -0,0 +1,30 @@
1
+ import { request } from '../transport/http.js';
2
+ import { siteToken } from '../tools/credentialpick.js';
3
+ /**
4
+ * Mint a signed link to this page's DRAFT preview.
5
+ *
6
+ * The link is short-lived and is the only gate on the public `/_wb/preview`
7
+ * route, which renders the STORED draft through the Go renderer. So a screenshot
8
+ * always shows the last SAVED state — save first, or you photograph the past.
9
+ */
10
+ export async function previewUrl(ctx, siteId, pageId) {
11
+ const out = (await request({
12
+ base: ctx.base,
13
+ method: 'GET',
14
+ path: `/api/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/preview`,
15
+ token: siteToken(ctx),
16
+ fetchImpl: ctx.fetchImpl,
17
+ }));
18
+ const link = out?.preview?.url;
19
+ if (!link) {
20
+ throw new Error('sbuilder: the server returned no preview url for this page. A page with no saved draft ' +
21
+ 'has no preview — save it first.');
22
+ }
23
+ // The server returns a RELATIVE link in dev (`/_wb/preview?t=…`) and an
24
+ // absolute one in production, where the preview is served from the storefront
25
+ // origin rather than the API's. Resolving against the API base handles both:
26
+ // `new URL` leaves an absolute input untouched. Passing the raw value to
27
+ // Playwright throws "Cannot navigate to invalid URL", which is where this was
28
+ // found — running it, not reading it.
29
+ return new URL(link, ctx.base).toString();
30
+ }
@@ -0,0 +1,103 @@
1
+ import { chromium } from 'playwright-core';
2
+ /** The three widths the platform's own breakpoints care about. */
3
+ export const DEFAULT_WIDTHS = [1440, 768, 390];
4
+ /**
5
+ * Launch the SYSTEM Chrome — `channel: 'chrome'`, not a bundled browser.
6
+ *
7
+ * `playwright-core` ships no browsers, so installing this package downloads
8
+ * nothing. When Chrome is absent the launch throws, and this re-throws NAMING
9
+ * it: a vision loop that quietly returns a blank image is worse than one that
10
+ * refuses, because the agent would go on to judge a page it never saw.
11
+ */
12
+ async function launch() {
13
+ try {
14
+ return await chromium.launch({ channel: 'chrome', headless: true });
15
+ }
16
+ catch (err) {
17
+ throw new Error('sbuilder: could not launch Google Chrome for the screenshot. sb_look needs Chrome ' +
18
+ `installed — playwright-core bundles no browser. Underlying error: ${String(err)}`);
19
+ }
20
+ }
21
+ /**
22
+ * Photograph a rendered page at several widths, and measure every node.
23
+ *
24
+ * The boxes are the load-bearing half. `cursor` frames on the live-edit socket
25
+ * carry LAYOUT pixels — the canvas is zoomed per viewer, so a screen coordinate
26
+ * lands somewhere else on a peer with a different window — and nothing else in
27
+ * this server knows where a node ended up. Measured here, the agent's cursor
28
+ * moves to the element it is about to change instead of to a made-up number.
29
+ */
30
+ export async function shoot(url, opts = {}) {
31
+ const widths = opts.widths ?? DEFAULT_WIDTHS;
32
+ const browser = await launch();
33
+ try {
34
+ const shots = [];
35
+ for (const width of widths) {
36
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
37
+ await page.goto(url, { waitUntil: 'networkidle' });
38
+ // A RENDERED page carries its node ids as the HTML `id` attribute — not as
39
+ // `data-node-id`, which is the editor CANVAS's hook and never reaches the
40
+ // renderer. Selecting the canvas attribute here returned an empty box list
41
+ // on every real page, silently: the screenshots looked fine, and the half
42
+ // of this function that exists to place the presence cursor did nothing.
43
+ // Found by running it against the Go renderer.
44
+ //
45
+ // Ids are filtered by SHAPE (`xx_8hex`, plus ROOT) rather than taken from
46
+ // every `[id]`, so a wrapper or an anchor target cannot be mistaken for a
47
+ // node. `type` is read off the leading `wb-` class, which is the only type
48
+ // signal the render emits; the caller already knows the real types from
49
+ // sb_outline, so this is a convenience, not a contract.
50
+ const boxes = (await page.evaluate(() => [...document.querySelectorAll('[id]')]
51
+ .filter((el) => el.id === 'ROOT' || /^[a-z]{2}_[0-9a-f]{8}$/.test(el.id))
52
+ .map((el) => {
53
+ const r = el.getBoundingClientRect();
54
+ const wb = String(el.className || '')
55
+ .split(/\s+/)
56
+ .find((c) => c.startsWith('wb-'));
57
+ return {
58
+ id: el.id,
59
+ type: wb ? wb.slice(3) : '',
60
+ x: Math.round(r.x),
61
+ y: Math.round(r.y),
62
+ w: Math.round(r.width),
63
+ h: Math.round(r.height),
64
+ };
65
+ })));
66
+ // ZOOM. A designer does not judge a card by looking at the whole page, and
67
+ // a full-page shot of a long storefront makes one card a few pixels tall.
68
+ // The clip comes from the SAME measurement pass the boxes do, so what is
69
+ // framed is exactly what `sb_set` addresses.
70
+ let clip;
71
+ if (opts.node) {
72
+ const box = boxes.find((b) => b.id === opts.node);
73
+ if (!box) {
74
+ await page.close();
75
+ throw new Error(`sbuilder: node "${opts.node}" is not on the rendered page at ${width}px. It may be ` +
76
+ 'hidden at this breakpoint, or not saved yet — sb_look renders the STORED draft.');
77
+ }
78
+ if (box.w === 0 || box.h === 0) {
79
+ await page.close();
80
+ throw new Error(`sbuilder: node "${opts.node}" renders with no size at ${width}px (${box.w}×${box.h}) — ` +
81
+ 'nothing to photograph. It is collapsed or empty; sb_review will say which.');
82
+ }
83
+ const pad = opts.pad ?? 16;
84
+ clip = {
85
+ x: Math.max(0, box.x - pad),
86
+ y: Math.max(0, box.y - pad),
87
+ width: Math.min(width, box.w + pad * 2),
88
+ height: box.h + pad * 2,
89
+ };
90
+ }
91
+ const png = await page.screenshot({
92
+ type: 'png',
93
+ ...(clip ? { clip } : { fullPage: true }),
94
+ });
95
+ shots.push({ width, pngBase64: png.toString('base64'), boxes });
96
+ await page.close();
97
+ }
98
+ return shots;
99
+ }
100
+ finally {
101
+ await browser.close();
102
+ }
103
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "sbuilder-mcp",
3
+ "version": "0.1.0",
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
+ "mcpName": "io.github.vuluu2k/sbuilder-mcp",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "bin": {
9
+ "sb-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "README.vi.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "dev": "tsc --watch",
27
+ "start": "node dist/index.js",
28
+ "smoke": "node dist/smoke.js",
29
+ "test": "vitest run",
30
+ "codegen": "tsx scripts/gen-catalog.ts",
31
+ "prepublishOnly": "npm run build && npm run smoke",
32
+ "release": "node scripts/release.mjs",
33
+ "release:patch": "node scripts/release.mjs patch",
34
+ "release:minor": "node scripts/release.mjs minor",
35
+ "release:major": "node scripts/release.mjs major",
36
+ "release:dry": "node scripts/release.mjs --dry"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.30.0",
40
+ "playwright-core": "^1.62.1",
41
+ "zod": "^3.25.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.0.0",
45
+ "tsx": "^4.19.0",
46
+ "typescript": "^5.6.0",
47
+ "vitest": "^3.2.0"
48
+ },
49
+ "homepage": "https://github.com/vuluu2k/sbuilder-mcp#readme",
50
+ "bugs": {
51
+ "url": "https://github.com/vuluu2k/sbuilder-mcp/issues"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/vuluu2k/sbuilder-mcp.git"
56
+ },
57
+ "keywords": [
58
+ "mcp",
59
+ "model-context-protocol",
60
+ "store-builder",
61
+ "sbuilder",
62
+ "website-builder",
63
+ "ai-agent",
64
+ "page-builder"
65
+ ]
66
+ }