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,164 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { syncable, isSyncablePatch } from '../core/patch.js';
3
+ /**
4
+ * The agent's seat in the live-edit room.
5
+ *
6
+ * THE YIELD RULE governs everything here: this client never answers `snapreq`,
7
+ * never publishes a checkpoint of its own, and re-pulls on any evidence of
8
+ * divergence. That is what lets it skip the editor's outbox deferral, inbox
9
+ * arbitration and "who pulls" tie-break — roughly a thousand lines whose entire
10
+ * purpose is arbitrating between two EQUALLY authoritative editors. This one is
11
+ * not one of those, deliberately.
12
+ */
13
+ export class LiveSession {
14
+ socket;
15
+ opts;
16
+ selfId = '';
17
+ pageId = '';
18
+ roster = new Map();
19
+ pending = new Map();
20
+ seq = 0;
21
+ constructor(socket, opts) {
22
+ this.socket = socket;
23
+ this.opts = opts;
24
+ this.socket.on((e) => this.receive(e));
25
+ }
26
+ get peers() {
27
+ return [...this.roster.values()];
28
+ }
29
+ /** Is anyone else here? The yield rule only costs anything when someone is. */
30
+ get humanPresent() {
31
+ return this.roster.size > 0;
32
+ }
33
+ get maxSeq() {
34
+ return this.seq;
35
+ }
36
+ get pendingAcks() {
37
+ return this.pending.size;
38
+ }
39
+ start(pageId) {
40
+ this.pageId = pageId;
41
+ // The welcome may already have arrived, or may not — the order depends on
42
+ // how fast the server answers. Announce now if we know who we are;
43
+ // otherwise `receive` does it when the welcome lands.
44
+ if (this.selfId)
45
+ this.announcePage();
46
+ }
47
+ announcePage() {
48
+ if (!this.pageId)
49
+ return;
50
+ this.socket.send({ t: 'page', pageId: this.pageId });
51
+ }
52
+ /**
53
+ * Put a batch of my own patches on the wire.
54
+ *
55
+ * Filtered through `syncable` first, and an EMPTY result is not sent at all:
56
+ * the server drops an ops frame with no ops silently (a bare `continue` in
57
+ * readPump), so sending one is indistinguishable from success while achieving
58
+ * nothing at all.
59
+ */
60
+ publish(patches) {
61
+ if (!this.pageId)
62
+ return;
63
+ const ops = syncable(patches);
64
+ if (ops.length === 0)
65
+ return;
66
+ const opId = randomBytes(8).toString('hex');
67
+ this.pending.set(opId, Date.now());
68
+ this.socket.send({ t: 'ops', pageId: this.pageId, ops, opId });
69
+ }
70
+ /** Presence only — never document state. */
71
+ cursor(x, y) {
72
+ if (!this.pageId)
73
+ return;
74
+ this.socket.send({ t: 'cursor', pageId: this.pageId, x, y });
75
+ }
76
+ select(nodeId) {
77
+ if (!this.pageId)
78
+ return;
79
+ this.socket.send({ t: 'select', pageId: this.pageId, nodeId });
80
+ }
81
+ receive(e) {
82
+ switch (e.t) {
83
+ case 'welcome': {
84
+ this.selfId = String(e.peerId ?? '');
85
+ this.roster.clear();
86
+ for (const p of e.peers ?? [])
87
+ this.roster.set(p.id, p);
88
+ this.announcePage();
89
+ break;
90
+ }
91
+ case 'join': {
92
+ const p = e.peer;
93
+ if (p?.id && p.id !== this.selfId)
94
+ this.roster.set(p.id, p);
95
+ break;
96
+ }
97
+ case 'leave': {
98
+ this.roster.delete(String(e.peerId ?? ''));
99
+ break;
100
+ }
101
+ case 'page': {
102
+ const p = this.roster.get(String(e.peerId ?? ''));
103
+ if (p)
104
+ p.pageId = String(e.pageId ?? '');
105
+ break;
106
+ }
107
+ case 'ack': {
108
+ this.pending.delete(String(e.opId ?? ''));
109
+ const s = Number(e.seq ?? 0);
110
+ if (s > this.seq)
111
+ this.seq = s;
112
+ break;
113
+ }
114
+ case 'ops': {
115
+ if (e.pageId !== this.pageId)
116
+ return;
117
+ const s = Number(e.seq ?? 0);
118
+ // A GAP means frames this client never received. Nothing local can
119
+ // repair that, so it goes straight to the caller as a re-pull.
120
+ if (this.seq > 0 && s > this.seq + 1) {
121
+ this.opts.onDesync(`gap in seq: expected ${this.seq + 1}, received ${s}`);
122
+ }
123
+ if (s > this.seq)
124
+ this.seq = s;
125
+ // My own batch, echoed to the room. It was applied locally when it was
126
+ // made; re-applying is harmless for a set and WRONG for a splice.
127
+ if (e.peerId === this.selfId)
128
+ return;
129
+ const raw = e.ops ?? [];
130
+ // Admission is checked on the RECEIVING side too. This is the only guard
131
+ // that exists against a peer not running our code.
132
+ const ok = raw.filter((p) => isSyncablePatch(p));
133
+ if (ok.length > 0)
134
+ this.opts.onRemote(ok);
135
+ break;
136
+ }
137
+ case 'snapreq':
138
+ // THE YIELD RULE. Never answered. Sending a snapshot would make this
139
+ // client an authority on the document — exactly the position it declines
140
+ // to hold, and the reason the rest of this class can stay this small.
141
+ break;
142
+ case 'ckpt': {
143
+ if (e.pageId !== this.pageId)
144
+ return;
145
+ // Compared only at the SAME seq: two documents at different points in
146
+ // the order are supposed to differ.
147
+ //
148
+ // This client computes no digest of its own, so it cannot tell a real
149
+ // mismatch from a peer simply publishing. It treats a same-seq
150
+ // checkpoint arriving while idle as reason enough to re-pull, which is
151
+ // the CONSERVATIVE direction for a client whose repair is a cheap HTTP
152
+ // GET rather than a document exchange. If this proves noisy in practice
153
+ // the fix is to compute a digest and compare it — not to delete the
154
+ // branch and go back to trusting a tree nobody verified.
155
+ if (Number(e.seq ?? 0) === this.seq && this.pending.size === 0) {
156
+ this.opts.onDesync(`checkpoint published at seq ${this.seq}; re-pulling to be sure`);
157
+ }
158
+ break;
159
+ }
160
+ default:
161
+ break;
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The one way a tool answers.
3
+ *
4
+ * Every tool returns through here so the content shape is decided in a single
5
+ * place — a hand-built content array is the shape that drifts, and a drifted one
6
+ * fails inside the client rather than here.
7
+ */
8
+ export function text(value) {
9
+ const body = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
10
+ return { content: [{ type: 'text', text: body }] };
11
+ }
12
+ /** A base64 image, optionally followed by a note the model should read. */
13
+ export function image(dataBase64, mimeType = 'image/png', note) {
14
+ const content = [{ type: 'image', data: dataBase64, mimeType }];
15
+ if (note !== undefined) {
16
+ content.push({
17
+ type: 'text',
18
+ text: typeof note === 'string' ? note : JSON.stringify(note, null, 2),
19
+ });
20
+ }
21
+ return { content };
22
+ }
23
+ /**
24
+ * Several images (one per breakpoint, say), optionally followed by a note.
25
+ * Separate blocks rather than one tiled sheet so each is seen at a readable size.
26
+ */
27
+ export function images(items, note) {
28
+ const content = items.map((it) => ({
29
+ type: 'image',
30
+ data: it.dataBase64,
31
+ mimeType: it.mimeType ?? 'image/png',
32
+ }));
33
+ if (note !== undefined) {
34
+ content.push({
35
+ type: 'text',
36
+ text: typeof note === 'string' ? note : JSON.stringify(note, null, 2),
37
+ });
38
+ }
39
+ return { content };
40
+ }
package/dist/server.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { Session } from './transport/auth.js';
4
+ import { registerApiTools } from './tools/api.js';
5
+ import { registerSessionTools } from './tools/session.js';
6
+ import { registerPageTools } from './tools/page.js';
7
+ import { registerLiveTools } from './tools/live.js';
8
+ const INSTRUCTIONS = `Design and operate a Store Builder site.
9
+
10
+ Call sb_connect first. Then:
11
+ - sb_api_find describes what the platform can do; sb_api_call executes it. Between them
12
+ they reach all 310 API operations, so most merchant work needs no other tool.
13
+ - Mutating calls default to dry_run:true and send NOTHING. Pass dry_run:false to act.
14
+ - /api/v1 paths need SB_TOKEN; every other path uses the session from sb_connect. The
15
+ platform refuses each credential on the other's surface, so this is not interchangeable.
16
+ - When sb_api_find returns body_warning or body_note, do not invent a request body. Read
17
+ the matching GET first and send back a modified copy.
18
+
19
+ To DESIGN a page: sb_page_open, then sb_catalog_search to pick element types, then sb_add
20
+ with a NESTED spec (one call per section, not per node), then sb_set for styling.
21
+ - Writes default to dry_run:true and change nothing. Pass dry_run:false to act.
22
+ - sb_set writes PER BREAKPOINT. A visual quantity written at base renders on the canvas and
23
+ vanishes on publish; pass base:true only for identity or content.
24
+ - sb_outline, never a raw document dump. Read one node with sb_node_read.
25
+ - A node flagged global is a SHARED master: editing it changes every page that carries it.
26
+ A node flagged overlay is not part of the page at all.
27
+
28
+ - sb_live_join makes the agent VISIBLE: edits then appear in anyone's open editor as they
29
+ happen, with a cursor that moves to the node being changed.
30
+ - sb_look saves, renders through the platform's own renderer, and hands back screenshots
31
+ plus measured node boxes. Judge the design from those; do not guess at it.
32
+ - sb_bind puts real store data in the page instead of placeholder text.`;
33
+ /**
34
+ * The published version, read from package.json at runtime so serverInfo never
35
+ * drifts from what npm shipped. package.json sits one level above both dist/ and
36
+ * src/, so the same relative URL resolves in a build and in a source checkout.
37
+ * Never blocks startup over a version string.
38
+ */
39
+ export function pkgVersion() {
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
42
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
43
+ }
44
+ catch {
45
+ return '0.0.0';
46
+ }
47
+ }
48
+ export function buildContext() {
49
+ const base = process.env.SB_API ?? 'http://localhost:8080';
50
+ return { base, session: new Session(base), apiKey: process.env.SB_TOKEN };
51
+ }
52
+ export function createServer(ctx = buildContext()) {
53
+ const server = new McpServer({ name: 'sbuilder', version: pkgVersion(), title: 'Store Builder' }, { instructions: INSTRUCTIONS });
54
+ registerSessionTools(server, ctx);
55
+ registerApiTools(server, ctx);
56
+ const pageSession = registerPageTools(server, ctx);
57
+ registerLiveTools(server, ctx, pageSession);
58
+ return server;
59
+ }
package/dist/smoke.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Offline self-test. No network, no MCP transport — just the pure logic, so a
3
+ * broken build fails before anything is published. Must end with ALL GOOD.
4
+ */
5
+ import { text } from './mcp/response.js';
6
+ import { createServer, pkgVersion } from './server.js';
7
+ import { API_OPERATIONS } from './catalog/api.generated.js';
8
+ import { searchOperations, describeOperation } from './catalog/search.js';
9
+ import { credentialFor } from './transport/credential.js';
10
+ function check(label, ok) {
11
+ if (!ok) {
12
+ console.error(`FAIL: ${label}`);
13
+ process.exit(1);
14
+ }
15
+ console.error(`ok: ${label}`);
16
+ }
17
+ export async function runSmoke() {
18
+ check('text() wraps a string', text('x').content[0].text === 'x');
19
+ check('pkgVersion() is not empty', pkgVersion().length > 0);
20
+ check('createServer() builds', createServer() !== null);
21
+ check('API index has 300+ operations', API_OPERATIONS.length > 300);
22
+ check('every operation has a credential', API_OPERATIONS.every((o) => ['apiKey', 'siteScoped', 'none'].includes(o.credential)));
23
+ check('every credential matches the routing rule', API_OPERATIONS.every((o) => o.credential === credentialFor(o.path)));
24
+ check('search finds menu operations', searchOperations('menu').length > 0);
25
+ check('search returns nothing for nonsense', searchOperations('zzzzqqqq').length === 0);
26
+ check('no operation reports both body verdicts at once', API_OPERATIONS.every((o) => {
27
+ const d = describeOperation(o);
28
+ return !(d.body_warning !== undefined && d.body_note !== undefined);
29
+ }));
30
+ const { ELEMENTS, ELEMENT_SOURCE } = await import('./catalog/elements.generated.js');
31
+ check('element catalog is populated', Object.keys(ELEMENTS).length === ELEMENT_SOURCE.count);
32
+ check('every element carries AI hints', Object.values(ELEMENTS).every((e) => e.description.length > 0));
33
+ // An end-to-end pass through the document core: build a section with a child,
34
+ // and assert the result is something the platform would actually store.
35
+ const { PageDoc } = await import('./domains/site/document.js');
36
+ const { addSubtree, setKeys } = await import('./domains/site/builder.js');
37
+ const { validateForSave } = await import('./domains/site/validate.js');
38
+ const doc = PageDoc.from({
39
+ schema_version: 2,
40
+ root_node_id: 'rt',
41
+ nodes: {
42
+ rt: {
43
+ id: 'rt',
44
+ data: { type: 'root', parent: null, nodes: [], isCanvas: true, hidden: false, custom: {} },
45
+ style: {}, config: {}, specials: {}, responsive: {}, events: [], bindings: [],
46
+ },
47
+ },
48
+ });
49
+ const built = addSubtree(doc, 'rt', { type: 'flex-section', children: [{ type: 'heading' }] });
50
+ doc.apply(built.patches);
51
+ check('builder linked the subtree', doc.outline({ depth: 2 })[0].kids?.length === 1);
52
+ check('builder produces a storable document', validateForSave(doc).length === 0);
53
+ doc.apply(setKeys(doc, built.ids[0], { gap: '24px' }, { namespace: 'style' }));
54
+ const section = doc.node(built.ids[0]);
55
+ check('sb_set writes per breakpoint, not at base', section.responsive.desktop?.style?.gap === '24px' && section.style.gap === undefined);
56
+ doc.apply(setKeys(doc, built.ids[0], { maxWidth: '1200px' }, { namespace: 'style', base: true }));
57
+ const seeded = doc.node(built.ids[0]);
58
+ check('a base style is written, not refused', seeded.style.maxWidth === '1200px');
59
+ const { BINDING_SOURCES } = await import('./catalog/elements.generated.js');
60
+ check('binding sources are populated', BINDING_SOURCES.length > 15);
61
+ const { bindNode } = await import('./tools/live.js');
62
+ const bd = PageDoc.from({
63
+ schema_version: 2,
64
+ root_node_id: 'rt',
65
+ nodes: {
66
+ rt: {
67
+ id: 'rt',
68
+ data: { type: 'root', parent: null, nodes: ['he_1'], isCanvas: true, hidden: false, custom: {} },
69
+ style: {}, config: {}, specials: {}, responsive: {}, events: [], bindings: [],
70
+ },
71
+ he_1: {
72
+ id: 'he_1',
73
+ data: { type: 'heading', parent: 'rt', nodes: [], isCanvas: false, hidden: false, custom: {} },
74
+ style: {}, config: {}, specials: {}, responsive: {}, events: [], bindings: [],
75
+ },
76
+ },
77
+ });
78
+ bd.apply(bindNode(bd, 'he_1', 'product.title', 'specials.text'));
79
+ check('a valid binding lands', bd.node('he_1').bindings.length === 1);
80
+ let bindRefused = false;
81
+ try {
82
+ bindNode(bd, 'he_1', 'product.title', 'style.color');
83
+ }
84
+ catch {
85
+ bindRefused = true;
86
+ }
87
+ check('a non-specials binding field is REFUSED', bindRefused);
88
+ const { reviewDesign } = await import('./domains/site/review.js');
89
+ // Fill the heading before claiming the section is finished. The first version
90
+ // of this check asserted the section built above was clean; it was not — the
91
+ // heading still carried the placeholder the element ships with, and the
92
+ // reviewer said so. The check was wrong, not the reviewer.
93
+ doc.apply(setKeys(doc, built.ids[1], { text: 'Autumn sale' }, { namespace: 'specials' }));
94
+ check('a finished section reviews clean', reviewDesign(doc).length === 0);
95
+ // ...and an unfilled one must. A reviewer that never fires is indistinguishable
96
+ // from one that is not wired up.
97
+ const { addSubtree: add2 } = await import('./domains/site/builder.js');
98
+ doc.apply(add2(doc, 'rt', { type: 'flex-section', children: [{ type: 'text' }] }).patches);
99
+ const codes = reviewDesign(doc).map((f) => f.code);
100
+ check('an unfilled placeholder IS reported', codes.includes('placeholder_content'));
101
+ console.error('ALL GOOD');
102
+ }
103
+ runSmoke().catch((err) => {
104
+ console.error('smoke threw:', err);
105
+ process.exit(1);
106
+ });
@@ -0,0 +1,96 @@
1
+ import { z } from 'zod';
2
+ import { API_OPERATIONS } from '../catalog/api.generated.js';
3
+ import { searchOperations, describeOperation } from '../catalog/search.js';
4
+ import { request, redact } from '../transport/http.js';
5
+ import { text } from '../mcp/response.js';
6
+ /**
7
+ * Pick the credential a path needs.
8
+ *
9
+ * `siteScoped` PREFERS the API key. Both open the private surface, and the key
10
+ * is the narrower of the two: revocable on its own, bounded by its scopes
11
+ * intersected with its minter's live role, and bound to one store — while a
12
+ * session carries the whole account. Preferring it is also what lets a merchant
13
+ * connect an agent with one env var and no password.
14
+ */
15
+ export function tokenFor(ctx, credential) {
16
+ if (credential === 'apiKey') {
17
+ // Naming the env var matters: the alternative is a 401 api_key_required
18
+ // from the platform, which reads like a permissions problem rather than an
19
+ // unset variable.
20
+ if (!ctx.apiKey) {
21
+ throw new Error('sbuilder: SB_TOKEN is not set — /api/v1 operations need an API key');
22
+ }
23
+ return ctx.apiKey;
24
+ }
25
+ if (credential === 'siteScoped') {
26
+ if (ctx.apiKey)
27
+ return ctx.apiKey;
28
+ if (ctx.session.loggedIn())
29
+ return ctx.session.token();
30
+ throw new Error('sbuilder: no credential for this site. Set SB_TOKEN to an API key from the site\'s ' +
31
+ 'Agent app, or call sb_connect with SB_EMAIL and SB_PASSWORD.');
32
+ }
33
+ return undefined;
34
+ }
35
+ export async function callOperation(ctx, args) {
36
+ const op = API_OPERATIONS.find((o) => o.id === args.id);
37
+ if (!op)
38
+ throw new Error(`sbuilder: unknown operation "${args.id}" — use sb_api_find first`);
39
+ // Substitute {name} placeholders. A missing one would otherwise be sent
40
+ // literally, and a path containing a brace 404s with nothing to explain it.
41
+ let path = op.path;
42
+ for (const m of op.path.matchAll(/\{([^}]+)\}/g)) {
43
+ const name = m[1];
44
+ const value = args.path_params?.[name];
45
+ if (value === undefined) {
46
+ throw new Error(`sbuilder: operation ${op.id} needs path param "${name}"`);
47
+ }
48
+ path = path.replace(`{${name}}`, encodeURIComponent(value));
49
+ }
50
+ const token = tokenFor(ctx, op.credential);
51
+ const dryRun = args.dry_run !== false;
52
+ if (dryRun) {
53
+ return {
54
+ dry_run: true,
55
+ would_send: redact({
56
+ method: op.method,
57
+ url: ctx.base.replace(/\/$/, '') + path,
58
+ query: args.query,
59
+ Authorization: token ? `Bearer ${token}` : undefined,
60
+ body: args.body,
61
+ }),
62
+ note: 'Nothing was sent. Re-call with dry_run:false to execute.',
63
+ };
64
+ }
65
+ return request({
66
+ base: ctx.base,
67
+ method: op.method,
68
+ path,
69
+ token,
70
+ query: args.query,
71
+ body: args.body,
72
+ fetchImpl: ctx.fetchImpl,
73
+ });
74
+ }
75
+ export function registerApiTools(server, ctx) {
76
+ server.tool('sb_api_find', 'Find platform API operations by intent. Returns each match with its real parameter ' +
77
+ 'schema, which credential it needs, and an explicit note when the document fails to ' +
78
+ 'describe the request body. Use this before sb_api_call: the tool list is short, but ' +
79
+ 'this index reaches all 310 operations.', {
80
+ query: z
81
+ .string()
82
+ .describe('What you want to do, in words: "create a menu", "list orders", "upload media"'),
83
+ tag: z.string().optional().describe('Narrow to one tag, e.g. "menus", "products", "theme"'),
84
+ limit: z.number().int().min(1).max(50).optional(),
85
+ }, async ({ query, tag, limit }) => text(searchOperations(query, { tag, limit }).map(describeOperation)));
86
+ server.tool('sb_api_call', 'Execute one operation found by sb_api_find. Defaults to a dry run that sends nothing ' +
87
+ 'and shows the request it would have made.', {
88
+ id: z
89
+ .string()
90
+ .describe('Operation id from sb_api_find, e.g. "get:/api/sites/{siteID}/menus"'),
91
+ path_params: z.record(z.string()).optional(),
92
+ query: z.record(z.string()).optional(),
93
+ body: z.unknown().optional(),
94
+ dry_run: z.boolean().optional().describe('Defaults to true. Pass false to actually send.'),
95
+ }, async (args) => text(await callOperation(ctx, args)));
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ import { tokenFor } from './api.js';
2
+ /**
3
+ * The credential for a private, site-scoped call.
4
+ *
5
+ * A one-line re-export so `transport/` does not reach into `tools/api.ts` for
6
+ * the rule and quietly grow a second copy of it. There is exactly one answer to
7
+ * "which token opens /api/sites", and it lives in `tokenFor`.
8
+ */
9
+ export function siteToken(ctx) {
10
+ return tokenFor(ctx, 'siteScoped');
11
+ }
@@ -0,0 +1,104 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { text, images } from '../mcp/response.js';
4
+ import { BINDING_SOURCES } from '../catalog/elements.generated.js';
5
+ import { previewUrl } from '../vision/preview.js';
6
+ import { shoot, DEFAULT_WIDTHS } from '../vision/shoot.js';
7
+ import { RealtimeSocket } from '../transport/socket.js';
8
+ import { LiveSession } from '../live/session.js';
9
+ import { siteToken } from './credentialpick.js';
10
+ import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
11
+ /**
12
+ * Bind a node's content to real store data.
13
+ *
14
+ * Two validations, and both close a SILENT no-op:
15
+ *
16
+ * - the `source` must be one the renderer's scope actually provides. An unknown
17
+ * one resolves to nothing and the element renders its own placeholder, which
18
+ * looks exactly like "the data has not loaded yet".
19
+ * - the `field` must live under `specials`. `applyBindings` (schema/src/binding.ts)
20
+ * reads the namespace off the field and `continue`s on anything else — so a
21
+ * `style.color` binding is stored, saved, published, and ignored forever.
22
+ */
23
+ export function bindNode(doc, id, source, field) {
24
+ const node = doc.node(id);
25
+ if (!BINDING_SOURCES.includes(source)) {
26
+ throw new Error(`sbuilder: "${source}" is not a binding source the renderer provides, so the binding ` +
27
+ `would render as a placeholder forever. Valid sources: ${BINDING_SOURCES.join(', ')}.`);
28
+ }
29
+ const dot = field.indexOf('.');
30
+ if (dot < 0 || field.slice(0, dot) !== 'specials' || !field.slice(dot + 1)) {
31
+ throw new Error(`sbuilder: a binding field must be "specials.<key>", not "${field}". The renderer ignores ` +
32
+ 'every other namespace, so the binding would be stored and never applied.');
33
+ }
34
+ return [
35
+ {
36
+ op: 'insert',
37
+ path: ['nodes', id, 'bindings'],
38
+ index: node.bindings.length,
39
+ value: { id: randomBytes(6).toString('hex'), source, field },
40
+ },
41
+ ];
42
+ }
43
+ export function registerLiveTools(server, ctx, session) {
44
+ server.tool('sb_live_join', "Join the editor's live-edit room for this site, as a visible peer. Once joined, every " +
45
+ 'sb_add / sb_set / sb_move / sb_remove / sb_bind also goes out as a live op, so anyone ' +
46
+ 'with the editor open watches the page assemble. Safe alongside a human: this client ' +
47
+ 'always yields — it never answers a snapshot request and re-pulls on any divergence.', { site_id: z.string() }, async ({ site_id }) => {
48
+ const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
49
+ const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, () => siteToken(ctx));
50
+ const live = new LiveSession(socket, {
51
+ onRemote: (patches) => session.applyRemote(patches),
52
+ onDesync: (reason) => session.markStale(reason),
53
+ });
54
+ socket.connect();
55
+ session.attachLive(live);
56
+ return text({
57
+ joined: site_id,
58
+ note: 'Edits now publish to the room as they are made. Call sb_page_open next.',
59
+ });
60
+ });
61
+ server.tool('sb_look', "Save the open page, render it through the platform's own renderer, and return " +
62
+ 'screenshots at desktop, tablet and mobile widths — plus the measured bounding box of ' +
63
+ 'every node. Pass node_id to frame ONE element instead of the whole page. Judge your ' +
64
+ 'own work from these rather than guessing.', {
65
+ widths: z.array(z.number().int().min(320).max(2560)).optional(),
66
+ with_boxes: z.boolean().optional(),
67
+ node_id: z
68
+ .string()
69
+ .optional()
70
+ .describe('Frame just this node instead of the whole page — how a designer looks at one card'),
71
+ }, async ({ widths, with_boxes, node_id }) => {
72
+ await session.save();
73
+ const { siteId, pageId } = session.location();
74
+ const url = await previewUrl(ctx, siteId, pageId);
75
+ const shots = await shoot(url, { widths: widths ?? DEFAULT_WIDTHS, node: node_id });
76
+ // The boxes feed the presence cursor as well as the agent's own reading.
77
+ session.noteBoxes(shots[0]?.boxes ?? []);
78
+ // The findings ride WITH the picture. Judging a page by eye and judging it
79
+ // by rule are the same act, and separating them is how the second one gets
80
+ // skipped.
81
+ const findings = reviewDesign(session.current());
82
+ return images(shots.map((s) => ({ dataBase64: s.pngBase64 })), {
83
+ widths: shots.map((s) => s.width),
84
+ ...(node_id ? { framed: node_id } : {}),
85
+ ...(with_boxes === false ? {} : { boxes: shots[0]?.boxes ?? [] }),
86
+ ...(findings.length > 0 ? { findings, findings_notice: REVIEW_NOTICE } : {}),
87
+ });
88
+ });
89
+ server.tool('sb_bind', "Bind a node's content to real store data, so the page shows actual products rather than " +
90
+ 'placeholder text.', {
91
+ id: z.string(),
92
+ source: z.string().describe(`One of: ${BINDING_SOURCES.join(', ')}`),
93
+ field: z.string().describe('Where the value lands, always "specials.<key>"'),
94
+ dry_run: z.boolean().optional(),
95
+ }, async ({ id, source, field, dry_run }) => {
96
+ const d = session.current();
97
+ const patches = bindNode(d, id, source, field);
98
+ if (dry_run !== false)
99
+ return text({ dry_run: true, patches });
100
+ session.applyAndPublish(patches);
101
+ await session.save();
102
+ return text({ bound: id, source, field, rev: d.rev });
103
+ });
104
+ }