webcake-storefront-mcp 1.7.0 → 1.9.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/dist/api.js CHANGED
@@ -363,6 +363,13 @@ export class WebcakeCmsApi {
363
363
  getApp(type) {
364
364
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/applications/subcriptions/get_app`, { query: { type } });
365
365
  }
366
+ /** Install (register) an application on the site. type = Enum.Application value
367
+ * (e.g. automation=2, send_email=7). Body: { site_id, type, is_active }. */
368
+ registerApp(type, is_active = true) {
369
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/applications/subcriptions/register`, {
370
+ body: { site_id: this.siteId, type, is_active },
371
+ });
372
+ }
366
373
  // ── Promotions ──
367
374
  listPromotions(query) {
368
375
  return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/promotion_advance/all`, { query });
@@ -425,6 +432,24 @@ export class WebcakeCmsApi {
425
432
  return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/multilingual/update_global_source_contents`, { body: params });
426
433
  }
427
434
  // ── Automation ──
435
+ /** List the site's automations (id, name, rule/trigger, status). Use this to find the
436
+ * automation_id to pass to send_mail. Returns { data: [...], total_entries, ... }. */
437
+ listAutomations(query) {
438
+ return this.request("GET", `/api/v1/dashboard/site/${this.siteId}/automations/all`, { query });
439
+ }
440
+ createAutomation(automationAttrs) {
441
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/automations/create`, {
442
+ body: { automation_attrs: { ...automationAttrs, site_id: this.siteId } },
443
+ });
444
+ }
445
+ updateAutomation(automationAttrs) {
446
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/automations/update`, {
447
+ body: { automation_attrs: automationAttrs },
448
+ });
449
+ }
450
+ deleteAutomations(ids) {
451
+ return this.request("POST", `/api/v1/dashboard/site/${this.siteId}/automations/delete`, { body: { ids } });
452
+ }
428
453
  sendMail(params) {
429
454
  return this.request("POST", `/api/v1/cms_function/${this.siteId}/application/automation/send_mail`, { body: params });
430
455
  }
@@ -64,7 +64,9 @@ key — only bp1..bp4.)
64
64
 
65
65
  ## Content & data
