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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/README.vi.md +137 -0
- package/dist/catalog/api.generated.js +11938 -0
- package/dist/catalog/element-types.js +1 -0
- package/dist/catalog/elements.generated.js +14761 -0
- package/dist/catalog/search.js +87 -0
- package/dist/catalog/types.js +1 -0
- package/dist/core/patch.js +110 -0
- package/dist/core/tree.js +69 -0
- package/dist/domains/site/builder.js +224 -0
- package/dist/domains/site/document.js +112 -0
- package/dist/domains/site/ids.js +27 -0
- package/dist/domains/site/node.js +43 -0
- package/dist/domains/site/review.js +141 -0
- package/dist/domains/site/traps.js +98 -0
- package/dist/domains/site/validate.js +49 -0
- package/dist/index.js +20 -0
- package/dist/install/index.js +108 -0
- package/dist/install/paths.js +83 -0
- package/dist/install/write.js +97 -0
- package/dist/live/session.js +164 -0
- package/dist/mcp/response.js +40 -0
- package/dist/server.js +59 -0
- package/dist/smoke.js +106 -0
- package/dist/tools/api.js +96 -0
- package/dist/tools/context.js +1 -0
- package/dist/tools/credentialpick.js +11 -0
- package/dist/tools/live.js +104 -0
- package/dist/tools/page.js +383 -0
- package/dist/tools/session.js +72 -0
- package/dist/transport/auth.js +61 -0
- package/dist/transport/credential.js +7 -0
- package/dist/transport/http.js +77 -0
- package/dist/transport/pages.js +51 -0
- package/dist/transport/socket.js +85 -0
- package/dist/vision/preview.js +30 -0
- package/dist/vision/shoot.js +103 -0
- 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
|
+
}
|