webcake-storefront-mcp 1.0.1

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.
@@ -0,0 +1,875 @@
1
+ import { z } from "zod";
2
+ import { getImageAlt, setImageAlts as dbSetImageAlts, listImageAlts, countImageAlts } from "../db.js";
3
+ import { isMongoEnabled, mongoUpsertAlts, mongoFindAlts, mongoListAlts } from "../mongo.js";
4
+ const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?[^"')\s]*)?$/i;
5
+ const URL_IN_CSS_RE = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g;
6
+ const HTTP_URL_RE = /https?:\/\/[^\s"'<>)]+/g;
7
+ function isImageUrl(s) {
8
+ if (typeof s !== "string")
9
+ return false;
10
+ if (s.startsWith("data:"))
11
+ return false;
12
+ return IMAGE_EXT_RE.test(s);
13
+ }
14
+ function normalizeUrl(u) {
15
+ try {
16
+ const url = new URL(u);
17
+ url.search = "";
18
+ return url.toString().toLowerCase();
19
+ }
20
+ catch {
21
+ return u.toLowerCase();
22
+ }
23
+ }
24
+ function parseSource(raw) {
25
+ try {
26
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function getRoots(source) {
33
+ if (!source)
34
+ return [];
35
+ if (source.sections)
36
+ return source.sections;
37
+ if (source.id)
38
+ return [source];
39
+ if (Array.isArray(source))
40
+ return source;
41
+ return [];
42
+ }
43
+ function walkNodes(roots, fn) {
44
+ function walk(n) {
45
+ if (!n)
46
+ return;
47
+ fn(n);
48
+ for (const c of n.children || [])
49
+ walk(c);
50
+ }
51
+ for (const r of roots || [])
52
+ walk(r);
53
+ }
54
+ function collectImagesFromValue(value, ctx, out) {
55
+ if (typeof value === "string") {
56
+ // CSS url(...) refs
57
+ URL_IN_CSS_RE.lastIndex = 0;
58
+ let m;
59
+ while ((m = URL_IN_CSS_RE.exec(value)) !== null) {
60
+ const u = m[1];
61
+ if (u && !u.startsWith("data:"))
62
+ out.push({ url: u, ...ctx });
63
+ }
64
+ const trimmed = value.trim();
65
+ // Whole string is a URL/path image
66
+ if (isImageUrl(trimmed) && (/^https?:\/\//i.test(trimmed) || trimmed.startsWith("/"))) {
67
+ out.push({ url: trimmed, ...ctx });
68
+ return;
69
+ }
70
+ // Image URLs embedded in HTML/longer text
71
+ const matches = value.match(HTTP_URL_RE) || [];
72
+ for (const u of matches) {
73
+ if (isImageUrl(u))
74
+ out.push({ url: u, ...ctx });
75
+ }
76
+ return;
77
+ }
78
+ if (Array.isArray(value)) {
79
+ for (const v of value)
80
+ collectImagesFromValue(v, ctx, out);
81
+ return;
82
+ }
83
+ if (value && typeof value === "object") {
84
+ for (const k of Object.keys(value)) {
85
+ if (k === "children")
86
+ continue; // walked separately
87
+ collectImagesFromValue(value[k], ctx, out);
88
+ }
89
+ }
90
+ }
91
+ function extractFromSource(source, sourceMeta) {
92
+ const out = [];
93
+ const roots = getRoots(source);
94
+ walkNodes(roots, (node) => {
95
+ const { children, ...rest } = node;
96
+ collectImagesFromValue(rest, {
97
+ source_type: sourceMeta.source_type,
98
+ source_id: sourceMeta.source_id,
99
+ source_name: sourceMeta.source_name,
100
+ element_id: node.id || null,
101
+ element_type: node.type || null,
102
+ }, out);
103
+ });
104
+ return out;
105
+ }
106
+ export function registerImageTools(server, api, handle) {
107
+ server.tool("scan_unique_images", `Scan all images used across page sources, global sources, and global sections. Returns a unique list of image URLs with which elements use each one.
108
+ Useful for: image audit, finding broken/duplicated CDN URLs, bulk replace planning, theme migration.
109
+ Scans every string field in the source tree (config.src, style.background-image, etc.) and CSS url(...) refs — wide net catches all variants.`, {
110
+ scope: z.enum(["all", "pages", "global_sources", "global_sections"]).default("all").describe("Which sources to scan"),
111
+ page_id: z.string().optional().describe("Limit to one page (only used when scope is 'pages' or 'all')"),
112
+ lazy: z.boolean().default(false).describe("Return only unique URL list, no usage tracking — faster for large sites"),
113
+ include_relative: z.boolean().default(false).describe("Include relative-path images (e.g. /uploads/...). Default only http(s) URLs"),
114
+ }, ({ scope, page_id, lazy, include_relative }) => handle(async () => {
115
+ const all = [];
116
+ const errors = [];
117
+ if (scope === "all" || scope === "pages") {
118
+ try {
119
+ const res = await api.listPages();
120
+ const pages = (res && res.data) || res || [];
121
+ if (Array.isArray(pages)) {
122
+ const targets = page_id ? pages.filter((p) => p.id === page_id) : pages;
123
+ for (const p of targets) {
124
+ const source = parseSource(p.source && p.source.source);
125
+ if (!source)
126
+ continue;
127
+ all.push(...extractFromSource(source, {
128
+ source_type: "page",
129
+ source_id: p.id,
130
+ source_name: p.name,
131
+ }));
132
+ }
133
+ }
134
+ }
135
+ catch (e) {
136
+ errors.push(`pages: ${e.message}`);
137
+ }
138
+ }
139
+ if (scope === "all" || scope === "global_sources") {
140
+ try {
141
+ const [gsRes, cartRes] = await Promise.all([
142
+ api.getGlobalSources({}).catch(() => null),
143
+ api.getSourceCart().catch(() => null),
144
+ ]);
145
+ const gsList = (gsRes && gsRes.data) || (Array.isArray(gsRes) ? gsRes : []) || [];
146
+ const cartList = (cartRes && cartRes.data) || (Array.isArray(cartRes) ? cartRes : []) || [];
147
+ for (const gs of [...gsList, ...cartList]) {
148
+ const source = parseSource(gs.source);
149
+ if (!source)
150
+ continue;
151
+ all.push(...extractFromSource(source, {
152
+ source_type: "global_source",
153
+ source_id: gs.id,
154
+ source_name: gs.component || gs.type || `gs-${gs.id}`,
155
+ }));
156
+ }
157
+ }
158
+ catch (e) {
159
+ errors.push(`global_sources: ${e.message}`);
160
+ }
161
+ }
162
+ if (scope === "all" || scope === "global_sections") {
163
+ try {
164
+ const res = await api.listGlobalSections();
165
+ const items = (res && res.data) || res || [];
166
+ if (Array.isArray(items)) {
167
+ for (const sec of items) {
168
+ const raw = sec.source && typeof sec.source === "object" && sec.source.source
169
+ ? sec.source.source
170
+ : sec.source;
171
+ const source = parseSource(raw);
172
+ if (!source)
173
+ continue;
174
+ all.push(...extractFromSource(source, {
175
+ source_type: "global_section",
176
+ source_id: sec.id,
177
+ source_name: sec.name || sec.component || `section-${sec.id}`,
178
+ }));
179
+ }
180
+ }
181
+ }
182
+ catch (e) {
183
+ errors.push(`global_sections: ${e.message}`);
184
+ }
185
+ }
186
+ const byUrl = new Map();
187
+ for (const hit of all) {
188
+ const raw = hit.url;
189
+ if (!raw)
190
+ continue;
191
+ if (!include_relative && !/^https?:\/\//i.test(raw))
192
+ continue;
193
+ const key = normalizeUrl(raw);
194
+ if (!byUrl.has(key))
195
+ byUrl.set(key, { url: raw, used_in: [], _seen: new Set() });
196
+ if (!lazy) {
197
+ const entry = byUrl.get(key);
198
+ const usageKey = `${hit.source_type}:${hit.source_id}:${hit.element_id}`;
199
+ if (!entry._seen.has(usageKey)) {
200
+ entry._seen.add(usageKey);
201
+ entry.used_in.push({
202
+ source_type: hit.source_type,
203
+ source_id: hit.source_id,
204
+ source_name: hit.source_name,
205
+ element_id: hit.element_id,
206
+ element_type: hit.element_type,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ const images = [...byUrl.values()].map((img) => lazy
212
+ ? { url: img.url }
213
+ : { url: img.url, used_count: img.used_in.length, used_in: img.used_in });
214
+ return {
215
+ scope,
216
+ unique_count: images.length,
217
+ total_references: all.length,
218
+ ...(errors.length && { errors }),
219
+ images,
220
+ };
221
+ }));
222
+ // ── Image reader: fetch image bytes, return as MCP image content for vision-capable clients ──
223
+ const DESCRIBE_HINT = `Describe the image with these fields, useful as input for new image generation:
224
+ - subject: main object / scene / person
225
+ - style: photography, illustration, 3D render, flat vector, watercolor, ...
226
+ - palette: 3–5 dominant colors (hex or names)
227
+ - composition: layout, framing, focal point
228
+ - mood: emotion or atmosphere
229
+ - lighting: natural / studio / golden hour / dramatic / soft / ...
230
+ - background: setting / environment
231
+ - notable_details: props, textures, typography, brand elements
232
+ Use these as building blocks when drafting an image-gen brief.`;
233
+ /** Resize raw image buffer with sharp so base64 output stays under targetBytes.
234
+ * Skips svg/gif (sharp can't easily handle animation, svg is text). */
235
+ async function shrinkImageBuffer(buf, ctype, targetRawBytes) {
236
+ if (ctype.includes("svg") || ctype.includes("gif"))
237
+ return { buf, mime: ctype };
238
+ try {
239
+ const sharp = (await import("sharp")).default;
240
+ let width = 1024;
241
+ let quality = 80;
242
+ let out = await sharp(buf).rotate().resize({ width, withoutEnlargement: true }).jpeg({ quality }).toBuffer();
243
+ // Step down if still too big
244
+ const steps = [[768, 70], [512, 60], [384, 50]];
245
+ for (const [w, q] of steps) {
246
+ if (out.length <= targetRawBytes)
247
+ break;
248
+ out = await sharp(buf).rotate().resize({ width: w, withoutEnlargement: true }).jpeg({ quality: q }).toBuffer();
249
+ }
250
+ return { buf: out, mime: "image/jpeg" };
251
+ }
252
+ catch {
253
+ return { buf, mime: ctype };
254
+ }
255
+ }
256
+ async function fetchImageAsContent(url, maxSizeMb, opts = {}) {
257
+ if (!/^https?:\/\//i.test(url)) {
258
+ return { ok: false, error: "must be absolute http(s) URL" };
259
+ }
260
+ const ctrl = new AbortController();
261
+ const timer = setTimeout(() => ctrl.abort(), 20000);
262
+ let res;
263
+ try {
264
+ res = await fetch(url, { signal: ctrl.signal });
265
+ }
266
+ catch (e) {
267
+ clearTimeout(timer);
268
+ return { ok: false, error: `fetch failed: ${e.message}` };
269
+ }
270
+ clearTimeout(timer);
271
+ if (!res.ok)
272
+ return { ok: false, error: `HTTP ${res.status}` };
273
+ const ctype = (res.headers.get("content-type") || "").split(";")[0].trim();
274
+ if (!ctype.startsWith("image/"))
275
+ return { ok: false, error: `not an image (${ctype || "unknown"})` };
276
+ let buf = Buffer.from(await res.arrayBuffer());
277
+ const sizeMb = buf.length / (1024 * 1024);
278
+ if (sizeMb > maxSizeMb) {
279
+ return { ok: false, error: `image too large (${sizeMb.toFixed(2)}MB > ${maxSizeMb}MB)` };
280
+ }
281
+ // Target: base64 output should stay under (targetBase64Kb) → raw bytes = targetBase64Kb * 1024 * 0.75
282
+ // Default 600KB base64 for single-image use; caller can override (batch uses smaller target).
283
+ const targetBase64Kb = opts.targetBase64Kb || 600;
284
+ const targetRawBytes = Math.floor(targetBase64Kb * 1024 * 0.75);
285
+ let mime = ctype;
286
+ if (buf.length > targetRawBytes) {
287
+ const shrunk = await shrinkImageBuffer(buf, ctype, targetRawBytes);
288
+ buf = shrunk.buf;
289
+ mime = shrunk.mime;
290
+ }
291
+ return { ok: true, mime, data: buf.toString("base64"), size_kb: Math.round(buf.length / 1024), resized: mime !== ctype };
292
+ }
293
+ server.tool("read_image", `Fetch an image URL and return its bytes for vision analysis by the AI client. Pair with scan_unique_images to inspect images already on the site.
294
+
295
+ After receiving the image, describe it (subject, style, palette, composition, mood, lighting, background, notable_details) to build an image-generation brief.
296
+
297
+ ${DESCRIBE_HINT}`, {
298
+ url: z.string().describe("Absolute http(s) image URL"),
299
+ max_size_mb: z.number().default(8).describe("Reject images larger than this (default 8MB)"),
300
+ }, async ({ url, max_size_mb }) => {
301
+ const r = await fetchImageAsContent(url, max_size_mb);
302
+ if (!r.ok) {
303
+ return { content: [{ type: "text", text: `Error: ${r.error}` }], isError: true };
304
+ }
305
+ return {
306
+ content: [
307
+ { type: "image", data: r.data, mimeType: r.mime },
308
+ { type: "text", text: JSON.stringify({ url, mime: r.mime, size_kb: r.size_kb }) },
309
+ ],
310
+ };
311
+ });
312
+ // ── Image element discovery + alt writer ──
313
+ function isImageElement(node) {
314
+ const t = (node && node.type) ? String(node.type).toLowerCase() : "";
315
+ if (/image|img|picture|photo/i.test(t))
316
+ return true;
317
+ // Check root + every breakpoint config for an image-bearing field
318
+ const configs = [node && node.config];
319
+ for (const k of Object.keys(node || {})) {
320
+ if (/^bp\d+$/.test(k) && node[k] && typeof node[k] === "object")
321
+ configs.push(node[k].config);
322
+ }
323
+ for (const cfg of configs) {
324
+ if (!cfg || typeof cfg !== "object")
325
+ continue;
326
+ const candidates = [cfg.src, cfg.url, cfg.image && (cfg.image.src || cfg.image.url), cfg.background && (cfg.background.src || cfg.background.url)];
327
+ if (candidates.some((v) => typeof v === "string" && isImageUrl(v)))
328
+ return true;
329
+ }
330
+ return false;
331
+ }
332
+ /** Find src inside a config object (used per-breakpoint). Returns { src, sub_path } where sub_path is the leaf path inside config. */
333
+ function findSrcInConfig(cfg) {
334
+ if (!cfg || typeof cfg !== "object")
335
+ return { src: "", sub_path: "" };
336
+ if (cfg.image && typeof cfg.image === "object") {
337
+ const src = cfg.image.src || cfg.image.url || "";
338
+ if (src)
339
+ return { src, sub_path: "image.src" };
340
+ }
341
+ if (typeof cfg.src === "string" && isImageUrl(cfg.src)) {
342
+ return { src: cfg.src, sub_path: "src" };
343
+ }
344
+ if (typeof cfg.url === "string" && isImageUrl(cfg.url)) {
345
+ return { src: cfg.url, sub_path: "url" };
346
+ }
347
+ if (cfg.background && typeof cfg.background === "object") {
348
+ const src = cfg.background.src || cfg.background.url || "";
349
+ if (src)
350
+ return { src, sub_path: "background.src" };
351
+ }
352
+ return { src: "", sub_path: "" };
353
+ }
354
+ /** Locate the src path. In this builder, config lives inside breakpoints (bp1, bp2...).
355
+ * Walks bp1 → bp2 → ... → root.config as fallback. Alt is always at specials.image_alt. */
356
+ function probeImagePaths(node) {
357
+ const specials = (node && node.specials) || {};
358
+ const alt_path = "specials.image_alt";
359
+ const alt = specials.image_alt || specials.alt || "";
360
+ // Iterate breakpoints in sorted order (bp1 first)
361
+ const bpKeys = Object.keys(node || {})
362
+ .filter((k) => /^bp\d+$/.test(k))
363
+ .sort((a, b) => Number(a.slice(2)) - Number(b.slice(2)));
364
+ for (const bp of bpKeys) {
365
+ const cfg = node[bp] && node[bp].config;
366
+ const { src, sub_path } = findSrcInConfig(cfg);
367
+ if (src)
368
+ return { src, alt, src_path: `${bp}.config.${sub_path}`, alt_path };
369
+ }
370
+ // Fallback: root config (some legacy/non-responsive nodes)
371
+ const { src, sub_path } = findSrcInConfig(node && node.config);
372
+ if (src)
373
+ return { src, alt, src_path: `config.${sub_path}`, alt_path };
374
+ return { src: "", alt, src_path: "", alt_path };
375
+ }
376
+ function setByPath(obj, path, value) {
377
+ const parts = path.split(".");
378
+ let cur = obj;
379
+ for (let i = 0; i < parts.length - 1; i++) {
380
+ const k = parts[i];
381
+ if (cur[k] == null || typeof cur[k] !== "object")
382
+ cur[k] = {};
383
+ cur = cur[k];
384
+ }
385
+ cur[parts[parts.length - 1]] = value;
386
+ }
387
+ server.tool("list_image_elements", `Find all image elements across pages + global sources, with element_id, current alt, src, and the field path where alt is/should be written. Use as the first step before generating alt text via vision (read_image) and writing back with set_image_alts.
388
+ Note: global_sections are read-only via the API and are not included.`, {
389
+ scope: z.enum(["all", "pages", "global_sources"]).default("all").describe("Which sources to inspect"),
390
+ page_id: z.string().optional().describe("Limit to one page (only used when scope is 'pages' or 'all')"),
391
+ only_missing_alt: z.boolean().default(false).describe("Return only elements whose alt is empty"),
392
+ limit: z.number().default(500).describe("Max elements to return"),
393
+ }, ({ scope, page_id, only_missing_alt, limit }) => handle(async () => {
394
+ const out = [];
395
+ const errors = [];
396
+ function visitSource(source, meta) {
397
+ if (out.length >= limit)
398
+ return;
399
+ const roots = getRoots(source);
400
+ walkNodes(roots, (node) => {
401
+ if (out.length >= limit)
402
+ return;
403
+ if (!isImageElement(node))
404
+ return;
405
+ const { src, alt, src_path, alt_path } = probeImagePaths(node);
406
+ if (only_missing_alt && alt && alt.trim())
407
+ return;
408
+ const cached = src ? getImageAlt(normalizeUrl(src)) : null;
409
+ out.push({
410
+ source_type: meta.source_type,
411
+ source_id: meta.source_id,
412
+ source_name: meta.source_name,
413
+ element_id: node.id || null,
414
+ element_type: node.type || null,
415
+ src: src || null,
416
+ alt: alt || "",
417
+ src_path,
418
+ alt_path,
419
+ ...(cached && { cached_alt: cached.alt, cached_source: cached.source, cached_at: cached.updated_at }),
420
+ });
421
+ });
422
+ }
423
+ if (scope === "all" || scope === "pages") {
424
+ try {
425
+ const res = await api.listPages();
426
+ const pages = (res && res.data) || res || [];
427
+ if (Array.isArray(pages)) {
428
+ const targets = page_id ? pages.filter((p) => p.id === page_id) : pages;
429
+ for (const p of targets) {
430
+ const source = parseSource(p.source && p.source.source);
431
+ if (source)
432
+ visitSource(source, { source_type: "page", source_id: p.id, source_name: p.name });
433
+ }
434
+ }
435
+ }
436
+ catch (e) {
437
+ errors.push(`pages: ${e.message}`);
438
+ }
439
+ }
440
+ if (scope === "all" || scope === "global_sources") {
441
+ try {
442
+ const [gsRes, cartRes] = await Promise.all([
443
+ api.getGlobalSources({}).catch(() => null),
444
+ api.getSourceCart().catch(() => null),
445
+ ]);
446
+ const gsList = (gsRes && gsRes.data) || (Array.isArray(gsRes) ? gsRes : []) || [];
447
+ const cartList = (cartRes && cartRes.data) || (Array.isArray(cartRes) ? cartRes : []) || [];
448
+ for (const gs of [...gsList, ...cartList]) {
449
+ const source = parseSource(gs.source);
450
+ if (source)
451
+ visitSource(source, {
452
+ source_type: "global_source",
453
+ source_id: gs.id,
454
+ source_name: gs.component || gs.type || `gs-${gs.id}`,
455
+ });
456
+ }
457
+ }
458
+ catch (e) {
459
+ errors.push(`global_sources: ${e.message}`);
460
+ }
461
+ }
462
+ return {
463
+ scope,
464
+ count: out.length,
465
+ truncated: out.length >= limit,
466
+ ...(errors.length && { errors }),
467
+ elements: out,
468
+ };
469
+ }));
470
+ function findNodeByIdInSource(source, elementId) {
471
+ let found = null;
472
+ const roots = getRoots(source);
473
+ walkNodes(roots, (n) => { if (n.id === elementId)
474
+ found = n; });
475
+ return found;
476
+ }
477
+ server.tool("set_image_alts", `Batch-write alt text for image elements across pages + global sources. Groups updates by source so each source is fetched + saved exactly once.
478
+ Workflow: list_image_elements → read_image (per src) → describe → set_image_alts(items).
479
+ If alt_path is omitted, it is auto-detected via the same probe used by list_image_elements (config.image.alt → config.alt → specials.alt).`, {
480
+ items: z.array(z.object({
481
+ source_type: z.enum(["page", "global_source"]).describe("Which source contains the element"),
482
+ source_id: z.string().describe("Page ID or global source ID"),
483
+ element_id: z.string().describe("Element ID"),
484
+ alt: z.string().describe("Alt text to write"),
485
+ alt_path: z.string().optional().describe("Dotted path inside the node, e.g. 'config.image.alt'. Omit to auto-detect"),
486
+ })).min(1).describe("List of alt updates"),
487
+ dry_run: z.boolean().default(false).describe("Preview the diff without saving"),
488
+ }, ({ items, dry_run }) => handle(async () => {
489
+ const groups = new Map();
490
+ for (const it of items) {
491
+ const key = `${it.source_type}:${it.source_id}`;
492
+ if (!groups.has(key))
493
+ groups.set(key, []);
494
+ groups.get(key).push(it);
495
+ }
496
+ const results = [];
497
+ for (const [key, group] of groups) {
498
+ const [source_type, source_id] = key.split(":");
499
+ let source = null;
500
+ let saver = null;
501
+ let existingLen = 0;
502
+ let context = null;
503
+ try {
504
+ if (source_type === "page") {
505
+ const pages = await api.listPages();
506
+ const list = (pages && pages.data) || pages || [];
507
+ const page = Array.isArray(list) ? list.find((p) => p.id === source_id) : null;
508
+ if (!page) {
509
+ results.push({ source_type, source_id, error: "Page not found" });
510
+ continue;
511
+ }
512
+ source = parseSource(page.source && page.source.source);
513
+ if (!source) {
514
+ results.push({ source_type, source_id, error: "Page has no source" });
515
+ continue;
516
+ }
517
+ existingLen = JSON.stringify(source).length;
518
+ saver = (newSrc) => api.updatePageSource(source_id, { source: newSrc });
519
+ context = { page };
520
+ }
521
+ else {
522
+ const [gsRes, cartRes] = await Promise.all([
523
+ api.getGlobalSources({}).catch(() => null),
524
+ api.getSourceCart().catch(() => null),
525
+ ]);
526
+ const gsList = (gsRes && gsRes.data) || (Array.isArray(gsRes) ? gsRes : []) || [];
527
+ const cartList = (cartRes && cartRes.data) || (Array.isArray(cartRes) ? cartRes : []) || [];
528
+ const gs = [...gsList, ...cartList].find((g) => String(g.id) === String(source_id));
529
+ if (!gs) {
530
+ results.push({ source_type, source_id, error: "Global source not found" });
531
+ continue;
532
+ }
533
+ source = parseSource(gs.source);
534
+ if (!source) {
535
+ results.push({ source_type, source_id, error: "Global source has no source" });
536
+ continue;
537
+ }
538
+ existingLen = JSON.stringify(source).length;
539
+ const isCart = gs.component === "cart-droppable";
540
+ saver = (newSrc) => isCart
541
+ ? api.updateSourceCart({ source: newSrc, type: gs.type, site_id: api.siteId })
542
+ : api.updateGlobalSource({ global_source_id: source_id, source: newSrc, type: gs.component, site_id: api.siteId });
543
+ context = { gs };
544
+ }
545
+ }
546
+ catch (e) {
547
+ results.push({ source_type, source_id, error: `load failed: ${e.message}` });
548
+ continue;
549
+ }
550
+ const perItem = [];
551
+ for (const it of group) {
552
+ const node = findNodeByIdInSource(source, it.element_id);
553
+ if (!node) {
554
+ perItem.push({ element_id: it.element_id, error: "Element not found" });
555
+ continue;
556
+ }
557
+ const probe = probeImagePaths(node);
558
+ const path = it.alt_path || probe.alt_path;
559
+ const before = path.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), node);
560
+ setByPath(node, path, it.alt);
561
+ perItem.push({ element_id: it.element_id, alt_path: path, before: before == null ? "" : before, after: it.alt, _src: probe.src });
562
+ }
563
+ if (dry_run) {
564
+ results.push({ source_type, source_id, dry_run: true, updates: perItem });
565
+ continue;
566
+ }
567
+ const newLen = JSON.stringify(source).length;
568
+ if (existingLen > 200 && newLen < existingLen * 0.5) {
569
+ results.push({ source_type, source_id, error: `BLOCKED: source would shrink ${existingLen} → ${newLen}`, updates: perItem });
570
+ continue;
571
+ }
572
+ try {
573
+ await saver(source);
574
+ // Auto-cache: save alt per src URL so re-runs can skip OCR
575
+ const cacheBatch = [];
576
+ for (const u of perItem) {
577
+ if (u.error || !u._src)
578
+ continue;
579
+ if (!/^https?:\/\//i.test(u._src))
580
+ continue;
581
+ cacheBatch.push({ url_key: normalizeUrl(u._src), url: u._src, alt: u.after, source: "ai" });
582
+ }
583
+ if (cacheBatch.length) {
584
+ try {
585
+ dbSetImageAlts(cacheBatch);
586
+ }
587
+ catch { /* cache best-effort */ }
588
+ if (isMongoEnabled()) {
589
+ mongoUpsertAlts(cacheBatch).catch(() => { });
590
+ }
591
+ }
592
+ results.push({
593
+ source_type,
594
+ source_id,
595
+ success: true,
596
+ updated: perItem.filter((u) => !u.error).length,
597
+ cached: cacheBatch.length,
598
+ updates: perItem.map(({ _src, ...rest }) => rest),
599
+ });
600
+ }
601
+ catch (e) {
602
+ results.push({ source_type, source_id, error: `save failed: ${e.message}`, updates: perItem.map(({ _src, ...rest }) => rest) });
603
+ }
604
+ }
605
+ return { dry_run, sources: results.length, results };
606
+ }));
607
+ // ── Alt cache tools ──
608
+ server.tool("get_cached_image_alts", `Look up cached alt descriptions for image URLs. URLs are matched by normalized form (query string stripped, lowercase). Use BEFORE running read_image/OCR — skip already-described URLs.
609
+ When MONGO_URI is set, misses are then checked against MongoDB and successful hits are backfilled into the local SQLite cache for fast re-lookup.`, {
610
+ urls: z.array(z.string()).min(1).describe("Image URLs to look up"),
611
+ }, ({ urls }) => handle(async () => {
612
+ const hits = [];
613
+ let misses = [];
614
+ const keyToUrl = new Map();
615
+ for (const u of urls) {
616
+ if (!/^https?:\/\//i.test(u)) {
617
+ misses.push(u);
618
+ continue;
619
+ }
620
+ const key = normalizeUrl(u);
621
+ keyToUrl.set(key, u);
622
+ const row = getImageAlt(key);
623
+ if (row)
624
+ hits.push({ url: u, url_key: key, alt: row.alt, source: row.source, updated_at: row.updated_at });
625
+ else
626
+ misses.push(u);
627
+ }
628
+ let mongo_hits = 0;
629
+ if (isMongoEnabled() && misses.length) {
630
+ const missKeys = misses
631
+ .filter((u) => /^https?:\/\//i.test(u))
632
+ .map((u) => normalizeUrl(u));
633
+ try {
634
+ const found = await mongoFindAlts(missKeys);
635
+ if (found.size) {
636
+ const backfill = [];
637
+ const stillMissing = [];
638
+ for (const u of misses) {
639
+ const k = /^https?:\/\//i.test(u) ? normalizeUrl(u) : null;
640
+ if (k && found.has(k)) {
641
+ const doc = found.get(k);
642
+ hits.push({ url: u, url_key: k, alt: doc.alt, source: doc.source || "mongo", updated_at: doc.updated_at, origin: "mongo" });
643
+ backfill.push({ url_key: k, url: doc.url || u, alt: doc.alt, source: doc.source || "mongo" });
644
+ mongo_hits++;
645
+ }
646
+ else {
647
+ stillMissing.push(u);
648
+ }
649
+ }
650
+ if (backfill.length) {
651
+ try {
652
+ dbSetImageAlts(backfill);
653
+ }
654
+ catch { /* best-effort */ }
655
+ }
656
+ misses = stillMissing;
657
+ }
658
+ }
659
+ catch { /* fall through with original misses */ }
660
+ }
661
+ return { hits_count: hits.length, miss_count: misses.length, mongo_hits, hits, misses };
662
+ }));
663
+ server.tool("save_image_alts_cache", `Manually save image URL → alt entries to the local cache. Useful for bulk import or saving descriptions generated outside the set_image_alts flow.`, {
664
+ items: z.array(z.object({
665
+ url: z.string().describe("Image URL"),
666
+ alt: z.string().describe("Alt/description text"),
667
+ source: z.string().optional().describe("Origin tag (e.g. 'ai', 'manual', 'imported'). Default 'manual'"),
668
+ })).min(1),
669
+ }, ({ items }) => handle(async () => {
670
+ const batch = [];
671
+ const skipped = [];
672
+ for (const it of items) {
673
+ if (!/^https?:\/\//i.test(it.url)) {
674
+ skipped.push({ url: it.url, reason: "non-http URL" });
675
+ continue;
676
+ }
677
+ batch.push({ url_key: normalizeUrl(it.url), url: it.url, alt: it.alt, source: it.source || "manual" });
678
+ }
679
+ if (batch.length) {
680
+ dbSetImageAlts(batch);
681
+ if (isMongoEnabled()) {
682
+ mongoUpsertAlts(batch).catch(() => { });
683
+ }
684
+ }
685
+ return { saved: batch.length, skipped, mongo: isMongoEnabled() ? "queued" : "disabled" };
686
+ }));
687
+ server.tool("list_image_alts_cache", `List entries in the alt cache, most recently updated first.`, {
688
+ limit: z.number().default(100).describe("Max entries (default 100)"),
689
+ offset: z.number().default(0).describe("Pagination offset"),
690
+ }, ({ limit, offset }) => handle(async () => {
691
+ const total = countImageAlts();
692
+ const rows = listImageAlts(limit, offset);
693
+ return { total, count: rows.length, entries: rows };
694
+ }));
695
+ // ── Mongo sync (active when MONGO_URI is set) ──
696
+ server.tool("sync_image_alts_to_mongo", `Push local SQLite alt cache entries up to MongoDB. Bulk upsert keyed by url_key. Use when you want to back up local-only entries to the shared central store, or after a session of heavy AI describes.
697
+ Requires MONGO_URI env var.`, {
698
+ limit: z.number().default(1000).describe("Max entries to push per call"),
699
+ offset: z.number().default(0).describe("Offset into local cache"),
700
+ }, ({ limit, offset }) => handle(async () => {
701
+ if (!isMongoEnabled())
702
+ return { error: "MONGO_URI not configured" };
703
+ const rows = listImageAlts(limit, offset);
704
+ if (!rows.length)
705
+ return { pushed: 0, total_local: countImageAlts() };
706
+ const res = await mongoUpsertAlts(rows.map((r) => ({ url_key: r.url_key, url: r.url, alt: r.alt, source: r.source })));
707
+ return { pushed: rows.length, ...res, total_local: countImageAlts() };
708
+ }));
709
+ server.tool("sync_image_alts_from_mongo", `Pull MongoDB alt entries down into local SQLite cache. Useful when starting on a new machine/site to warm the local cache from the central store.
710
+ Requires MONGO_URI env var.`, {
711
+ limit: z.number().default(1000).describe("Max entries to pull"),
712
+ offset: z.number().default(0).describe("Offset into Mongo collection"),
713
+ }, ({ limit, offset }) => handle(async () => {
714
+ if (!isMongoEnabled())
715
+ return { error: "MONGO_URI not configured" };
716
+ const { total, entries } = await mongoListAlts(limit, offset);
717
+ if (entries.length) {
718
+ dbSetImageAlts(entries.map((e) => ({ url_key: e.url_key, url: e.url, alt: e.alt, source: e.source || "mongo" })));
719
+ }
720
+ return { pulled: entries.length, total_remote: total, total_local: countImageAlts() };
721
+ }));
722
+ // ── Combo: fetch images + metadata in one call so Claude can describe + call set_image_alts once ──
723
+ server.tool("fetch_images_for_alt_fill", `One-shot helper for filling image_alt across the site. Returns image bytes + element metadata in a single response so Claude can describe everything in one pass, then call set_image_alts once.
724
+
725
+ Workflow:
726
+ 1. Call this tool with scope/limit.
727
+ 2. Tool returns each image inline with its element_id + source_type + source_id (and skips URLs already in cache).
728
+ 3. Claude reads images, drafts an alt for each, then calls set_image_alts(items) once with the template at the end of the response.
729
+
730
+ The pre-built "items" template at the end contains placeholders — fill in "alt" and call set_image_alts.`, {
731
+ scope: z.enum(["all", "pages", "global_sources"]).default("all"),
732
+ page_id: z.string().optional(),
733
+ only_missing_alt: z.boolean().default(true).describe("Default true — skip elements that already have alt"),
734
+ skip_cached: z.boolean().default(true).describe("Skip URLs already in alt cache (Claude doesn't need to describe again)"),
735
+ limit: z.number().default(10).describe("Max images per call (cap 20)"),
736
+ max_size_mb: z.number().default(8),
737
+ }, async ({ scope, page_id, only_missing_alt, skip_cached, limit, max_size_mb }) => {
738
+ try {
739
+ const cap = Math.min(Math.max(limit, 1), 20);
740
+ // 1. Collect candidate elements
741
+ const candidates = [];
742
+ const addFromSource = (source, meta) => {
743
+ const roots = getRoots(source);
744
+ walkNodes(roots, (node) => {
745
+ if (candidates.length >= cap * 3)
746
+ return; // overscan, will filter
747
+ if (!isImageElement(node))
748
+ return;
749
+ const probe = probeImagePaths(node);
750
+ if (only_missing_alt && probe.alt && probe.alt.trim())
751
+ return;
752
+ if (!probe.src || !/^https?:\/\//i.test(probe.src))
753
+ return;
754
+ candidates.push({
755
+ source_type: meta.source_type,
756
+ source_id: meta.source_id,
757
+ source_name: meta.source_name,
758
+ element_id: node.id,
759
+ src: probe.src,
760
+ alt_path: probe.alt_path,
761
+ });
762
+ });
763
+ };
764
+ if (scope === "all" || scope === "pages") {
765
+ const res = await api.listPages();
766
+ const pages = (res && res.data) || res || [];
767
+ if (Array.isArray(pages)) {
768
+ const targets = page_id ? pages.filter((p) => p.id === page_id) : pages;
769
+ for (const p of targets) {
770
+ const source = parseSource(p.source && p.source.source);
771
+ if (source)
772
+ addFromSource(source, { source_type: "page", source_id: p.id, source_name: p.name });
773
+ }
774
+ }
775
+ }
776
+ if (scope === "all" || scope === "global_sources") {
777
+ const [gsRes, cartRes] = await Promise.all([
778
+ api.getGlobalSources({}).catch(() => null),
779
+ api.getSourceCart().catch(() => null),
780
+ ]);
781
+ const gsList = (gsRes && gsRes.data) || (Array.isArray(gsRes) ? gsRes : []) || [];
782
+ const cartList = (cartRes && cartRes.data) || (Array.isArray(cartRes) ? cartRes : []) || [];
783
+ for (const gs of [...gsList, ...cartList]) {
784
+ const source = parseSource(gs.source);
785
+ if (source)
786
+ addFromSource(source, {
787
+ source_type: "global_source",
788
+ source_id: gs.id,
789
+ source_name: gs.component || gs.type || `gs-${gs.id}`,
790
+ });
791
+ }
792
+ }
793
+ // 2. Resolve cache hits → auto-prepare items; misses → need vision
794
+ const autoItems = [];
795
+ const needVision = [];
796
+ for (const c of candidates) {
797
+ if (skip_cached) {
798
+ const cached = getImageAlt(normalizeUrl(c.src));
799
+ if (cached && cached.alt) {
800
+ autoItems.push({ source_type: c.source_type, source_id: c.source_id, element_id: c.element_id, alt: cached.alt });
801
+ continue;
802
+ }
803
+ }
804
+ needVision.push(c);
805
+ if (needVision.length >= cap)
806
+ break;
807
+ }
808
+ // 3. Fetch image bytes in parallel (budget ~950KB total / N images, base64)
809
+ const perImageBase64Kb = needVision.length ? Math.max(60, Math.floor(950 / needVision.length)) : 600;
810
+ const fetched = await Promise.all(needVision.map((c) => fetchImageAsContent(c.src, max_size_mb, { targetBase64Kb: perImageBase64Kb })));
811
+ // 4. Build mixed content response
812
+ const content = [];
813
+ content.push({
814
+ type: "text",
815
+ text: `Fetched ${needVision.length} image(s) needing description. ${autoItems.length} auto-filled from cache. ${candidates.length - needVision.length - autoItems.length} skipped.
816
+
817
+ For each image below, write a short alt description in the language of the site (Vietnamese unless content suggests otherwise). Focus on the SUBJECT visible — avoid generic phrases like "image of...".
818
+
819
+ When done, call set_image_alts with the items array. The template is at the bottom of this response.`,
820
+ });
821
+ const visionItems = [];
822
+ for (let i = 0; i < needVision.length; i++) {
823
+ const c = needVision[i];
824
+ const r = fetched[i];
825
+ const header = `[#${i + 1}] element_id=${c.element_id} | source=${c.source_type}:${c.source_id} (${c.source_name}) | src=${c.src}`;
826
+ content.push({ type: "text", text: header });
827
+ if (r.ok) {
828
+ content.push({ type: "image", data: r.data, mimeType: r.mime });
829
+ visionItems.push({
830
+ source_type: c.source_type,
831
+ source_id: c.source_id,
832
+ element_id: c.element_id,
833
+ alt: "<FILL_ALT_FOR_#" + (i + 1) + ">",
834
+ });
835
+ }
836
+ else {
837
+ content.push({ type: "text", text: `(fetch error: ${r.error})` });
838
+ }
839
+ }
840
+ const template = {
841
+ auto_from_cache: autoItems,
842
+ to_describe: visionItems,
843
+ next_step: "Replace each <FILL_ALT_FOR_#N> with your description, then call set_image_alts with items = [...auto_from_cache, ...to_describe].",
844
+ };
845
+ content.push({ type: "text", text: JSON.stringify(template, null, 2) });
846
+ return { content };
847
+ }
848
+ catch (e) {
849
+ return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
850
+ }
851
+ });
852
+ server.tool("read_images", `Batch fetch multiple image URLs in parallel. Use when comparing several references or extracting motifs across a set.
853
+ Capped at 5 images per call to keep context manageable. For each image, describe subject/style/palette/composition/mood; then synthesize common themes for the brief.
854
+
855
+ ${DESCRIBE_HINT}`, {
856
+ urls: z.array(z.string()).min(1).max(5).describe("1–5 absolute http(s) image URLs"),
857
+ max_size_mb: z.number().default(8).describe("Per-image size cap in MB"),
858
+ }, async ({ urls, max_size_mb }) => {
859
+ const perImageBase64Kb = urls.length ? Math.max(60, Math.floor(950 / urls.length)) : 600;
860
+ const results = await Promise.all(urls.map((u) => fetchImageAsContent(u, max_size_mb, { targetBase64Kb: perImageBase64Kb })));
861
+ const content = [];
862
+ const summary = [];
863
+ for (let i = 0; i < urls.length; i++) {
864
+ const r = results[i];
865
+ if (!r.ok) {
866
+ summary.push({ url: urls[i], error: r.error });
867
+ continue;
868
+ }
869
+ content.push({ type: "image", data: r.data, mimeType: r.mime });
870
+ summary.push({ url: urls[i], mime: r.mime, size_kb: r.size_kb });
871
+ }
872
+ content.push({ type: "text", text: JSON.stringify({ count: content.length - 0, images: summary }) });
873
+ return { content };
874
+ });
875
+ }