66
66
  - Text: \`specials.text\` (HTML allowed), \`specials.tag\` ("h1".."p").
67
- - Image: \`runtime.config.src\` (URL). Use search_images / upload before referencing.
67
+ - Image: \`runtime.config.src\` (URL). The URL MUST be a WebCake CDN url — the storefront
68
+ whitelists image domains, so external URLs (Pexels, random sites) won't render. Get CDN
69
+ urls from search_images (uploads by default → use its cdn_url) or upload_images.
68
70
  - Form: wrap inputs in a \`form\`; set \`form.specials.type\`
69
71
  (form_order | form_login | form_signup | form_discount | order_tracking). Each input
70
72
  needs \`specials.field_name\`.
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.9.0",
4
+ "d": "24/06/2026",
5
+ "type": "Added",
6
+ "en": "search_images now accepts an upload parameter (default true) that re-hosts each Pexels result on the WebCake CDN and adds a cdn_url field to every…",
7
+ "vi": "search_images nay nhận thêm tham số upload (mặc định true) để tự động tải từng kết quả Pexels lên WebCake CDN và bổ sung trường cdn_url vào mỗi ảnh,…"
8
+ },
9
+ {
10
+ "v": "1.8.0",
11
+ "d": "23/06/2026",
12
+ "type": "Added",
13
+ "en": "New list_automations tool returns each automation's id, name, status, and trigger info so agents can find the automation_id required by send_mail or…",
14
+ "vi": "Tool mới list_automations trả về id, name, status và thông tin trigger của từng automation, giúp agent tìm được automation_id cần truyền vào…"
15
+ },
2
16
  {
3
17
  "v": "1.7.0",
4
18
  "d": "23/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Added",
27
41
  "en": "New create_product tool creates a storefront product (simple name + price, or advanced with named attributes and per-SKU variations); accepts hosted…",
28
42
  "vi": "Tool mới create_product tạo sản phẩm cho storefront (đơn giản với tên + giá, hoặc nâng cao với attributes và variations theo từng SKU); nhận URL ảnh…"
29
- },
30
- {
31
- "v": "1.4.0",
32
- "d": "23/06/2026",
33
- "type": "Changed",
34
- "en": "The install command's interactive wizard now presents numbered choices with ANSI colour output and a completion summary.",
35
- "vi": "Trình hướng dẫn tương tác của lệnh install nay hiển thị các lựa chọn được đánh số kèm màu ANSI và thông báo tóm tắt sau khi hoàn tất."
36
- },
37
- {
38
- "v": "1.3.0",
39
- "d": "23/06/2026",
40
- "type": "Added",
41
- "en": "New create_site tool creates a brand-new storefront site for the current account (seeded with sample products, categories, and a blog), optionally…",
42
- "vi": "Tool mới create_site tạo một site storefront hoàn toàn mới cho tài khoản hiện tại (kèm sản phẩm, danh mục và blog mẫu), tự động chuyển sang site vừa…"
43
43
  }
44
44
  ]
package/dist/db.js CHANGED
@@ -43,3 +43,18 @@ export function delConfig(key) {
43
43
  export function getAllConfig() {
44
44
  return { ...config };
45
45
  }
46
+ // ── Image upload cache (source URL/path → WebCake CDN URL, per site) ──────────
47
+ // Re-hosting the same stock photo / external image twice wastes an upload, so we
48
+ // remember the CDN URL the first time. Keyed by site because CDN URLs are per-site.
49
+ const IMAGE_CACHE_FILE = join(CONFIG_DIR, "image-cache.json");
50
+ const imageCache = readJson(IMAGE_CACHE_FILE, {});
51
+ export function getCachedUpload(siteId, source) {
52
+ const k = `${siteId}::${source}`;
53
+ return k in imageCache ? imageCache[k] : null;
54
+ }
55
+ export function setCachedUpload(siteId, source, cdnUrl) {
56
+ if (!siteId || !source || !cdnUrl)
57
+ return;
58
+ imageCache[`${siteId}::${source}`] = cdnUrl;
59
+ writeJson(IMAGE_CACHE_FILE, imageCache);
60
+ }
package/dist/enums.js ADDED
@@ -0,0 +1,46 @@
1
+ // WebCake enum reference, mirrored from builderx_api so the AI knows what the numeric
2
+ // codes / string values mean. Sources:
3
+ // - Enum.Application (lib/builderx_api/enum.ex) — application/app types
4
+ // - Automation schema (lib/builderx_api/automations/automation.ex) — type/status
5
+ // - Enum.PageType (page kinds) — kept here for one-stop reference
6
+ /** Application types (the `type` used by get_app / install_app / register). */
7
+ export const APP_TYPES = {
8
+ product_review: 0,
9
+ articles_review: 1,
10
+ automation: 2,
11
+ telegram: 3,
12
+ affiliates: 4,
13
+ multilingual: 5,
14
+ appointment: 6,
15
+ send_email: 7,
16
+ botcake: 8,
17
+ sale_channel: 9,
18
+ product_design: 10,
19
+ auth_otp: 11,
20
+ personal_product_design: 12,
21
+ course: 14,
22
+ zalo_mini_app: 15,
23
+ cms: 16,
24
+ recaptcha: 17,
25
+ pwa: 18,
26
+ };
27
+ export const APP_NAMES = Object.keys(APP_TYPES);
28
+ /** name -> code and code -> name helpers. */
29
+ export const APP_TYPE_BY_NAME = { ...APP_TYPES };
30
+ export const APP_NAME_BY_TYPE = Object.fromEntries(Object.entries(APP_TYPES).map(([k, v]) => [v, k]));
31
+ /** Automation.status values. */
32
+ export const AUTOMATION_STATUS = ["ACTIVE", "INACTIVE"];
33
+ /** Automation.type — user-built automations default to CUSTOM. */
34
+ export const AUTOMATION_TYPE_DEFAULT = "CUSTOM";
35
+ /** Page kinds (Enum.PageType) — numeric value stored on a page. */
36
+ export const PAGE_TYPES = {
37
+ main: 1,
38
+ store: 2,
39
+ member: 3,
40
+ blog: 4,
41
+ custom: 5,
42
+ error: 6,
43
+ maintain: 7,
44
+ };
45
+ /** A compact human-readable summary, handy to surface in tool output. */
46
+ export const APP_TYPE_REFERENCE = APP_NAMES.map((n) => `${APP_TYPES[n]}=${n}`).join(", ");
package/dist/guides.js CHANGED
@@ -2,65 +2,127 @@ export const HTTP_FUNCTION_GUIDE = `
2
2
  # HTTP Function Guide
3
3
 
4
4
  ## Syntax
5
- export const [method]_[FunctionName] = (request) => { return result; }
6
- - Method: lowercase (get, post, put, patch, delete)
7
- - FunctionName: PascalCase
5
+ export const [method]_[FunctionName] = async (request) => { return result; }
6
+ - Method: lowercase (get, post, put, patch, delete) — picked from the export-name prefix.
7
+ - FunctionName: keep it stable; it becomes the endpoint name.
8
+ - Make it async; the return value is JSON-serialized and sent back to the caller.
8
9
  - Examples: get_Products, post_CreateOrder, delete_RemoveItem
9
10
 
