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,383 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { text } from '../mcp/response.js';
|
|
3
|
+
import { loadSource, saveSource } from '../transport/pages.js';
|
|
4
|
+
import { PageDoc } from '../domains/site/document.js';
|
|
5
|
+
import { addSubtree, setKeys, moveNode, removeNode, duplicateNode, } from '../domains/site/builder.js';
|
|
6
|
+
import { request } from '../transport/http.js';
|
|
7
|
+
import { siteToken } from './credentialpick.js';
|
|
8
|
+
import { validateForSave } from '../domains/site/validate.js';
|
|
9
|
+
import { reviewDesign, REVIEW_NOTICE } from '../domains/site/review.js';
|
|
10
|
+
import { globalWarning, RESPONSIVE_NOTICE } from '../domains/site/traps.js';
|
|
11
|
+
import { ELEMENTS, TRAIT_WRITES } from '../catalog/elements.generated.js';
|
|
12
|
+
/**
|
|
13
|
+
* Findings, in the shape every surface returns them.
|
|
14
|
+
*
|
|
15
|
+
* Spread rather than repeated: three tools attach this, and three hand-written
|
|
16
|
+
* copies of a directive is how one of them quietly loses it.
|
|
17
|
+
*/
|
|
18
|
+
function reviewField(doc) {
|
|
19
|
+
const findings = reviewDesign(doc);
|
|
20
|
+
return findings.length > 0 ? { findings, findings_notice: REVIEW_NOTICE } : {};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The one open page.
|
|
24
|
+
*
|
|
25
|
+
* The write tools share it rather than each re-fetching: a fetch per edit would
|
|
26
|
+
* discard local work on every call, and would turn one hero section into forty
|
|
27
|
+
* round trips.
|
|
28
|
+
*/
|
|
29
|
+
export class PageSession {
|
|
30
|
+
ctx;
|
|
31
|
+
doc = null;
|
|
32
|
+
siteId = '';
|
|
33
|
+
pageId = '';
|
|
34
|
+
live = null;
|
|
35
|
+
stale = null;
|
|
36
|
+
boxes = [];
|
|
37
|
+
constructor(ctx) {
|
|
38
|
+
this.ctx = ctx;
|
|
39
|
+
}
|
|
40
|
+
attachLive(live) {
|
|
41
|
+
this.live = live;
|
|
42
|
+
}
|
|
43
|
+
location() {
|
|
44
|
+
this.current();
|
|
45
|
+
return { siteId: this.siteId, pageId: this.pageId };
|
|
46
|
+
}
|
|
47
|
+
/** Remember where each node landed, so the presence cursor can be honest. */
|
|
48
|
+
noteBoxes(boxes) {
|
|
49
|
+
this.boxes = boxes;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Apply MY patches and, when joined to a room, put them on the wire.
|
|
53
|
+
*
|
|
54
|
+
* ONE method rather than two calls at every site, because "applied locally and
|
|
55
|
+
* forgot to publish" is invisible: this session's document is right, the save
|
|
56
|
+
* is right, and only the humans watching see nothing happen.
|
|
57
|
+
*/
|
|
58
|
+
applyAndPublish(patches) {
|
|
59
|
+
const d = this.current();
|
|
60
|
+
d.apply(patches);
|
|
61
|
+
this.live?.publish(patches);
|
|
62
|
+
// Move the cursor to what was just touched, but ONLY when a real
|
|
63
|
+
// measurement exists. Presence with a made-up coordinate is theatre;
|
|
64
|
+
// presence with a measured one is information.
|
|
65
|
+
const touched = String(patches[0]?.path[1] ?? '');
|
|
66
|
+
const box = this.boxes.find((b) => b.id === touched);
|
|
67
|
+
if (box && this.live) {
|
|
68
|
+
this.live.select(touched);
|
|
69
|
+
this.live.cursor(box.x + box.w / 2, box.y + box.h / 2);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
applyRemote(patches) {
|
|
73
|
+
this.doc?.apply(patches);
|
|
74
|
+
}
|
|
75
|
+
/** The yield rule's local half: the next save re-pulls instead of overwriting. */
|
|
76
|
+
markStale(reason) {
|
|
77
|
+
this.stale = reason;
|
|
78
|
+
}
|
|
79
|
+
async open(siteId, pageId) {
|
|
80
|
+
const src = await loadSource(this.ctx, siteId, pageId);
|
|
81
|
+
this.doc = PageDoc.from(src.document);
|
|
82
|
+
this.siteId = siteId;
|
|
83
|
+
this.pageId = pageId;
|
|
84
|
+
return this.doc.outline();
|
|
85
|
+
}
|
|
86
|
+
current() {
|
|
87
|
+
if (!this.doc)
|
|
88
|
+
throw new Error('sbuilder: no page is open — call sb_page_open first');
|
|
89
|
+
return this.doc;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Validate, then save.
|
|
93
|
+
*
|
|
94
|
+
* The validation is not belt-and-braces. The platform refuses a band-order
|
|
95
|
+
* violation or a broken tree on EVERY save, and learning that from a 409 one
|
|
96
|
+
* autosave later means the agent has spent the interval editing a tree nobody
|
|
97
|
+
* will ever store.
|
|
98
|
+
*/
|
|
99
|
+
async save() {
|
|
100
|
+
if (this.stale) {
|
|
101
|
+
// THE YIELD RULE. The room moved in a way this client cannot reconcile, so
|
|
102
|
+
// it must not write its copy over whatever is there now. Re-pull, and make
|
|
103
|
+
// the caller redo the intent against the current tree — loudly, because a
|
|
104
|
+
// silently dropped edit is the outcome this whole rule exists to prevent.
|
|
105
|
+
const reason = this.stale;
|
|
106
|
+
this.stale = null;
|
|
107
|
+
await this.open(this.siteId, this.pageId);
|
|
108
|
+
throw new Error(`sbuilder: the page changed under this session (${reason}). It has been re-loaded from ` +
|
|
109
|
+
'the server; re-read it with sb_outline and reapply your change.');
|
|
110
|
+
}
|
|
111
|
+
const d = this.current();
|
|
112
|
+
const problems = validateForSave(d);
|
|
113
|
+
if (problems.length > 0) {
|
|
114
|
+
throw new Error(`sbuilder: refusing to save — ${problems.join(' ')}`);
|
|
115
|
+
}
|
|
116
|
+
await saveSource(this.ctx, this.siteId, this.pageId, d.doc);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* What one inspector control writes.
|
|
121
|
+
*
|
|
122
|
+
* 83 of the 372 controls declare it in the platform's trait registry. The rest
|
|
123
|
+
* live inside a Vue widget's prop closure, which is not machine-readable — so
|
|
124
|
+
* they come back named but undescribed, with the honest reason. Saying nothing
|
|
125
|
+
* would read as "this control writes nothing".
|
|
126
|
+
*/
|
|
127
|
+
function describeControl(key) {
|
|
128
|
+
const d = TRAIT_WRITES[key];
|
|
129
|
+
if (!d) {
|
|
130
|
+
return {
|
|
131
|
+
writes: null,
|
|
132
|
+
note: 'The platform does not declare what this control writes (its widget builds the ' +
|
|
133
|
+
'binding in Vue). Read a node that already uses it with sb_node_read, or set the ' +
|
|
134
|
+
'CSS property directly — style is open.',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return { label: d.label, writes: d.writes, ...(d.defaults ? { defaults: d.defaults } : {}) };
|
|
138
|
+
}
|
|
139
|
+
const STYLE_NOTE = 'The `style` namespace is OPEN CSS: any camelCase key becomes a CSS property ' +
|
|
140
|
+
'(schema/src/satelliteCss.ts camelToKebab), so you can set anything CSS can express, ' +
|
|
141
|
+
'whether or not a control exists for it. `config` and `specials` are NOT open — they are ' +
|
|
142
|
+
"per-element, and this element's `defaults` name the keys it actually uses.";
|
|
143
|
+
const specSchema = z.lazy(() => z.object({
|
|
144
|
+
type: z.string(),
|
|
145
|
+
name: z.string().optional(),
|
|
146
|
+
style: z.record(z.unknown()).optional(),
|
|
147
|
+
config: z.record(z.unknown()).optional(),
|
|
148
|
+
specials: z.record(z.unknown()).optional(),
|
|
149
|
+
children: z.array(specSchema).optional(),
|
|
150
|
+
}));
|
|
151
|
+
export function registerPageTools(server, ctx) {
|
|
152
|
+
const session = new PageSession(ctx);
|
|
153
|
+
server.tool('sb_page_open', 'Open a page for editing and return its outline. Call before any sb_add / sb_set / ' +
|
|
154
|
+
'sb_move / sb_remove. Find page ids with sb_api_find "list pages".', { site_id: z.string(), page_id: z.string() }, async ({ site_id, page_id }) => {
|
|
155
|
+
const outline = await session.open(site_id, page_id);
|
|
156
|
+
return text({ outline, ...reviewField(session.current()) });
|
|
157
|
+
});
|
|
158
|
+
server.tool('sb_outline', 'The open page as a compressed tree — id, type, name, child count, band, and whether a ' +
|
|
159
|
+
'node is a shared global or a site overlay. Never the raw document: a real page is ' +
|
|
160
|
+
'hundreds of KB of JSON.', { depth: z.number().int().min(1).max(6).optional() }, async ({ depth }) => text(session.current().outline({ depth })));
|
|
161
|
+
server.tool('sb_node_read', 'One node in full — style, config, specials, per-breakpoint overrides, bindings.', { id: z.string() }, async ({ id }) => {
|
|
162
|
+
const d = session.current();
|
|
163
|
+
const node = d.node(id);
|
|
164
|
+
const warn = globalWarning(d.doc, id);
|
|
165
|
+
return text({ node, ...(warn ? { warning: warn } : {}) });
|
|
166
|
+
});
|
|
167
|
+
server.tool('sb_catalog_search', "Find an element type by what you want it to do. Searches the platform's own AI hints — " +
|
|
168
|
+
'when to use each element, when not to, and what content suits it.', { query: z.string(), limit: z.number().int().min(1).max(30).optional() }, async ({ query, limit }) => {
|
|
169
|
+
const terms = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
170
|
+
const scored = Object.values(ELEMENTS)
|
|
171
|
+
.map((el) => {
|
|
172
|
+
const hay = [el.type, el.label, el.category, el.description, ...el.semantics, ...el.useWhen]
|
|
173
|
+
.join(' ')
|
|
174
|
+
.toLowerCase();
|
|
175
|
+
return { el, score: terms.filter((t) => hay.includes(t)).length };
|
|
176
|
+
})
|
|
177
|
+
.filter((s) => s.score > 0)
|
|
178
|
+
.sort((a, b) => b.score - a.score || a.el.type.localeCompare(b.el.type))
|
|
179
|
+
.slice(0, limit ?? 10);
|
|
180
|
+
return text(scored.map(({ el }) => ({
|
|
181
|
+
type: el.type,
|
|
182
|
+
label: el.label,
|
|
183
|
+
category: el.category,
|
|
184
|
+
isContainer: el.isContainer,
|
|
185
|
+
isRootOnly: el.isRootOnly,
|
|
186
|
+
description: el.description,
|
|
187
|
+
useWhen: el.useWhen,
|
|
188
|
+
avoidWhen: el.avoidWhen,
|
|
189
|
+
contentTips: el.contentTips,
|
|
190
|
+
})));
|
|
191
|
+
});
|
|
192
|
+
server.tool('sb_traits_for', "This element's INSPECTOR, exactly as a person sees it: tabs, groups, and every control " +
|
|
193
|
+
'in them — with what each control writes when the platform declares it. Read this ' +
|
|
194
|
+
'before styling an element; it is the difference between designing it and guessing at it.', {
|
|
195
|
+
type: z.string(),
|
|
196
|
+
control: z.string().optional().describe('Narrow to one control, e.g. "font_size"'),
|
|
197
|
+
}, async ({ type, control }) => {
|
|
198
|
+
const el = ELEMENTS[type];
|
|
199
|
+
if (!el)
|
|
200
|
+
throw new Error(`sbuilder: unknown element "${type}" — use sb_catalog_search`);
|
|
201
|
+
if (control) {
|
|
202
|
+
if (!el.controls.includes(control)) {
|
|
203
|
+
throw new Error(`sbuilder: ${type} has no control "${control}". It has: ${el.controls.join(', ')}.`);
|
|
204
|
+
}
|
|
205
|
+
return text({ type, control, ...describeControl(control) });
|
|
206
|
+
}
|
|
207
|
+
return text({
|
|
208
|
+
type: el.type,
|
|
209
|
+
inspector: el.inspector.map((t) => ({
|
|
210
|
+
tab: t.tab,
|
|
211
|
+
groups: t.groups.map((g) => ({
|
|
212
|
+
group: g.label,
|
|
213
|
+
controls: g.controls.map((c) => ({ control: c, ...describeControl(c) })),
|
|
214
|
+
})),
|
|
215
|
+
})),
|
|
216
|
+
// The keys this element actually seeds. For `config` and `specials` —
|
|
217
|
+
// which, unlike `style`, are NOT open — this is the machine-readable
|
|
218
|
+
// answer to "what does this element store", and often the only one.
|
|
219
|
+
defaults: el.defaults,
|
|
220
|
+
isContainer: el.isContainer,
|
|
221
|
+
isRootOnly: el.isRootOnly,
|
|
222
|
+
childAllows: el.childAllows,
|
|
223
|
+
contentTips: el.contentTips,
|
|
224
|
+
style_is_open_css: STYLE_NOTE,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
server.tool('sb_add', 'Add an element — or a whole NESTED subtree — under a parent. One call builds a complete ' +
|
|
228
|
+
'section: pass children rather than calling this once per node.', {
|
|
229
|
+
parent_id: z.string(),
|
|
230
|
+
spec: specSchema,
|
|
231
|
+
index: z.number().int().min(0).optional(),
|
|
232
|
+
dry_run: z.boolean().optional(),
|
|
233
|
+
}, async ({ parent_id, spec, index, dry_run }) => {
|
|
234
|
+
const d = session.current();
|
|
235
|
+
const { patches, ids } = addSubtree(d, parent_id, spec, index);
|
|
236
|
+
if (dry_run !== false) {
|
|
237
|
+
return text({ dry_run: true, would_add: ids.length, patches: patches.length });
|
|
238
|
+
}
|
|
239
|
+
session.applyAndPublish(patches);
|
|
240
|
+
await session.save();
|
|
241
|
+
return text({ added: ids, rev: d.rev });
|
|
242
|
+
});
|
|
243
|
+
server.tool('sb_set', 'Write style, config or specials keys on a node. Style and config are written PER ' +
|
|
244
|
+
'BREAKPOINT by default — a visual quantity written at base vanishes on publish.', {
|
|
245
|
+
id: z.string(),
|
|
246
|
+
namespace: z.enum(['style', 'config', 'specials']),
|
|
247
|
+
keys: z.record(z.unknown()),
|
|
248
|
+
breakpoint: z.enum(['desktop', 'laptop', 'tablet', 'mobile']).optional(),
|
|
249
|
+
base: z.boolean().optional(),
|
|
250
|
+
state: z.string().optional().describe('An interaction state, e.g. "hover"'),
|
|
251
|
+
dry_run: z.boolean().optional(),
|
|
252
|
+
}, async ({ id, namespace, keys, breakpoint, base, state, dry_run }) => {
|
|
253
|
+
const d = session.current();
|
|
254
|
+
const patches = setKeys(d, id, keys, {
|
|
255
|
+
namespace,
|
|
256
|
+
breakpoint: breakpoint,
|
|
257
|
+
base,
|
|
258
|
+
state,
|
|
259
|
+
});
|
|
260
|
+
if (dry_run !== false)
|
|
261
|
+
return text({ dry_run: true, patches, note: RESPONSIVE_NOTICE });
|
|
262
|
+
session.applyAndPublish(patches);
|
|
263
|
+
await session.save();
|
|
264
|
+
const warn = globalWarning(d.doc, id);
|
|
265
|
+
return text({ set: Object.keys(keys), rev: d.rev, ...(warn ? { warning: warn } : {}) });
|
|
266
|
+
});
|
|
267
|
+
server.tool('sb_move', 'Move a node to another parent at an index.', {
|
|
268
|
+
id: z.string(),
|
|
269
|
+
parent_id: z.string(),
|
|
270
|
+
index: z.number().int().min(0),
|
|
271
|
+
dry_run: z.boolean().optional(),
|
|
272
|
+
}, async ({ id, parent_id, index, dry_run }) => {
|
|
273
|
+
const d = session.current();
|
|
274
|
+
const patches = moveNode(d, id, parent_id, index);
|
|
275
|
+
if (dry_run !== false)
|
|
276
|
+
return text({ dry_run: true, patches });
|
|
277
|
+
session.applyAndPublish(patches);
|
|
278
|
+
await session.save();
|
|
279
|
+
return text({ moved: id, rev: d.rev });
|
|
280
|
+
});
|
|
281
|
+
server.tool('sb_remove', 'Remove a node and its whole subtree.', { id: z.string(), dry_run: z.boolean().optional() }, async ({ id, dry_run }) => {
|
|
282
|
+
const d = session.current();
|
|
283
|
+
const patches = removeNode(d, id);
|
|
284
|
+
if (dry_run !== false)
|
|
285
|
+
return text({ dry_run: true, removing: patches.length });
|
|
286
|
+
session.applyAndPublish(patches);
|
|
287
|
+
await session.save();
|
|
288
|
+
return text({ removed: id, rev: d.rev });
|
|
289
|
+
});
|
|
290
|
+
server.tool('sb_review', 'Everything wrong with the open page that a VISITOR would see — a blank band, a ' +
|
|
291
|
+
'placeholder sentence the author never replaced, an image with no source, a binding ' +
|
|
292
|
+
'that will never resolve. Distinct from whether the page saves: a perfectly storable ' +
|
|
293
|
+
'document can publish as an empty box. Run it before you call a page finished.', {}, async () => {
|
|
294
|
+
const findings = reviewDesign(session.current());
|
|
295
|
+
return text(findings.length === 0
|
|
296
|
+
? { findings: [], verdict: 'Nothing a visitor would notice.' }
|
|
297
|
+
: { findings, findings_notice: REVIEW_NOTICE });
|
|
298
|
+
});
|
|
299
|
+
server.tool('sb_duplicate', 'Copy a node and everything under it, under fresh ids, right after the original. The ' +
|
|
300
|
+
'move a designer makes constantly — build one card, duplicate it twice.', { id: z.string(), dry_run: z.boolean().optional() }, async ({ id, dry_run }) => {
|
|
301
|
+
const d = session.current();
|
|
302
|
+
const { patches, ids } = duplicateNode(d, id);
|
|
303
|
+
if (dry_run !== false)
|
|
304
|
+
return text({ dry_run: true, would_copy: ids.length });
|
|
305
|
+
session.applyAndPublish(patches);
|
|
306
|
+
await session.save();
|
|
307
|
+
return text({ duplicated: id, into: ids[0], nodes: ids.length, rev: d.rev });
|
|
308
|
+
});
|
|
309
|
+
server.tool('sb_templates', "The store's saved section templates — designed sections a person starts from rather " +
|
|
310
|
+
'than assembling one. Use sb_template_use to drop one into the open page.', { site_id: z.string() }, async ({ site_id }) => text(await request({
|
|
311
|
+
base: ctx.base,
|
|
312
|
+
method: 'GET',
|
|
313
|
+
path: `/api/sites/${encodeURIComponent(site_id)}/section-templates`,
|
|
314
|
+
token: siteToken(ctx),
|
|
315
|
+
fetchImpl: ctx.fetchImpl,
|
|
316
|
+
})));
|
|
317
|
+
server.tool('sb_template_use', 'Instantiate a saved section template into a page. The server does the copy, so the ' +
|
|
318
|
+
'section arrives exactly as it was designed — then re-open the page to see it.', {
|
|
319
|
+
site_id: z.string(),
|
|
320
|
+
template_id: z.string(),
|
|
321
|
+
page_id: z.string(),
|
|
322
|
+
dry_run: z.boolean().optional(),
|
|
323
|
+
}, async ({ site_id, template_id, page_id, dry_run }) => {
|
|
324
|
+
const path = `/api/sites/${encodeURIComponent(site_id)}/section-templates/${encodeURIComponent(template_id)}/instantiate`;
|
|
325
|
+
if (dry_run !== false) {
|
|
326
|
+
return text({ dry_run: true, would_post: path, body: { pageId: page_id } });
|
|
327
|
+
}
|
|
328
|
+
const out = await request({
|
|
329
|
+
base: ctx.base,
|
|
330
|
+
method: 'POST',
|
|
331
|
+
path,
|
|
332
|
+
token: siteToken(ctx),
|
|
333
|
+
body: { pageId: page_id },
|
|
334
|
+
fetchImpl: ctx.fetchImpl,
|
|
335
|
+
});
|
|
336
|
+
return text({
|
|
337
|
+
instantiated: template_id,
|
|
338
|
+
into: page_id,
|
|
339
|
+
result: out,
|
|
340
|
+
note: 'Re-open the page with sb_page_open — this session still holds the old tree.',
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
server.tool('sb_page_list', "Every page on the site, with its slug and whether it is live.", { site_id: z.string() }, async ({ site_id }) => text(await request({
|
|
344
|
+
base: ctx.base,
|
|
345
|
+
method: 'GET',
|
|
346
|
+
path: `/api/sites/${encodeURIComponent(site_id)}/pages`,
|
|
347
|
+
token: siteToken(ctx),
|
|
348
|
+
fetchImpl: ctx.fetchImpl,
|
|
349
|
+
})));
|
|
350
|
+
server.tool('sb_page_create', 'Create a page. It arrives empty; sb_page_open seeds its ROOT so you can build into it.', {
|
|
351
|
+
site_id: z.string(),
|
|
352
|
+
name: z.string(),
|
|
353
|
+
settings: z.record(z.unknown()).optional(),
|
|
354
|
+
dry_run: z.boolean().optional(),
|
|
355
|
+
}, async ({ site_id, name, settings, dry_run }) => {
|
|
356
|
+
const path = `/api/sites/${encodeURIComponent(site_id)}/pages`;
|
|
357
|
+
if (dry_run !== false)
|
|
358
|
+
return text({ dry_run: true, would_post: path, body: { name, settings } });
|
|
359
|
+
return text(await request({
|
|
360
|
+
base: ctx.base,
|
|
361
|
+
method: 'POST',
|
|
362
|
+
path,
|
|
363
|
+
token: siteToken(ctx),
|
|
364
|
+
body: { name, ...(settings ? { settings } : {}) },
|
|
365
|
+
fetchImpl: ctx.fetchImpl,
|
|
366
|
+
}));
|
|
367
|
+
});
|
|
368
|
+
server.tool('sb_publish', 'Compile the draft into the live page. PUBLISH CASCADES: a page sharing a global ' +
|
|
369
|
+
'section with others republishes them too, because a header edited once must not go ' +
|
|
370
|
+
'live on one page and stay stale on the rest.', { site_id: z.string(), page_id: z.string(), dry_run: z.boolean().optional() }, async ({ site_id, page_id, dry_run }) => {
|
|
371
|
+
const path = `/api/sites/${encodeURIComponent(site_id)}/pages/${encodeURIComponent(page_id)}/publish`;
|
|
372
|
+
if (dry_run !== false)
|
|
373
|
+
return text({ dry_run: true, would_post: path });
|
|
374
|
+
return text(await request({
|
|
375
|
+
base: ctx.base,
|
|
376
|
+
method: 'POST',
|
|
377
|
+
path,
|
|
378
|
+
token: siteToken(ctx),
|
|
379
|
+
fetchImpl: ctx.fetchImpl,
|
|
380
|
+
}));
|
|
381
|
+
});
|
|
382
|
+
return session;
|
|
383
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { request } from '../transport/http.js';
|
|
3
|
+
import { text } from '../mcp/response.js';
|
|
4
|
+
import { API_OPERATIONS } from '../catalog/api.generated.js';
|
|
5
|
+
/**
|
|
6
|
+
* Log in and report what this server can actually reach.
|
|
7
|
+
*
|
|
8
|
+
* A missing API key is REPORTED, not thrown. The session half of the surface —
|
|
9
|
+
* pages, menus, theme, overlays, forms, settings — works without one, and
|
|
10
|
+
* failing the whole connect over it would hide that from a caller who does not
|
|
11
|
+
* need /api/v1 at all.
|
|
12
|
+
*/
|
|
13
|
+
export async function connect(ctx, args) {
|
|
14
|
+
const email = args.email ?? process.env.SB_EMAIL;
|
|
15
|
+
const password = args.password ?? process.env.SB_PASSWORD;
|
|
16
|
+
// KEY-ONLY MODE. An API key from the site's Agent app opens both surfaces on
|
|
17
|
+
// its own, which is the whole point of the connect button: one env var, no
|
|
18
|
+
// password anywhere. There is no login to do and no site list to fetch — a key
|
|
19
|
+
// belongs to exactly one store, and `GET /api/sites` means "this human's
|
|
20
|
+
// account", which a key deliberately cannot answer.
|
|
21
|
+
if (ctx.apiKey && (!email || !password)) {
|
|
22
|
+
return {
|
|
23
|
+
user: 'api key',
|
|
24
|
+
sites: [],
|
|
25
|
+
api_key: 'present',
|
|
26
|
+
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.',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (!email || !password) {
|
|
33
|
+
throw new Error('sbuilder: set SB_TOKEN to an API key from the site\'s Agent app, or set SB_EMAIL and ' +
|
|
34
|
+
'SB_PASSWORD for a full account session.');
|
|
35
|
+
}
|
|
36
|
+
await ctx.session.login(email, password);
|
|
37
|
+
const listed = (await request({
|
|
38
|
+
base: ctx.base,
|
|
39
|
+
method: 'GET',
|
|
40
|
+
path: '/api/sites',
|
|
41
|
+
token: ctx.session.token(),
|
|
42
|
+
fetchImpl: ctx.fetchImpl,
|
|
43
|
+
}));
|
|
44
|
+
const result = {
|
|
45
|
+
user: ctx.session.userName,
|
|
46
|
+
// The key is absent when the account owns no sites; a nil slice would have
|
|
47
|
+
// marshalled to null, which is why the platform's own list contract exists.
|
|
48
|
+
sites: (listed?.sites ?? []).map((s) => ({ id: s.id, name: s.name })),
|
|
49
|
+
api_key: ctx.apiKey ? 'present' : 'missing',
|
|
50
|
+
operations: API_OPERATIONS.length,
|
|
51
|
+
};
|
|
52
|
+
if (!ctx.apiKey) {
|
|
53
|
+
result.note =
|
|
54
|
+
'SB_TOKEN is not set, so /api/v1 operations (products, orders, customers, media, blog, ' +
|
|
55
|
+
'webhooks) will be refused with api_key_required. The private site API is unaffected.';
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
export function registerSessionTools(server, ctx) {
|
|
60
|
+
server.tool('sb_connect', 'Log in and list the sites this account can operate. Call this first. Reads SB_EMAIL and ' +
|
|
61
|
+
'SB_PASSWORD from the environment unless you pass them.', {
|
|
62
|
+
email: z.string().optional(),
|
|
63
|
+
password: z.string().optional(),
|
|
64
|
+
}, async (args) => text(await connect(ctx, args)));
|
|
65
|
+
server.tool('sb_site_list', 'List the sites this account can operate.', {}, async () => text(await request({
|
|
66
|
+
base: ctx.base,
|
|
67
|
+
method: 'GET',
|
|
68
|
+
path: '/api/sites',
|
|
69
|
+
token: ctx.session.token(),
|
|
70
|
+
fetchImpl: ctx.fetchImpl,
|
|
71
|
+
})));
|
|
72
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { request } from './http.js';
|
|
2
|
+
/**
|
|
3
|
+
* A user session against the platform's private API.
|
|
4
|
+
*
|
|
5
|
+
* `token()` is a GETTER, deliberately, and every consumer must call it per use
|
|
6
|
+
* rather than capture its result. The access token lives ~15 minutes and rotates
|
|
7
|
+
* on refresh; a client holding the string it was built with replays an expired
|
|
8
|
+
* token forever, and the failure is SILENT — a rejected socket auth still fires
|
|
9
|
+
* onopen, so there is no error event and nothing in any UI. The editor shipped
|
|
10
|
+
* exactly that bug and documents the fix on its own socket (MF1). Phase 3's
|
|
11
|
+
* socket takes `() => session.token()`, which is why the getter exists now
|
|
12
|
+
* rather than when it is first needed.
|
|
13
|
+
*/
|
|
14
|
+
export class Session {
|
|
15
|
+
base;
|
|
16
|
+
fetchImpl;
|
|
17
|
+
access = null;
|
|
18
|
+
refreshToken = null;
|
|
19
|
+
name = '';
|
|
20
|
+
constructor(base, fetchImpl) {
|
|
21
|
+
this.base = base;
|
|
22
|
+
this.fetchImpl = fetchImpl;
|
|
23
|
+
}
|
|
24
|
+
get userName() {
|
|
25
|
+
return this.name;
|
|
26
|
+
}
|
|
27
|
+
token() {
|
|
28
|
+
if (!this.access)
|
|
29
|
+
throw new Error('sbuilder: not logged in — call sb_connect first');
|
|
30
|
+
return this.access;
|
|
31
|
+
}
|
|
32
|
+
loggedIn() {
|
|
33
|
+
return this.access !== null;
|
|
34
|
+
}
|
|
35
|
+
async login(email, password) {
|
|
36
|
+
const out = (await request({
|
|
37
|
+
base: this.base,
|
|
38
|
+
method: 'POST',
|
|
39
|
+
path: '/api/auth/login',
|
|
40
|
+
body: { email, password },
|
|
41
|
+
fetchImpl: this.fetchImpl,
|
|
42
|
+
}));
|
|
43
|
+
this.access = out.tokens.accessToken;
|
|
44
|
+
this.refreshToken = out.tokens.refreshToken;
|
|
45
|
+
this.name = out.user?.name ?? '';
|
|
46
|
+
}
|
|
47
|
+
/** Rotate. The platform issues a NEW refresh token each time; keep that one. */
|
|
48
|
+
async refresh() {
|
|
49
|
+
if (!this.refreshToken)
|
|
50
|
+
throw new Error('sbuilder: no refresh token — log in first');
|
|
51
|
+
const out = (await request({
|
|
52
|
+
base: this.base,
|
|
53
|
+
method: 'POST',
|
|
54
|
+
path: '/api/auth/refresh',
|
|
55
|
+
body: { refreshToken: this.refreshToken },
|
|
56
|
+
fetchImpl: this.fetchImpl,
|
|
57
|
+
}));
|
|
58
|
+
this.access = out.tokens.accessToken;
|
|
59
|
+
this.refreshToken = out.tokens.refreshToken;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
constructor(status, code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.status = status;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = 'ApiError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Keys whose value is a credential wherever it appears. */
|
|
12
|
+
const SECRET_KEYS = /^(authorization|token|access_?token|refresh_?token|password|secret|api_?key)$/i;
|
|
13
|
+
/**
|
|
14
|
+
* Replace credential-shaped values with a marker, recursively.
|
|
15
|
+
*
|
|
16
|
+
* Every dry-run preview goes through this, so it is the only thing standing
|
|
17
|
+
* between a `dry_run` result and a bearer token sitting in a transcript. It keys
|
|
18
|
+
* off the FIELD NAME rather than the value's shape on purpose: a token format
|
|
19
|
+
* can change tomorrow, while the field name is what this repo controls.
|
|
20
|
+
*/
|
|
21
|
+
export function redact(value) {
|
|
22
|
+
if (Array.isArray(value))
|
|
23
|
+
return value.map(redact);
|
|
24
|
+
if (value && typeof value === 'object') {
|
|
25
|
+
const out = {};
|
|
26
|
+
for (const [k, v] of Object.entries(value)) {
|
|
27
|
+
out[k] = SECRET_KEYS.test(k) ? '[redacted]' : redact(v);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function buildUrl(base, path, query) {
|
|
34
|
+
const url = base.replace(/\/$/, '') + path;
|
|
35
|
+
if (!query)
|
|
36
|
+
return url;
|
|
37
|
+
const qs = new URLSearchParams();
|
|
38
|
+
for (const [k, v] of Object.entries(query)) {
|
|
39
|
+
if (v !== undefined)
|
|
40
|
+
qs.set(k, String(v));
|
|
41
|
+
}
|
|
42
|
+
const s = qs.toString();
|
|
43
|
+
return s ? `${url}?${s}` : url;
|
|
44
|
+
}
|
|
45
|
+
export async function request(opts) {
|
|
46
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
47
|
+
const headers = { Accept: 'application/json' };
|
|
48
|
+
if (opts.token)
|
|
49
|
+
headers.Authorization = `Bearer ${opts.token}`;
|
|
50
|
+
if (opts.body !== undefined)
|
|
51
|
+
headers['Content-Type'] = 'application/json';
|
|
52
|
+
const res = await doFetch(buildUrl(opts.base, opts.path, opts.query), {
|
|
53
|
+
method: opts.method,
|
|
54
|
+
headers,
|
|
55
|
+
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
56
|
+
});
|
|
57
|
+
const raw = await res.text();
|
|
58
|
+
let parsed = undefined;
|
|
59
|
+
if (raw) {
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// A non-JSON body from this platform means something UPSTREAM of the app
|
|
65
|
+
// answered — a proxy, a 502 page. Say exactly that rather than guessing a
|
|
66
|
+
// code the platform never wrote.
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw new ApiError(res.status, 'non_json_response', raw.slice(0, 400));
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const env = (parsed ?? {});
|
|
74
|
+
throw new ApiError(res.status, env.code ?? 'unknown', env.error ?? `HTTP ${res.status}`);
|
|
75
|
+
}
|
|
76
|
+
return parsed;
|
|
77
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { request } from './http.js';
|
|
2
|
+
import { siteToken } from '../tools/credentialpick.js';
|
|
3
|
+
function sourcePath(siteId, pageId) {
|
|
4
|
+
return `/api/sites/${encodeURIComponent(siteId)}/pages/${encodeURIComponent(pageId)}/source`;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read a page's DRAFT document.
|
|
8
|
+
*
|
|
9
|
+
* The response is enveloped under `source` (httpx.WriteItem), and `warnings`,
|
|
10
|
+
* `globals` and `overlays` are omitted rather than nulled when empty — so a
|
|
11
|
+
* caller must treat absence as "none" and never dereference them.
|
|
12
|
+
*
|
|
13
|
+
* What comes back is COMPOSED: global sections and site overlays have been
|
|
14
|
+
* merged onto ROOT. That is the tree to edit; the server strips the overlays
|
|
15
|
+
* back out on write.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadSource(ctx, siteId, pageId) {
|
|
18
|
+
const out = (await request({
|
|
19
|
+
base: ctx.base,
|
|
20
|
+
method: 'GET',
|
|
21
|
+
path: sourcePath(siteId, pageId),
|
|
22
|
+
token: siteToken(ctx),
|
|
23
|
+
fetchImpl: ctx.fetchImpl,
|
|
24
|
+
}));
|
|
25
|
+
return out.source;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Save a page's draft document.
|
|
29
|
+
*
|
|
30
|
+
* The body is `{ document, schemaVersion }`, copied from the editor's own
|
|
31
|
+
* `saveSource` (editor/src/features/pages/api.ts:114) rather than inferred: the
|
|
32
|
+
* OpenAPI document declares NO body for this route at all, so there is nothing
|
|
33
|
+
* to derive it from and a guess would have sent `{ document }` alone.
|
|
34
|
+
* `schema_version ?? 1` mirrors the editor's fallback exactly.
|
|
35
|
+
*
|
|
36
|
+
* A rejection here is the platform refusing the TREE, not the transport failing
|
|
37
|
+
* — `band_order` is the common one — and `ApiError.code` carries which. That is
|
|
38
|
+
* why nothing is swallowed: the code is the only thing that tells an agent what
|
|
39
|
+
* to fix.
|
|
40
|
+
*/
|
|
41
|
+
export async function saveSource(ctx, siteId, pageId, document) {
|
|
42
|
+
const out = (await request({
|
|
43
|
+
base: ctx.base,
|
|
44
|
+
method: 'PUT',
|
|
45
|
+
path: sourcePath(siteId, pageId),
|
|
46
|
+
token: siteToken(ctx),
|
|
47
|
+
body: { document, schemaVersion: document.schema_version ?? 1 },
|
|
48
|
+
fetchImpl: ctx.fetchImpl,
|
|
49
|
+
}));
|
|
50
|
+
return out.source;
|
|
51
|
+
}
|