sbuilder-mcp 0.1.1 → 0.1.3
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 +8 -0
- package/dist/install/index.js +14 -1
- package/dist/install/write.js +105 -0
- package/dist/server.js +19 -1
- package/dist/tools/api.js +11 -2
- package/dist/tools/credentialpick.js +7 -2
- package/dist/tools/live.js +14 -7
- package/dist/tools/page.js +12 -11
- package/dist/tools/session.js +105 -2
- package/dist/tools/siteref.js +33 -0
- package/dist/transport/http.js +16 -1
- package/dist/transport/identity.js +58 -0
- package/dist/transport/keys.js +73 -0
- package/dist/transport/media.js +9 -1
- package/dist/transport/pages.js +2 -2
- package/dist/vision/measure.js +109 -0
- package/dist/vision/shoot.js +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/dist/install/index.js
CHANGED
|
@@ -8,6 +8,14 @@ export function buildEntry(opts, pkg = 'sbuilder-mcp') {
|
|
|
8
8
|
env.SB_API = opts.api;
|
|
9
9
|
if (opts.token)
|
|
10
10
|
env.SB_TOKEN = opts.token;
|
|
11
|
+
// NAMING THE STORE is what lets a second one join this entry later instead of
|
|
12
|
+
// replacing it. A key opens exactly one site, so the ring files each key under
|
|
13
|
+
// the site it belongs to and the call picks by the site it names.
|
|
14
|
+
if (opts.token && opts.site) {
|
|
15
|
+
env.SB_SITES = JSON.stringify([
|
|
16
|
+
{ id: opts.site, ...(opts.siteName ? { name: opts.siteName } : {}), token: opts.token },
|
|
17
|
+
]);
|
|
18
|
+
}
|
|
11
19
|
// Only when a key is absent: a key opens everything the agent does day to day,
|
|
12
20
|
// and writing an account password into six config files to buy the handful of
|
|
13
21
|
// account-level calls it adds is a bad trade the installer should not make for
|
|
@@ -45,7 +53,7 @@ export function install(opts) {
|
|
|
45
53
|
return { client: t.label, path: t.path, status: 'skipped', reason: 'dry run', note: t.note };
|
|
46
54
|
}
|
|
47
55
|
try {
|
|
48
|
-
const out = mergeInto(t, SERVER_NAME, entry);
|
|
56
|
+
const out = mergeInto(t, opts.name || SERVER_NAME, entry);
|
|
49
57
|
return {
|
|
50
58
|
client: t.label,
|
|
51
59
|
path: t.path,
|
|
@@ -72,6 +80,11 @@ export function runInstallCli(argv) {
|
|
|
72
80
|
email: get('--email') ?? process.env.SB_EMAIL,
|
|
73
81
|
password: get('--password') ?? process.env.SB_PASSWORD,
|
|
74
82
|
clients: get('--client')?.split(','),
|
|
83
|
+
// One key opens ONE store, so a machine driving two stores needs two
|
|
84
|
+
// entries. Without this the second install silently replaced the first.
|
|
85
|
+
name: get('--name'),
|
|
86
|
+
site: get('--site') ?? process.env.SB_SITE,
|
|
87
|
+
siteName: get('--site-name'),
|
|
75
88
|
dryRun: argv.includes('--dry-run'),
|
|
76
89
|
};
|
|
77
90
|
if (!opts.token && !(opts.email && opts.password)) {
|
package/dist/install/write.js
CHANGED
|
@@ -31,6 +31,35 @@ export function mergeJson(target, name, entry) {
|
|
|
31
31
|
}
|
|
32
32
|
const servers = (doc[target.key] ?? {});
|
|
33
33
|
const before = JSON.stringify(servers[name] ?? null);
|
|
34
|
+
// ONE KEY OPENS ONE STORE, so a second store cannot quietly take this entry.
|
|
35
|
+
//
|
|
36
|
+
// The server name was a constant, and connecting a second store overwrote the
|
|
37
|
+
// first: same name, different token. Nothing said so. The agent then reached
|
|
38
|
+
// store B only, store A's connection died, and the machine went on reporting
|
|
39
|
+
// itself as installed — the platform even kept the old row, last seen an hour
|
|
40
|
+
// ago, with no way to tell "stopped being used" from "quietly replaced".
|
|
41
|
+
//
|
|
42
|
+
// So an existing entry pointing somewhere else is a REFUSAL that names the
|
|
43
|
+
// fix, not a silent replacement.
|
|
44
|
+
const clash = pointsElsewhere(servers[name], entry);
|
|
45
|
+
if (clash) {
|
|
46
|
+
// MERGE if we can, refuse if we cannot.
|
|
47
|
+
//
|
|
48
|
+
// Two stores on one machine do not need two installs: a key opens one site,
|
|
49
|
+
// but the server holds a ring of them and picks by the site each call names.
|
|
50
|
+
// So when the incoming install says WHICH site it is for, its key joins the
|
|
51
|
+
// ring beside the one already there and both stores work from this single
|
|
52
|
+
// entry. Only an install that cannot name its site is refused — there is no
|
|
53
|
+
// way to file its key under anything.
|
|
54
|
+
const merged = combine(servers[name], entry);
|
|
55
|
+
if (!merged) {
|
|
56
|
+
return {
|
|
57
|
+
wrote: false,
|
|
58
|
+
reason: `"${name}" in ${target.path} already points at another store (${clash}), and this install did not say which site it is for. Re-run with --site, or with --name to keep a separate entry.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
entry = merged;
|
|
62
|
+
}
|
|
34
63
|
servers[name] = entry;
|
|
35
64
|
doc[target.key] = servers;
|
|
36
65
|
// Idempotent: an identical entry is not a write, so re-running the installer
|
|
@@ -95,3 +124,79 @@ export function mergeInto(target, name, entry) {
|
|
|
95
124
|
? mergeToml(target, name, entry)
|
|
96
125
|
: mergeJson(target, name, entry);
|
|
97
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether an existing entry belongs to a DIFFERENT store than the one being
|
|
129
|
+
* installed.
|
|
130
|
+
*
|
|
131
|
+
* Compared on the credential and the host, because those are what bind a config
|
|
132
|
+
* to a store — the command and args are identical for every install. Returns the
|
|
133
|
+
* old prefix so the refusal can say WHICH store it would have replaced; the full
|
|
134
|
+
* token is never echoed, since these messages get pasted into issues.
|
|
135
|
+
*/
|
|
136
|
+
export function pointsElsewhere(existing, entry) {
|
|
137
|
+
if (!existing || typeof existing !== 'object')
|
|
138
|
+
return null;
|
|
139
|
+
const env = (existing.env ?? {});
|
|
140
|
+
const oldToken = env.SB_TOKEN ?? '';
|
|
141
|
+
const oldApi = env.SB_API ?? '';
|
|
142
|
+
const newToken = entry.env.SB_TOKEN ?? '';
|
|
143
|
+
const newApi = entry.env.SB_API ?? '';
|
|
144
|
+
if (!oldToken && !oldApi)
|
|
145
|
+
return null;
|
|
146
|
+
if (oldToken === newToken && oldApi === newApi)
|
|
147
|
+
return null;
|
|
148
|
+
// A key is bound to one site, so a different key is a different store even
|
|
149
|
+
// when the host matches.
|
|
150
|
+
// Eight characters: enough to recognise the key on the API-keys screen,
|
|
151
|
+
// short enough that a message pasted into an issue is not a credential.
|
|
152
|
+
const hint = oldToken ? oldToken.slice(0, 8) + '…' : oldApi;
|
|
153
|
+
return hint;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Fold a second store into an entry that already serves a first.
|
|
157
|
+
*
|
|
158
|
+
* The ring lives in `SB_SITES` as JSON rows, with `SB_TOKEN` staying as the
|
|
159
|
+
* default for any site that has no pair of its own — which is what keeps the
|
|
160
|
+
* store that was installed first working without ever having named itself.
|
|
161
|
+
*
|
|
162
|
+
* Returns null when the incoming install carries no site, because then its key
|
|
163
|
+
* cannot be filed under anything and the only honest options are a refusal or a
|
|
164
|
+
* silent overwrite.
|
|
165
|
+
*/
|
|
166
|
+
export function combine(existing, incoming) {
|
|
167
|
+
const bySite = new Map();
|
|
168
|
+
for (const src of [existing.env?.SB_SITES, incoming.env?.SB_SITES]) {
|
|
169
|
+
try {
|
|
170
|
+
const rows = JSON.parse(src || '[]');
|
|
171
|
+
if (!Array.isArray(rows))
|
|
172
|
+
continue;
|
|
173
|
+
for (const r of rows) {
|
|
174
|
+
if (r?.id && r?.token) {
|
|
175
|
+
// The LATER row wins on name: a store that was renamed should show its
|
|
176
|
+
// new name after the next install, not the one it had months ago.
|
|
177
|
+
bySite.set(r.id, { id: r.id, token: r.token, ...(r.name ? { name: r.name } : {}) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// A hand-mangled value is skipped, not thrown on: the other store's entry
|
|
183
|
+
// is still perfectly good and must survive.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (bySite.size === 0)
|
|
187
|
+
return null;
|
|
188
|
+
return {
|
|
189
|
+
command: incoming.command,
|
|
190
|
+
args: incoming.args,
|
|
191
|
+
env: {
|
|
192
|
+
...existing.env,
|
|
193
|
+
...incoming.env,
|
|
194
|
+
// The FIRST store keeps the default slot. Handing it to the newcomer would
|
|
195
|
+
// silently redirect every call that does not name a site — the same
|
|
196
|
+
// takeover this exists to prevent, one level down.
|
|
197
|
+
...(existing.env?.SB_TOKEN ? { SB_TOKEN: existing.env.SB_TOKEN } : {}),
|
|
198
|
+
...(existing.env?.SB_API ? { SB_API: existing.env.SB_API } : {}),
|
|
199
|
+
SB_SITES: JSON.stringify([...bySite.values()]),
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readKeyRing } from './transport/keys.js';
|
|
2
|
+
import { setAgentClient } from './transport/identity.js';
|
|
1
3
|
import { readFileSync } from 'node:fs';
|
|
2
4
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
5
|
import { Session } from './transport/auth.js';
|
|
@@ -47,10 +49,26 @@ export function pkgVersion() {
|
|
|
47
49
|
}
|
|
48
50
|
export function buildContext() {
|
|
49
51
|
const base = process.env.SB_API ?? 'http://localhost:8080';
|
|
50
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
base,
|
|
54
|
+
session: new Session(base),
|
|
55
|
+
apiKey: process.env.SB_TOKEN,
|
|
56
|
+
// Extra stores, if this machine drives more than one. A key opens exactly
|
|
57
|
+
// one site, so several stores mean several keys — not several installs.
|
|
58
|
+
keys: readKeyRing(),
|
|
59
|
+
};
|
|
51
60
|
}
|
|
52
61
|
export function createServer(ctx = buildContext()) {
|
|
53
62
|
const server = new McpServer({ name: 'sbuilder', version: pkgVersion(), title: 'Store Builder' }, { instructions: INSTRUCTIONS });
|
|
63
|
+
// LEARN WHO LAUNCHED US, at the handshake, before any tool runs.
|
|
64
|
+
//
|
|
65
|
+
// Set here rather than after connect() because the handshake happens DURING
|
|
66
|
+
// connect: a hook attached afterwards is attached to an event that has already
|
|
67
|
+
// fired, and every call would then report an anonymous machine — the exact
|
|
68
|
+
// blindness this exists to remove.
|
|
69
|
+
server.server.oninitialized = () => {
|
|
70
|
+
setAgentClient(server.server.getClientVersion(), pkgVersion());
|
|
71
|
+
};
|
|
54
72
|
registerSessionTools(server, ctx);
|
|
55
73
|
registerApiTools(server, ctx);
|
|
56
74
|
const pageSession = registerPageTools(server, ctx);
|
package/dist/tools/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { keyForSite, siteInPath } from '../transport/keys.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { API_OPERATIONS } from '../catalog/api.generated.js';
|
|
3
4
|
import { searchOperations, describeOperation } from '../catalog/search.js';
|
|
@@ -12,7 +13,7 @@ import { text } from '../mcp/response.js';
|
|
|
12
13
|
* session carries the whole account. Preferring it is also what lets a merchant
|
|
13
14
|
* connect an agent with one env var and no password.
|
|
14
15
|
*/
|
|
15
|
-
export function tokenFor(ctx, credential) {
|
|
16
|
+
export function tokenFor(ctx, credential, siteId) {
|
|
16
17
|
if (credential === 'apiKey') {
|
|
17
18
|
// Naming the env var matters: the alternative is a 401 api_key_required
|
|
18
19
|
// from the platform, which reads like a permissions problem rather than an
|
|
@@ -23,6 +24,14 @@ export function tokenFor(ctx, credential) {
|
|
|
23
24
|
return ctx.apiKey;
|
|
24
25
|
}
|
|
25
26
|
if (credential === 'siteScoped') {
|
|
27
|
+
// A store with a key of its own always wins. Sending store A's key to store
|
|
28
|
+
// B is refused by the platform as `api_key_site` — correctly, and in a way
|
|
29
|
+
// that reads like a permissions bug rather than the wrong credential.
|
|
30
|
+
if (ctx.keys) {
|
|
31
|
+
const own = keyForSite(ctx.keys, siteId);
|
|
32
|
+
if (own)
|
|
33
|
+
return own;
|
|
34
|
+
}
|
|
26
35
|
if (ctx.apiKey)
|
|
27
36
|
return ctx.apiKey;
|
|
28
37
|
if (ctx.session.loggedIn())
|
|
@@ -47,7 +56,7 @@ export async function callOperation(ctx, args) {
|
|
|
47
56
|
}
|
|
48
57
|
path = path.replace(`{${name}}`, encodeURIComponent(value));
|
|
49
58
|
}
|
|
50
|
-
const token = tokenFor(ctx, op.credential);
|
|
59
|
+
const token = tokenFor(ctx, op.credential, siteInPath(path));
|
|
51
60
|
const dryRun = args.dry_run !== false;
|
|
52
61
|
if (dryRun) {
|
|
53
62
|
return {
|
|
@@ -5,7 +5,12 @@ import { tokenFor } from './api.js';
|
|
|
5
5
|
* A one-line re-export so `transport/` does not reach into `tools/api.ts` for
|
|
6
6
|
* the rule and quietly grow a second copy of it. There is exactly one answer to
|
|
7
7
|
* "which token opens /api/sites", and it lives in `tokenFor`.
|
|
8
|
+
*
|
|
9
|
+
* PASS THE SITE. An install may hold keys for several stores, and the key for
|
|
10
|
+
* the store being addressed is the only one the platform will accept — omitting
|
|
11
|
+
* it silently falls back to the default key, which then fails as `api_key_site`
|
|
12
|
+
* on somebody's second store.
|
|
8
13
|
*/
|
|
9
|
-
export function siteToken(ctx) {
|
|
10
|
-
return tokenFor(ctx, 'siteScoped');
|
|
14
|
+
export function siteToken(ctx, siteId) {
|
|
15
|
+
return tokenFor(ctx, 'siteScoped', siteId);
|
|
11
16
|
}
|
package/dist/tools/live.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { siteRef } from './siteref.js';
|
|
1
2
|
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
import { text, images } from '../mcp/response.js';
|
|
@@ -6,6 +7,7 @@ import { previewUrl } from '../vision/preview.js';
|
|
|
6
7
|
import { uploadMedia } from '../transport/media.js';
|
|
7
8
|
import { request } from '../transport/http.js';
|
|
8
9
|
import { shoot, DEFAULT_WIDTHS } from '../vision/shoot.js';
|
|
10
|
+
import { measure, MEASURE_NOTICE } from '../vision/measure.js';
|
|
9
11
|
import { RealtimeSocket } from '../transport/socket.js';
|
|
10
12
|
import { LiveSession } from '../live/session.js';
|
|
11
13
|
import { siteToken } from './credentialpick.js';
|
|
@@ -46,9 +48,9 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
46
48
|
server.tool('sb_live_join', "Join the editor's live-edit room for this site, as a visible peer. Once joined, every " +
|
|
47
49
|
'sb_add / sb_set / sb_move / sb_remove / sb_bind also goes out as a live op, so anyone ' +
|
|
48
50
|
'with the editor open watches the page assemble. Safe alongside a human: this client ' +
|
|
49
|
-
'always yields — it never answers a snapshot request and re-pulls on any divergence.', { site_id:
|
|
51
|
+
'always yields — it never answers a snapshot request and re-pulls on any divergence.', { site_id: siteRef(ctx) }, async ({ site_id }) => {
|
|
50
52
|
const wsBase = ctx.base.replace(/^http/, 'ws').replace(/\/$/, '');
|
|
51
|
-
const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, () => siteToken(ctx));
|
|
53
|
+
const socket = new RealtimeSocket(`${wsBase}/api/realtime/ws?site=${encodeURIComponent(site_id)}`, () => siteToken(ctx, site_id));
|
|
52
54
|
const live = new LiveSession(socket, {
|
|
53
55
|
onRemote: (patches) => session.applyRemote(patches),
|
|
54
56
|
onDesync: (reason) => session.markStale(reason),
|
|
@@ -62,8 +64,9 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
62
64
|
});
|
|
63
65
|
server.tool('sb_look', "Save the open page, render it through the platform's own renderer, and return " +
|
|
64
66
|
'screenshots at desktop, tablet and mobile widths — plus the measured bounding box of ' +
|
|
65
|
-
'every node
|
|
66
|
-
'
|
|
67
|
+
'every node — plus any LAYOUT defect measured on the render: content past the ' +
|
|
68
|
+
'viewport, elements overlapping, text too small to read. Pass node_id to frame ONE ' +
|
|
69
|
+
'element instead of the whole page. Judge your own work from these rather than guessing.', {
|
|
67
70
|
widths: z.array(z.number().int().min(320).max(2560)).optional(),
|
|
68
71
|
with_boxes: z.boolean().optional(),
|
|
69
72
|
node_id: z
|
|
@@ -81,16 +84,20 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
81
84
|
// by rule are the same act, and separating them is how the second one gets
|
|
82
85
|
// skipped.
|
|
83
86
|
const findings = reviewDesign(session.current());
|
|
87
|
+
// Measured on the render, not read off the document — a card that spills
|
|
88
|
+
// at 390px is invisible to every check that only reads the tree.
|
|
89
|
+
const visual = node_id ? [] : measure(shots);
|
|
84
90
|
return images(shots.map((s) => ({ dataBase64: s.pngBase64 })), {
|
|
85
91
|
widths: shots.map((s) => s.width),
|
|
86
92
|
...(node_id ? { framed: node_id } : {}),
|
|
87
93
|
...(with_boxes === false ? {} : { boxes: shots[0]?.boxes ?? [] }),
|
|
88
94
|
...(findings.length > 0 ? { findings, findings_notice: REVIEW_NOTICE } : {}),
|
|
95
|
+
...(visual.length > 0 ? { layout: visual, layout_notice: MEASURE_NOTICE } : {}),
|
|
89
96
|
});
|
|
90
97
|
});
|
|
91
98
|
server.tool('sb_media_list', "The site's media library — reuse an image that is already there before adding another. " +
|
|
92
99
|
'Search by name, filter by type, page with limit/offset.', {
|
|
93
|
-
site_id:
|
|
100
|
+
site_id: siteRef(ctx),
|
|
94
101
|
search: z.string().optional(),
|
|
95
102
|
media_type: z.string().optional().describe('e.g. "image"'),
|
|
96
103
|
limit: z.number().int().min(1).max(200).optional(),
|
|
@@ -99,14 +106,14 @@ export function registerLiveTools(server, ctx, session) {
|
|
|
99
106
|
base: ctx.base,
|
|
100
107
|
method: 'GET',
|
|
101
108
|
path: `/api/sites/${encodeURIComponent(site_id)}/media`,
|
|
102
|
-
token: siteToken(ctx),
|
|
109
|
+
token: siteToken(ctx, site_id),
|
|
103
110
|
query: { search, mediaType: media_type, limit, offset },
|
|
104
111
|
fetchImpl: ctx.fetchImpl,
|
|
105
112
|
})));
|
|
106
113
|
server.tool('sb_media_upload', 'Put an image into the media library and get its URL back, ready for sb_set. Takes a ' +
|
|
107
114
|
'local file path or a URL to fetch. This is the ONLY way to add an image: the upload ' +
|
|
108
115
|
'is multipart, which sb_api_call cannot send.', {
|
|
109
|
-
site_id:
|
|
116
|
+
site_id: siteRef(ctx),
|
|
110
117
|
path: z.string().optional().describe('A file on this machine'),
|
|
111
118
|
url: z.string().optional().describe('Fetched, then uploaded'),
|
|
112
119
|
name: z.string().optional(),
|
package/dist/tools/page.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { siteRef } from './siteref.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { text } from '../mcp/response.js';
|
|
3
4
|
import { loadSource, saveSource } from '../transport/pages.js';
|
|
@@ -151,7 +152,7 @@ const specSchema = z.lazy(() => z.object({
|
|
|
151
152
|
export function registerPageTools(server, ctx) {
|
|
152
153
|
const session = new PageSession(ctx);
|
|
153
154
|
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:
|
|
155
|
+
'sb_move / sb_remove. Find page ids with sb_api_find "list pages".', { site_id: siteRef(ctx), page_id: z.string() }, async ({ site_id, page_id }) => {
|
|
155
156
|
const outline = await session.open(site_id, page_id);
|
|
156
157
|
return text({ outline, ...reviewField(session.current()) });
|
|
157
158
|
});
|
|
@@ -307,16 +308,16 @@ export function registerPageTools(server, ctx) {
|
|
|
307
308
|
return text({ duplicated: id, into: ids[0], nodes: ids.length, rev: d.rev });
|
|
308
309
|
});
|
|
309
310
|
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:
|
|
311
|
+
'than assembling one. Use sb_template_use to drop one into the open page.', { site_id: siteRef(ctx) }, async ({ site_id }) => text(await request({
|
|
311
312
|
base: ctx.base,
|
|
312
313
|
method: 'GET',
|
|
313
314
|
path: `/api/sites/${encodeURIComponent(site_id)}/section-templates`,
|
|
314
|
-
token: siteToken(ctx),
|
|
315
|
+
token: siteToken(ctx, site_id),
|
|
315
316
|
fetchImpl: ctx.fetchImpl,
|
|
316
317
|
})));
|
|
317
318
|
server.tool('sb_template_use', 'Instantiate a saved section template into a page. The server does the copy, so the ' +
|
|
318
319
|
'section arrives exactly as it was designed — then re-open the page to see it.', {
|
|
319
|
-
site_id:
|
|
320
|
+
site_id: siteRef(ctx),
|
|
320
321
|
template_id: z.string(),
|
|
321
322
|
page_id: z.string(),
|
|
322
323
|
dry_run: z.boolean().optional(),
|
|
@@ -329,7 +330,7 @@ export function registerPageTools(server, ctx) {
|
|
|
329
330
|
base: ctx.base,
|
|
330
331
|
method: 'POST',
|
|
331
332
|
path,
|
|
332
|
-
token: siteToken(ctx),
|
|
333
|
+
token: siteToken(ctx, site_id),
|
|
333
334
|
body: { pageId: page_id },
|
|
334
335
|
fetchImpl: ctx.fetchImpl,
|
|
335
336
|
});
|
|
@@ -340,15 +341,15 @@ export function registerPageTools(server, ctx) {
|
|
|
340
341
|
note: 'Re-open the page with sb_page_open — this session still holds the old tree.',
|
|
341
342
|
});
|
|
342
343
|
});
|
|
343
|
-
server.tool('sb_page_list', "Every page on the site, with its slug and whether it is live.", { site_id:
|
|
344
|
+
server.tool('sb_page_list', "Every page on the site, with its slug and whether it is live.", { site_id: siteRef(ctx) }, async ({ site_id }) => text(await request({
|
|
344
345
|
base: ctx.base,
|
|
345
346
|
method: 'GET',
|
|
346
347
|
path: `/api/sites/${encodeURIComponent(site_id)}/pages`,
|
|
347
|
-
token: siteToken(ctx),
|
|
348
|
+
token: siteToken(ctx, site_id),
|
|
348
349
|
fetchImpl: ctx.fetchImpl,
|
|
349
350
|
})));
|
|
350
351
|
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:
|
|
352
|
+
site_id: siteRef(ctx),
|
|
352
353
|
name: z.string(),
|
|
353
354
|
settings: z.record(z.unknown()).optional(),
|
|
354
355
|
dry_run: z.boolean().optional(),
|
|
@@ -360,14 +361,14 @@ export function registerPageTools(server, ctx) {
|
|
|
360
361
|
base: ctx.base,
|
|
361
362
|
method: 'POST',
|
|
362
363
|
path,
|
|
363
|
-
token: siteToken(ctx),
|
|
364
|
+
token: siteToken(ctx, site_id),
|
|
364
365
|
body: { name, ...(settings ? { settings } : {}) },
|
|
365
366
|
fetchImpl: ctx.fetchImpl,
|
|
366
367
|
}));
|
|
367
368
|
});
|
|
368
369
|
server.tool('sb_publish', 'Compile the draft into the live page. PUBLISH CASCADES: a page sharing a global ' +
|
|
369
370
|
'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:
|
|
371
|
+
'live on one page and stay stale on the rest.', { site_id: siteRef(ctx), page_id: z.string(), dry_run: z.boolean().optional() }, async ({ site_id, page_id, dry_run }) => {
|
|
371
372
|
const path = `/api/sites/${encodeURIComponent(site_id)}/pages/${encodeURIComponent(page_id)}/publish`;
|
|
372
373
|
if (dry_run !== false)
|
|
373
374
|
return text({ dry_run: true, would_post: path });
|
|
@@ -375,7 +376,7 @@ export function registerPageTools(server, ctx) {
|
|
|
375
376
|
base: ctx.base,
|
|
376
377
|
method: 'POST',
|
|
377
378
|
path,
|
|
378
|
-
token: siteToken(ctx),
|
|
379
|
+
token: siteToken(ctx, site_id),
|
|
379
380
|
fetchImpl: ctx.fetchImpl,
|
|
380
381
|
}));
|
|
381
382
|
});
|
package/dist/tools/session.js
CHANGED
|
@@ -62,11 +62,114 @@ export function registerSessionTools(server, ctx) {
|
|
|
62
62
|
email: z.string().optional(),
|
|
63
63
|
password: z.string().optional(),
|
|
64
64
|
}, async (args) => text(await connect(ctx, args)));
|
|
65
|
-
server.tool('sb_site_list', 'List the
|
|
65
|
+
server.tool('sb_site_list', 'List the stores this install can reach. Switching store is just passing a ' +
|
|
66
|
+
'different site_id on the next call — no reconnect, no reinstall.', {}, async () => text(await sitesFor(ctx)));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Which stores this install can reach.
|
|
70
|
+
*
|
|
71
|
+
* Extracted from the tool so it can be tested without standing up an MCP server
|
|
72
|
+
* — and because it answers a question the platform deliberately will not: `GET
|
|
73
|
+
* /api/sites` refuses an API key (`api_key_scope`, "a key addresses one site's
|
|
74
|
+
* resources; it cannot list sites"). That refusal is right, and it left the
|
|
75
|
+
* normal install unable to name the one parameter that switches store.
|
|
76
|
+
*/
|
|
77
|
+
export async function sitesFor(ctx) {
|
|
78
|
+
/*
|
|
79
|
+
* TWO SOURCES, and the key one is the source that was missing.
|
|
80
|
+
*
|
|
81
|
+
* `GET /api/sites` needs an account session. An API key is refused there on
|
|
82
|
+
* purpose — `api_key_scope`, "a key addresses one site's resources; it cannot
|
|
83
|
+
* list sites" — which is correct and left the normal install with NO way to
|
|
84
|
+
* answer "which stores can I reach". Switching store is one parameter, and the
|
|
85
|
+
* agent could not discover the parameter.
|
|
86
|
+
*
|
|
87
|
+
* Each ring entry is then PROBED, because the config outlives the store. A
|
|
88
|
+
* deleted site leaves its key in the database (nothing cascades to a Citus
|
|
89
|
+
* reference table) and leaves its row in SB_SITES forever, so an unchecked
|
|
90
|
+
* list advertises a store that answers 404 to everything — and it does so on
|
|
91
|
+
* the one screen whose entire job is to say what is available.
|
|
92
|
+
*/
|
|
93
|
+
const fromKeys = await Promise.all([...(ctx.keys?.bySite.values() ?? [])].map(async (s) => ({
|
|
94
|
+
id: s.id,
|
|
95
|
+
// The NAME is the point: a person says "switch to Áo Thun", never
|
|
96
|
+
// "switch to site_14675b5a570b248d".
|
|
97
|
+
name: s.name,
|
|
98
|
+
// Enough to match the store on its API-keys screen, never the whole key.
|
|
99
|
+
key: s.token.slice(0, 8) + '…',
|
|
100
|
+
source: 'key',
|
|
101
|
+
...(await probe(ctx, s.id, s.token)),
|
|
102
|
+
})));
|
|
103
|
+
if (!ctx.session.loggedIn()) {
|
|
104
|
+
if (fromKeys.length > 0)
|
|
105
|
+
return { sites: fromKeys };
|
|
106
|
+
// An older install: one key, no site recorded beside it. Say what to do
|
|
107
|
+
// rather than returning an empty list, which reads as "you have no stores".
|
|
108
|
+
return {
|
|
109
|
+
sites: [],
|
|
110
|
+
note: ctx.apiKey
|
|
111
|
+
? 'This install holds one key but did not record which store it opens. Re-run the ' +
|
|
112
|
+
"install command from the store's Agent app — it now passes --site — or call " +
|
|
113
|
+
"sb_connect to list an account's stores."
|
|
114
|
+
: 'No credential. Set SB_TOKEN, or call sb_connect with SB_EMAIL and SB_PASSWORD.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const account = await request({
|
|
66
118
|
base: ctx.base,
|
|
67
119
|
method: 'GET',
|
|
68
120
|
path: '/api/sites',
|
|
69
121
|
token: ctx.session.token(),
|
|
70
122
|
fetchImpl: ctx.fetchImpl,
|
|
71
|
-
})
|
|
123
|
+
});
|
|
124
|
+
return { ...account, keyed_sites: fromKeys };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Is this store still there, and does its key still open it?
|
|
128
|
+
*
|
|
129
|
+
* One cheap read per store, on the tool whose whole job is to report what is
|
|
130
|
+
* available — the only place where being right is worth a round trip. Every
|
|
131
|
+
* other tool takes the store it was given and lets the platform answer.
|
|
132
|
+
*
|
|
133
|
+
* The four outcomes are deliberately distinct, because the remedy differs and a
|
|
134
|
+
* merged one sends people to the wrong screen: a deleted store needs its row
|
|
135
|
+
* removed from the config, a revoked key needs a new key, a scope problem needs
|
|
136
|
+
* the key re-minted with more, and an unreachable host needs nothing at all
|
|
137
|
+
* except trying again later.
|
|
138
|
+
*/
|
|
139
|
+
async function probe(ctx, siteId, token) {
|
|
140
|
+
try {
|
|
141
|
+
await request({
|
|
142
|
+
base: ctx.base,
|
|
143
|
+
method: 'GET',
|
|
144
|
+
path: `/api/sites/${encodeURIComponent(siteId)}/pages`,
|
|
145
|
+
query: { limit: 1 },
|
|
146
|
+
token,
|
|
147
|
+
fetchImpl: ctx.fetchImpl,
|
|
148
|
+
});
|
|
149
|
+
return { status: 'ok' };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
const status = err.status;
|
|
153
|
+
if (status === 404) {
|
|
154
|
+
return {
|
|
155
|
+
status: 'gone',
|
|
156
|
+
fix: 'This store no longer exists. Remove its row from SB_SITES, or re-run the install command for a store that does.',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (status === 401) {
|
|
160
|
+
return {
|
|
161
|
+
status: 'key_rejected',
|
|
162
|
+
fix: "The key was revoked or is unknown. Mint a new one from that store's Agent app and re-run the install command.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (status === 403) {
|
|
166
|
+
return {
|
|
167
|
+
status: 'key_too_narrow',
|
|
168
|
+
fix: 'The key authenticates but may not read this store. Re-mint it, or check the role of the member who created it.',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// A network failure is not a verdict about the store, and reporting it as
|
|
172
|
+
// one would send somebody to delete a config row over a flaky connection.
|
|
173
|
+
return { status: 'unreachable' };
|
|
174
|
+
}
|
|
72
175
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { resolveSite } from '../transport/keys.js';
|
|
3
|
+
/**
|
|
4
|
+
* A `site_id` argument that also accepts the store's NAME.
|
|
5
|
+
*
|
|
6
|
+
* Because a person switching store says "Áo Thun", never
|
|
7
|
+
* "site_14675b5a570b248d". The ring already records both for every store this
|
|
8
|
+
* machine holds a key for, and without this the name reached the URL builder
|
|
9
|
+
* verbatim and produced `/api/sites/Áo%20Thun/pages` — a 404 that names nothing.
|
|
10
|
+
*
|
|
11
|
+
* Resolved in the SCHEMA rather than in each handler, deliberately. Nine tools
|
|
12
|
+
* take a site id, and a rule applied by hand nine times is a rule that will be
|
|
13
|
+
* applied eight times the moment a tenth is added — with the tenth silently
|
|
14
|
+
* being the only one that cannot switch by name.
|
|
15
|
+
*
|
|
16
|
+
* Pass-through is the fallback: anything that does not match a known store is
|
|
17
|
+
* handed on untouched, so ids keep working, and so does a store this install has
|
|
18
|
+
* no key for (which then fails at the platform, as it should).
|
|
19
|
+
*/
|
|
20
|
+
export function siteRef(ctx) {
|
|
21
|
+
return z
|
|
22
|
+
.string()
|
|
23
|
+
.describe('Store id, or the store name as shown by sb_site_list — "Áo Thun" works as well as ' +
|
|
24
|
+
'site_14675b5a570b248d.')
|
|
25
|
+
.transform((given) => {
|
|
26
|
+
if (!ctx.keys)
|
|
27
|
+
return given;
|
|
28
|
+
// An AMBIGUOUS name resolves to nothing and falls through unchanged, so
|
|
29
|
+
// the platform refuses it rather than this picking one of two stores with
|
|
30
|
+
// the same name and writing edits into the wrong one.
|
|
31
|
+
return resolveSite(ctx.keys, given)?.id ?? given;
|
|
32
|
+
});
|
|
33
|
+
}
|
package/dist/transport/http.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the ring from the environment.
|
|
3
|
+
*
|
|
4
|
+
* A malformed value yields an EMPTY ring rather than throwing. This runs at
|
|
5
|
+
* startup, before any transport exists to report through, so the failure would
|
|
6
|
+
* be a server that refuses to start with its reason on a stdout nobody reads —
|
|
7
|
+
* and `SB_TOKEN` alone still works, which is the single-store case.
|
|
8
|
+
*/
|
|
9
|
+
export function readKeyRing(env = process.env) {
|
|
10
|
+
const bySite = new Map();
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(env.SB_SITES || '[]');
|
|
13
|
+
if (Array.isArray(parsed)) {
|
|
14
|
+
for (const row of parsed) {
|
|
15
|
+
const r = row;
|
|
16
|
+
if (typeof r?.id === 'string' && r.id && typeof r.token === 'string' && r.token) {
|
|
17
|
+
bySite.set(r.id, {
|
|
18
|
+
id: r.id,
|
|
19
|
+
token: r.token,
|
|
20
|
+
...(typeof r.name === 'string' && r.name ? { name: r.name } : {}),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Not valid JSON. Fall through with an empty ring.
|
|
28
|
+
}
|
|
29
|
+
return { bySite, fallback: env.SB_TOKEN || undefined };
|
|
30
|
+
}
|
|
31
|
+
export function siteInPath(path) {
|
|
32
|
+
const m = /^\/api\/sites\/([^/]+)/.exec(path);
|
|
33
|
+
return m ? decodeURIComponent(m[1]) : undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The key for one request.
|
|
37
|
+
*
|
|
38
|
+
* A named site with a key of its own always wins, so a machine holding three
|
|
39
|
+
* stores never sends store A's credential to store B — which the platform would
|
|
40
|
+
* refuse with `api_key_site`, correctly and confusingly.
|
|
41
|
+
*/
|
|
42
|
+
export function keyForSite(ring, siteId) {
|
|
43
|
+
if (siteId) {
|
|
44
|
+
const own = ring.bySite.get(siteId);
|
|
45
|
+
if (own)
|
|
46
|
+
return own.token;
|
|
47
|
+
}
|
|
48
|
+
return ring.fallback;
|
|
49
|
+
}
|
|
50
|
+
/** The same answer, for a caller that holds a path rather than an id. */
|
|
51
|
+
export function keyForPath(ring, path) {
|
|
52
|
+
return keyForSite(ring, siteInPath(path));
|
|
53
|
+
}
|
|
54
|
+
/** Every store this install holds a key for, freshest information first. */
|
|
55
|
+
export function knownSites(ring) {
|
|
56
|
+
return [...ring.bySite.values()];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Find a store by whatever the person said — its id or its name.
|
|
60
|
+
*
|
|
61
|
+
* Names are matched case- and space-insensitively because they are typed by
|
|
62
|
+
* hand into a chat, not copied. An ambiguous name returns nothing rather than
|
|
63
|
+
* guessing: two stores called "Shop" and a silent pick means edits land in the
|
|
64
|
+
* wrong one, which is the failure this whole area keeps producing.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveSite(ring, needle) {
|
|
67
|
+
const want = needle.trim().toLowerCase();
|
|
68
|
+
const byId = ring.bySite.get(needle.trim());
|
|
69
|
+
if (byId)
|
|
70
|
+
return byId;
|
|
71
|
+
const named = [...ring.bySite.values()].filter((s) => (s.name ?? '').trim().toLowerCase() === want);
|
|
72
|
+
return named.length === 1 ? named[0] : undefined;
|
|
73
|
+
}
|
package/dist/transport/media.js
CHANGED
|
@@ -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
|
-
|
|
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, siteId)}`,
|
|
55
|
+
Accept: 'application/json',
|
|
56
|
+
...identityHeaders(),
|
|
57
|
+
},
|
|
50
58
|
body: form,
|
|
51
59
|
});
|
|
52
60
|
const raw = await res.text();
|
package/dist/transport/pages.js
CHANGED
|
@@ -19,7 +19,7 @@ export async function loadSource(ctx, siteId, pageId) {
|
|
|
19
19
|
base: ctx.base,
|
|
20
20
|
method: 'GET',
|
|
21
21
|
path: sourcePath(siteId, pageId),
|
|
22
|
-
token: siteToken(ctx),
|
|
22
|
+
token: siteToken(ctx, siteId),
|
|
23
23
|
fetchImpl: ctx.fetchImpl,
|
|
24
24
|
}));
|
|
25
25
|
return out.source;
|
|
@@ -43,7 +43,7 @@ export async function saveSource(ctx, siteId, pageId, document) {
|
|
|
43
43
|
base: ctx.base,
|
|
44
44
|
method: 'PUT',
|
|
45
45
|
path: sourcePath(siteId, pageId),
|
|
46
|
-
token: siteToken(ctx),
|
|
46
|
+
token: siteToken(ctx, siteId),
|
|
47
47
|
body: { document, schemaVersion: document.schema_version ?? 1 },
|
|
48
48
|
fetchImpl: ctx.fetchImpl,
|
|
49
49
|
}));
|
|
@@ -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.';
|
package/dist/vision/shoot.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|