10
- ## Request object
11
- - request.params — query params or body params
12
- - request.customer logged-in customer { id, name, email, first_name, last_name, phone_number, avatar }
13
- - request.account admin account { id, name, email, first_name, last_name, phone_number, avatar }
14
- - request.data full request params (including query string)
15
-
16
- ## API endpoint after deploy
17
- GET/POST/PUT/PATCH /api/v1/{site_id}/_functions/{FunctionName}
18
-
19
- ## webcake-data (Database SDK, built-in, no config needed)
11
+ ## The request argument
12
+ Your function is called with ONE object: { params, customer, site_id, account, data }.
13
+ - request.params the arguments the caller passed (query string for GET, body for POST/…).
14
+ - request.customer the logged-in storefront customer (when authenticated), else {}. Fields: id, name, email, first_name, last_name, phone_number, avatar.
15
+ - request.account the logged-in admin account (when called by an admin), else {}.
16
+ - request.site_id — the current site id (string).
17
+ - request.data — extra request data (usually {}).
18
+ IMPORTANT: pass request through to the @webcake/* module functions (their first arg).
19
+
20
+ ## Endpoint after deploy
21
+ ANY method → /api/v1/{site_id}/_functions/{FunctionName}
22
+ The caller receives your return value as data.result. From the storefront, the
23
+ webcake-fn client (api.method_FunctionName(params)) returns that result directly.
24
+
25
+ ## webcake-data — Database SDK (built-in)
20
26
  import { DBConnection } from 'webcake-data';
21
- const db = new DBConnection();
22
- const Model = db.model('table_name');
27
+ const db = new DBConnection(); // auto-uses the sandbox global site/token
28
+ const Model = db.model('collection_name');
23
29
 
24
- ### CRUD
25
- - Model.create({ field: value })
26
- - Model.insertMany([...])
27
- - Model.find(filter).sort().limit().skip().select().exec()
30
+ ### Model CRUD (all async unless noted)
31
+ - Model.create(doc) → created doc
32
+ - Model.insertMany([doc, ...]) → array
33
+ - Model.find(filter) → QueryBuilder (NOT a promise — chain then .exec()/await)
28
34
  - Model.findOne(filter, { select, sort, populate })
29
- - Model.findById(id)
30
- - Model.updateOne(filter, update)
31
- - Model.findByIdAndUpdate(id, update)
35
+ - Model.findById(id, { select, populate })
36
+ - Model.updateOne(filter, update) → { acknowledged, matchedCount, modifiedCount }
37
+ - Model.findByIdAndUpdate(id, update, { new: true })
38
+ - Model.findOneAndUpdate(filter, update)
32
39
  - Model.updateMany(filter, update)
33
- - Model.deleteOne(filter)
34
- - Model.findByIdAndDelete(id)
40
+ - Model.deleteOne(filter) → { acknowledged, deletedCount }
41
+ - Model.findByIdAndDelete(id) / Model.findOneAndDelete(filter)
35
42
  - Model.deleteMany(filter)
36
- - Model.countDocuments(filter)
37
- - Model.exists(filter)
38
-
39
- ### QueryBuilder
40
- Model.find().where('age').gte(25).lte(40).in('role', ['admin']).like('email', '%@ex.com').sort({ age: -1 }).limit(20).skip(10).select('name email').exec()
41
-
42
- ### Populate (joins)
43
- Model.find().populate({ field: 'posts', table: 'posts', referenceField: 'user_id', select: 'title', where: {}, sort: {}, limit: 5 }).exec()
44
-
45
- ### Operators
46
- where, eq, ne, gt, gte, lt, lte, in, nin, between, like, sort, limit, skip, select, populate
47
-
48
- ## Built-in Modules
49
- - import { findArticleById, findArticle, createArticle, updateArticleById, deleteArticleById } from '@webcake/article'
50
- - import { findCustomerById, findCustomerByPhone, findCustomerByEmail } from '@webcake/customer'
51
- - import { addBonus } from '@webcake/promotion'
52
- - import { getAccessToken } from '@webcake/token'
53
- - import { sendMail } from '@webcake/app/automation'
54
- All module functions take (request, ...args) and auto-use global token/site_id.
55
-
56
- ## Sandbox Globals (no import needed)
57
- - fetch(url, options) HTTP requests
58
- - URLSearchParams — URL query building
59
- - console.log/warn/error — logging (captured in debug mode)
43
+ - Model.countDocuments(filter) → number
44
+ - Model.exists(filter) → boolean
45
+
46
+ ### QueryBuilder (from Model.find())
47
+ Chain then terminate with .exec() (or just await the chain):
48
+ Model.find().where('age').gte(25).lte(40).in('role',['admin']).like('email','%@ex.com')
49
+ .sort({ age:-1 }).limit(20).skip(10).select('name email').exec()
50
+ Operators: where, eq, ne, gt, gte, lt, lte, in, nin, between, like, sort, limit, skip, select, populate.
51
+
52
+ ### Populate (join another collection)
53
+ Model.find().populate({
54
+ field:'posts', table:'posts', referenceField:'user_id',
55
+ select:'title', where:{}, sort:{ created_at:-1 }, limit:5, skip:0, justOne:false
56
+ }).exec()
57
+
58
+ ## Built-in @webcake/* modules (first arg is always request; they auth via global.token)
59
+ Thin wrappers over the backend's /cms_function/{site_id}/... endpoints. Pass request so
60
+ they pick up site_id. Below is EXACTLY what each call sends to the backend + what it returns.
61
+
62
+ - '@webcake/article' (backend: /cms_function/{site}/blog/article…)
63
+ findArticleById(request, id, opts?)
64
+ GET .../blog/article/{id}?opts=<json> → the article object ({} if not found)
65
+ findArticle(request, payload, opts?)
66
+ GET .../blog/article/all?<payload>&filters=<json>&opts=<json>
67
+ payload: { filters?:{...}, page?, limit? } → { data:[...], ... }
68
+ createArticle(request, data) POST .../blog/article → full response
69
+ data the backend accepts: { name (required), summary, content (HTML),
70
+ images: string[] (hosted URLs), tags: string[], status_approval,
71
+ render_inserted_at, render_expired_at }. slug is auto-generated; the article is
72
+ auto-filed under the default blog category (this endpoint takes NO category_id —
73
+ use the create_article MCP tool if you need explicit category linkage).
74
+ creator_id/customer_id come from the authenticated request.
75
+ updateArticleById(request, id, data) PATCH .../blog/article/{id} (same fields) → response
76
+ deleteArticleById(request, id) DELETE .../blog/article/{id} → response
77
+
78
+ - '@webcake/customer' (backend: /cms_function/{site}/customer/…) → customer object ({} if none)
79
+ findCustomerById(request, id) GET .../customer/identity/{id}
80
+ findCustomerByPhone(request, phone) GET .../customer/phone/{phone}
81
+ findCustomerByEmail(request, email) GET .../customer/email/{email}
82
+
83
+ - '@webcake/promotion' (backend: /cms_function/{site}/promotion/add_bonus)
84
+ addBonus(request, data) POST add_bonus → response.
85
+ It ADDS REWARD POINTS to a customer. data: { customer_id (required),
86
+ point (number, required), message? (defaults "Bạn được cộng điểm") }.
87
+
88
+ - '@webcake/token' (backend: /external/oauth/token)
89
+ getAccessToken(request) → access_token string (throws if none).
90
+ Needs request.x_storecake_refresh_token present (sent as x-storecake-refresh-token).
91
+
92
+ - '@webcake/app/automation' (backend: /cms_function/{site}/application/automation/send_mail)
93
+ sendMail(request, automationId, data) → response (throws if send fails).
94
+ Sends body { automation_id, data }. automation_id MUST be a valid UUID of an
95
+ automation set up on the site — find it with the MCP tool list_automations.
96
+ data is the payload passed into that automation/email template.
97
+
98
+ ## Sandbox globals (no import)
99
+ - fetch(url, options) — HTTP requests; response.ok/status/text()/json().
100
+ - URLSearchParams — build/parse query strings.
101
+ - console.log / warn / error — captured in debug mode (returned in data.logs).
102
+ - encodeURIComponent / decodeURIComponent / encodeURI / decodeURI
60
103
  - global.domain, global.siteId, global.token, global.headers
104
+ - Standard JS: JSON, Math, Date, Object, Array, String, Number, Map, Set, Promise, Error.
105
+ NOT available: require()/import at runtime, Buffer, crypto, fs, process, setTimeout/setInterval, eval/Function.
106
+
107
+ ## Limits
108
+ Runs sandboxed: ~4 MB memory, ~30 s timeout. The return value MUST be JSON-serializable.
61
109
 
62
- ## Cron Jobs (jobs_config JSON)
110
+ ## Cron jobs (jobs_config JSON)
63
111
  { "jobs": [{ "functionLocation": "backend/http_function", "functionName": "myFunc", "executionConfig": { "cronExpression": "0 2 * * *" } }] }
112
+
113
+ ## Example
114
+ import { DBConnection } from 'webcake-data';
115
+ import { findCustomerById } from '@webcake/customer';
116
+ export const post_RecentOrders = async (request) => {
117
+ const { params, customer } = request;
118
+ const db = new DBConnection();
119
+ const orders = await db.model('orders')
120
+ .find().where('customer_id').eq(customer.id || params.customer_id)
121
+ .sort({ created_at:-1 }).limit(10)
122
+ .populate({ field:'items', table:'order_items', referenceField:'order_id', limit:50 })
123
+ .exec();
124
+ return { count: orders.length, orders };
125
+ };
64
126
  `;
65
127
  export const CUSTOM_CODE_GUIDE = `
66
128
  # Custom Code Guide
@@ -0,0 +1,42 @@
1
+ // Image-upload cache (source URL/path → WebCake CDN URL), keyed per site.
2
+ //
3
+ // WHERE IT LIVES:
4
+ // - Remote (`serve`) with REDIS_URL set → Redis (shared across all requests/instances,
5
+ // survives container restarts/redeploys; this is the right place for multi-user mode).
6
+ // - stdio / npx / no Redis → a local JSON file (~/.webcake-storefront-mcp/image-cache.json)
7
+ // via db.ts. Single-user, persists on the user's machine.
8
+ // Redis is tried first; on any Redis miss/error we fall back to the file store, so the
9
+ // cache degrades gracefully and never blocks an upload.
10
+ import { getRedis } from "./redis.js";
11
+ import { getCachedUpload as fileGet, setCachedUpload as fileSet } from "../db.js";
12
+ const TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days — CDN urls are effectively permanent
13
+ const redisKey = (siteId, source) => `imgc:${siteId}:${source}`;
14
+ export async function getCachedUpload(siteId, source) {
15
+ const redis = getRedis();
16
+ if (redis) {
17
+ try {
18
+ const v = await redis.get(redisKey(siteId, source));
19
+ if (v)
20
+ return v;
21
+ }
22
+ catch {
23
+ /* fall through to the file store */
24
+ }
25
+ }
26
+ return fileGet(siteId, source);
27
+ }
28
+ export async function setCachedUpload(siteId, source, cdnUrl) {
29
+ if (!siteId || !source || !cdnUrl)
30
+ return;
31
+ const redis = getRedis();
32
+ if (redis) {
33
+ try {
34
+ await redis.set(redisKey(siteId, source), cdnUrl, "PX", TTL_MS);
35
+ return;
36
+ }
37
+ catch {
38
+ /* fall through to the file store */
39
+ }
40
+ }
41
+ fileSet(siteId, source, cdnUrl);
42
+ }
@@ -1,7 +1,40 @@
1
1
  import { z } from "zod";
2
+ import { APP_TYPES, APP_NAMES, APP_TYPE_REFERENCE, APP_NAME_BY_TYPE } from "../enums.js";
2
3
  export function registerAppTools(server, api, handle) {
3
- server.tool("list_apps", "List all installed applications/subscriptions of the site. Returns app type, status, and settings", {}, () => handle(() => api.listApps()));
4
- server.tool("get_app", "Get a specific installed app by type ID. Common types: 1=CMS, 2=Product Design, 10=Multilingual, etc.", {
5
- type: z.string().describe("App type ID"),
6
- }, ({ type }) => handle(() => api.getApp(type)));
4
+ server.tool("list_apps", `List the site's installed applications (type, status, settings).
5
+ App type codes: ${APP_TYPE_REFERENCE}.`, {}, () => handle(async () => {
6
+ const res = await api.listApps();
7
+ const raw = res?.data ?? res ?? [];
8
+ const list = Array.isArray(raw) ? raw : raw?.data || [];
9
+ return {
10
+ apps: list.map((a) => ({
11
+ type: a.type,
12
+ type_name: APP_NAME_BY_TYPE[a.type] ?? undefined,
13
+ status: a.status,
14
+ id: a.id,
15
+ })),
16
+ app_types: APP_TYPES,
17
+ };
18
+ }));
19
+ server.tool("get_app", `Get one installed app by its type code (returns null if not installed).
20
+ App type codes: ${APP_TYPE_REFERENCE}. You may pass the number or the name.`, {
21
+ type: z.union([z.number(), z.string()]).describe(`App type — a code (e.g. 2) or a name (e.g. "automation"). Codes: ${APP_TYPE_REFERENCE}`),
22
+ }, ({ type }) => handle(() => {
23
+ const code = typeof type === "string" && type in APP_TYPES ? APP_TYPES[type] : type;
24
+ return api.getApp(code);
25
+ }));
26
+ server.tool("install_app", `Install (register) an application on the current site so its features become usable.
27
+ For example, automations need the "automation" app installed first.
28
+ App types: ${APP_TYPE_REFERENCE}.`, {
29
+ app: z.enum(APP_NAMES).describe("App to install (by name)"),
30
+ is_active: z.boolean().default(true).describe("Activate the app on install"),
31
+ }, ({ app, is_active }) => handle(async () => {
32
+ const type = APP_TYPES[app];
33
+ const existing = await api.getApp(type).catch(() => null);
34
+ if (existing?.app ?? existing?.data) {
35
+ return { app, type, already_installed: true };
36
+ }
37
+ await api.registerApp(type, is_active);
38
+ return { app, type, installed: true };
39
+ }));
7
40
  }
@@ -1,8 +1,78 @@
1
1
  import { z } from "zod";
2
+ import { APP_TYPES } from "../enums.js";
2
3
  export function registerAutomationTools(server, api, handle) {
3
- server.tool("send_mail", "Send email via CMS automation", {
4
- to: z.string().describe("Recipient email"),
5
- subject: z.string().describe("Email subject"),
6
- body: z.string().describe("Email body (supports HTML)"),
7
- }, (params) => handle(() => api.sendMail(params)));
4
+ server.tool("list_automations", `List the site's automations so you can find an automation_id (e.g. to call send_mail, or to trigger from an HTTP function via @webcake/app/automation sendMail).
5
+ Returns each automation's id, name, status and trigger info. Filter with 'term'. The id is what send_mail / the cms automation flow needs.`, {
6
+ term: z.string().optional().describe("Search by automation name"),
7
+ page: z.number().default(1).describe("Page number"),
8
+ limit: z.number().default(50).describe("Items per page"),
9
+ }, ({ term, page, limit }) => handle(async () => {
10
+ const res = await api.listAutomations({ ...(term ? { term } : {}), page, limit });
11
+ // Response shape: { automations: { data:[...], total_entries, page, limit }, ... }
12
+ const body = res?.automations || res?.data || res;
13
+ const raw = body?.data || body || [];
14
+ const list = Array.isArray(raw) ? raw : [];
15
+ const automations = list.map((a) => ({
16
+ id: a.id,
17
+ name: a.name,
18
+ status: a.status ?? (a.is_completed ? "completed" : undefined),
19
+ is_completed: a.is_completed,
20
+ // surface the trigger so the agent can tell which automation does what
21
+ trigger: a.rule?.trigger?.triggerType || a.rule?.trigger?.triggerKey || a.trigger_type || undefined,
22
+ updated_at: a.updated_at,
23
+ }));
24
+ return {
25
+ automations,
26
+ total: body?.total_entries ?? automations.length,
27
+ page,
28
+ hint: "Pass an automation's id as send_mail.automation_id (or to sendMail(request, automationId, data) inside an HTTP function).",
29
+ };
30
+ }));
31
+ // Automation lives behind the "Automation" application (Enum.Application automation = 2).
32
+ // Creating an automation requires that app installed, so we ensure it first.
33
+ const AUTOMATION_APP = APP_TYPES.automation;
34
+ async function ensureAutomationApp() {
35
+ const res = await api.getApp(AUTOMATION_APP).catch(() => null);
36
+ const app = res?.app ?? res?.data ?? null;
37
+ if (app)
38
+ return { installed: true, just_installed: false };
39
+ await api.registerApp(AUTOMATION_APP, true);
40
+ return { installed: true, just_installed: true };
41
+ }
42
+ server.tool("create_automation", `Create an automation. Checks the Automation app is installed first and installs it if needed.
43
+ An automation = { name, type, status, rule }. 'rule' holds the trigger + actions (a map; shape depends on the trigger). Returns the new automation id (use it with send_mail for cms-triggered email).`, {
44
+ name: z.string().describe("Automation name"),
45
+ description: z.string().optional().describe("Description"),
46
+ type: z.string().default("CUSTOM").describe("Automation type (default CUSTOM)"),
47
+ status: z.enum(["ACTIVE", "INACTIVE"]).default("ACTIVE").describe("ACTIVE to run it, INACTIVE to keep it off"),
48
+ rule: z.record(z.any()).default({}).describe("Trigger + actions map. Leave {} for a blank automation you wire up later."),
49
+ }, ({ name, description, type, status, rule }) => handle(async () => {
50
+ const app = await ensureAutomationApp();
51
+ const res = await api.createAutomation({ name, ...(description ? { description } : {}), type, status, rule });
52
+ const a = res?.automation ?? res?.data ?? res;
53
+ return { success: true, automation_id: a?.id ?? null, name, status, app, hint: "Pass automation_id to send_mail to trigger it." };
54
+ }));
55
+ server.tool("update_automation", "Update an automation by id. Pass only the fields to change (name, description, type, status, rule).", {
56
+ id: z.string().describe("Automation id"),
57
+ name: z.string().optional(),
58
+ description: z.string().optional(),
59
+ type: z.string().optional(),
60
+ status: z.enum(["ACTIVE", "INACTIVE"]).optional(),
61
+ rule: z.record(z.any()).optional().describe("Replace the trigger + actions map"),
62
+ }, ({ id, ...fields }) => handle(async () => {
63
+ const res = await api.updateAutomation({ id, ...fields });
64
+ const a = res?.automation ?? res?.data ?? res;
65
+ return { success: true, automation_id: a?.id ?? id, updated: Object.keys(fields) };
66
+ }));
67
+ server.tool("delete_automation", "Delete one or more automations by id.", {
68
+ ids: z.array(z.string()).describe("Automation ids to delete"),
69
+ }, ({ ids }) => handle(async () => {
70
+ await api.deleteAutomations(ids);
71
+ return { success: true, deleted: ids };
72
+ }));
73
+ server.tool("send_mail", `Trigger a CMS automation to send an email (same endpoint @webcake/app/automation sendMail uses).
74
+ Requires the automation_id — get it from list_automations. 'data' is the payload passed to that automation/email template.`, {
75
+ automation_id: z.string().describe("Automation id (a UUID) — from list_automations"),
76
+ data: z.record(z.any()).default({}).describe("Data object passed into the automation (e.g. recipient, variables for the email template)"),
77
+ }, ({ automation_id, data }) => handle(() => api.sendMail({ automation_id, data })));
8
78
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { resolvePreviewUrl } from "../config.js";
3
+ import { getCachedUpload, setCachedUpload } from "../persistence/imageCache.js";
3
4
  import { parse as parseHtml } from "node-html-parser";
4
5
  import { stat, readFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
@@ -55,14 +56,49 @@ async function toAllowedImage(buf, contentType) {
55
56
  const out = await sharp(buf).jpeg({ quality: 85 }).toBuffer();
56
57
  return { buf: out, contentType: "image/jpeg" };
57
58
  }
59
+ /** Re-host one external image (http(s) URL or data: URI) on the WebCake CDN, with a
60
+ * per-site cache so the same source is never uploaded twice. Returns the hosted URL.
61
+ * The storefront only renders whitelisted (WebCake CDN) image domains, so every image
62
+ * used on a page/product MUST go through this — external URLs (Pexels, etc.) won't show. */
63
+ async function cdnUpload(api, source) {
64
+ const cached = await getCachedUpload(api.siteId, source);
65
+ if (cached)
66
+ return cached;
67
+ let buf, contentType;
68
+ if (source.startsWith("data:")) {
69
+ const m = source.match(/^data:([^;]+);base64,(.*)$/s);
70
+ if (!m)
71
+ throw new Error("Malformed data URI.");
72
+ contentType = m[1];
73
+ buf = Buffer.from(m[2], "base64");
74
+ }
75
+ else {
76
+ ({ buf, contentType } = await fetchBuffer(source));
77
+ }
78
+ const norm = await toAllowedImage(buf, contentType);
79
+ const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
80
+ const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
81
+ const hosted = (res && res.data) || (res && res.url) || null;
82
+ if (!hosted)
83
+ throw new Error("Upload returned no URL.");
84
+ await setCachedUpload(api.siteId, source, hosted);
85
+ return hosted;
86
+ }
58
87
  export function registerBuilderExtraTools(server, api, handle, opts = {}) {
59
88
  // ── Stock images (Pexels) ──────────────────────────────────────────────────
60
- server.tool("search_images", `Search stock photos (Pexels) to use on a page. Returns hosted image URLs you can put straight into an image element's runtime.config.src.
89
+ server.tool("search_images", `Search stock photos (Pexels) for a page/product. IMPORTANT: the storefront only renders
90
+ images served from the WebCake CDN (image domains are whitelisted) — raw Pexels URLs will
91
+ NOT display. By default this re-hosts each result on the WebCake CDN and returns a ready-to-use
92
+ cdn_url (cached, so repeats are free). Use cdn_url for image src / product images.
61
93
  Requires the PEXELS_API_KEY environment variable.`, {
62
94
  query: z.string().describe("Subject to search, e.g. 'coffee shop interior'"),
63
95
  per_page: z.number().min(1).max(30).default(6).describe("How many results (default 6)"),
64
96
  orientation: z.enum(["landscape", "portrait", "square"]).optional().describe("Preferred orientation"),
65
- }, ({ query, per_page, orientation }) => handle(async () => {
97
+ upload: z
98
+ .boolean()
99
+ .default(true)
100
+ .describe("Re-host each result on the WebCake CDN and return cdn_url (default true — required for the image to show). Set false to only browse Pexels URLs."),
101
+ }, ({ query, per_page, orientation, upload }) => handle(async () => {
66
102
  const key = process.env.PEXELS_API_KEY;
67
103
  if (!key)
68
104
  return { error: "PEXELS_API_KEY env var is not set. Add it to use stock image search." };
@@ -75,7 +111,7 @@ Requires the PEXELS_API_KEY environment variable.`, {
75
111
  if (!res.ok)
76
112
  return { error: `Pexels error ${res.status}` };
77
113
  const json = await res.json();
78
- const photos = (json.photos || []).map((p) => ({
114
+ let photos = (json.photos || []).map((p) => ({
79
115
  url: p.src && (p.src.large || p.src.original),
80
116
  thumbnail: p.src && p.src.medium,
81
117
  width: p.width,
@@ -84,10 +120,28 @@ Requires the PEXELS_API_KEY environment variable.`, {
84
120
  credit: p.photographer,
85
121
  source: p.url,
86
122
  }));
87
- return { query, total_results: json.total_results, photos };
123
+ if (upload) {
124
+ photos = await Promise.all(photos.map(async (ph) => {
125
+ try {
126
+ return { ...ph, cdn_url: await cdnUpload(api, ph.url) };
127
+ }
128
+ catch (e) {
129
+ return { ...ph, cdn_url: null, upload_error: e?.message ?? String(e) };
130
+ }
131
+ }));
132
+ }
133
+ return {
134
+ query,
135
+ total_results: json.total_results,
136
+ uploaded_to_cdn: upload,
137
+ note: upload
138
+ ? "Use each photo's cdn_url (WebCake-hosted) for image src / product images — NOT url (Pexels, not whitelisted)."
139
+ : "These are Pexels URLs and will NOT render on the storefront. Run upload_images (or upload:true) to re-host them on the CDN first.",
140
+ photos,
141
+ };
88
142
  }));
89
143
  // ── Upload images to the site CDN ───────────────────────────────────────────
90
- server.tool("upload_images", `Convert external image URLs, data: URIs, or LOCAL FILE PATHS into site-hosted CDN URLs by reading/downloading each image and re-uploading it to the WebCake backend. Use this whenever the user supplies their OWN images (their URLs or files from their machine), or a page is built from a reference HTML/URL. The returned hosted URLs go straight into an image element's specials.src / runtime.config.src same as search_images results. Stock photos from search_images are already hosted and don't need uploading.
144
+ server.tool("upload_images", `Convert external image URLs, data: URIs, or LOCAL FILE PATHS into site-hosted CDN URLs by reading/downloading each image and re-uploading it to the WebCake backend. Use this whenever the user supplies their OWN images (their URLs or files from their machine), or a page is built from a reference HTML/URL. The returned CDN URLs go straight into an image element's specials.src / runtime.config.src, or a product/category image. This is REQUIRED for any external image (incl. Pexels search results) because the storefront only renders whitelisted WebCake-CDN image domains. Results are cached per site, so re-uploading the same source is free.
91
145
  Processes up to 20 entries per call in parallel; non jpeg/png/webp inputs are converted to JPEG. UPLOADS BY DEFAULT (dry_run defaults to FALSE — this touches no account data): returns an "images" map (original source → hosted URL). Pass dry_run:true to only preview the entries that WOULD be processed (local paths report whether the file exists + its size) without any network/filesystem upload. Local file paths are only permitted when the MCP server runs locally (stdio); on the remote HTTP transport they are rejected per-entry.`, {
92
146
  urls: z
93
147
  .array(z.string())
@@ -124,29 +178,28 @@ Processes up to 20 entries per call in parallel; non jpeg/png/webp inputs are co
124
178
  const errors = [];
125
179
  await Promise.all(deduped.map(async (entry) => {
126
180
  try {
127
- let buf, contentType;
128
- if (entry.startsWith("data:")) {
129
- const m = entry.match(/^data:([^;]+);base64,(.*)$/s);
130
- if (!m)
131
- throw new Error("Malformed data URI.");
132
- contentType = m[1];
133
- buf = Buffer.from(m[2], "base64");
134
- }
135
- else if (isLocalPath(entry)) {
181
+ if (isLocalPath(entry)) {
136
182
  if (!localAllowed)
137
183
  throw new Error("Local file paths are only supported when the server runs locally (stdio). Send a public URL or data: URI instead.");
138
- ({ buf, contentType } = await readLocalImage(entry));
184
+ const cached = await getCachedUpload(api.siteId, entry);
185
+ if (cached) {
186
+ images[entry] = cached;
187
+ return;
188
+ }
189
+ const { buf, contentType } = await readLocalImage(entry);
190
+ const norm = await toAllowedImage(buf, contentType);
191
+ const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
192
+ const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
193
+ const hosted = (res && res.data) || (res && res.url) || null;
194
+ if (!hosted)
195
+ throw new Error("Upload returned no URL.");
196
+ await setCachedUpload(api.siteId, entry, hosted);
197
+ images[entry] = hosted;
139
198
  }
140
199
  else {
141
- ({ buf, contentType } = await fetchBuffer(entry));
200
+ // http(s) URL or data: URI cdnUpload handles fetch/convert/upload + cache.
201
+ images[entry] = await cdnUpload(api, entry);
142
202
  }
143
- const norm = await toAllowedImage(buf, contentType);
144
- const dataUri = `data:${norm.contentType};base64,${norm.buf.toString("base64")}`;
145
- const res = await api.uploadImageBase64({ base64: dataUri, content_type: norm.contentType });
146
- const hosted = (res && res.data) || (res && res.url) || null;
147
- if (!hosted)
148
- throw new Error("Upload returned no URL.");
149
- images[entry] = hosted;
150
203
  }
151
204
  catch (e) {
152
205
  errors.push({ url: entry, error: e?.message ?? String(e) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-storefront-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "MCP server for the WebCake/StoreCake storefront builder — page CRUD, page authoring, products, orders, and more",
5
5
  "mcpName": "io.github.vuluu2k/webcake-storefront-mcp",
6
6
  "license": "MIT",