webcake-landing-mcp 1.0.32 → 1.0.34

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/README.md CHANGED
@@ -447,6 +447,11 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
447
447
  | `new_page_skeleton` | An empty but complete top-level source `{ page, popup, settings, options, cartConfigs }`. |
448
448
  | `validate_page` | Structural + semantic validation (ids, event targets, containers, `field_name`). |
449
449
 
450
+ ### Media (works out of the box; optional Pexels key)
451
+ | Tool | Description |
452
+ |------|-------------|
453
+ | `search_images` | Find REAL stock photos (Pexels) for a page — returns hotlinkable URLs at several sizes to drop into an image element's `specials.src`. Works with **no setup** (a shared hosted proxy supplies images); set `PEXELS_API_KEY` env or the `x-pexels-key` header to use your own [free Pexels key](https://www.pexels.com/api/) / quota. |
454
+
450
455
  ### Persistence (needs `WEBCAKE_API_BASE` + `WEBCAKE_JWT`)
451
456
  | Tool | Description |
452
457
  |------|-------------|
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.0.34",
4
+ "d": "08/06/2026",
5
+ "type": "Added",
6
+ "en": "New search_images tool queries Pexels stock photos by short English subject and returns ready-to-hotlink URLs at several sizes; use src.large for…",
7
+ "vi": "Công cụ search_images mới truy vấn ảnh stock Pexels theo chủ đề tiếng Anh ngắn gọn và trả về các URL sẵn sàng hotlink ở nhiều kích thước; dùng…"
8
+ },
9
+ {
10
+ "v": "1.0.33",
11
+ "d": "08/06/2026",
12
+ "type": "Fixed",
13
+ "en": "get_element for country-select now marks specials.field_placeholder as required (the renderer crashes without it), and the element seed now emits a…",
14
+ "vi": "get_element cho country-select nay đánh dấu specials.field_placeholder là bắt buộc (renderer sẽ crash nếu thiếu trường này), và seed phần tử nay…"
15
+ },
2
16
  {
3
17
  "v": "1.0.32",
4
18
  "d": "08/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Fixed",
27
41
  "en": "The GET / guide page now takes full control of scroll restoration across reloads: native browser scroll restoration is disabled in the <head> script…",
28
42
  "vi": "Trang hướng dẫn GET / nay kiểm soát hoàn toàn việc khôi phục vị trí cuộn sau reload: tính năng scroll restoration tự nhiên của trình duyệt bị tắt…"
29
- },
30
- {
31
- "v": "1.0.28",
32
- "d": "08/06/2026",
33
- "type": "Fixed",
34
- "en": "The GET / guide page no longer animates the browser's scroll-position restoration on reload; smooth scrolling is now enabled one animation frame…",
35
- "vi": "Trang hướng dẫn GET / không còn tạo hiệu ứng cuộn cho quá trình trình duyệt khôi phục vị trí cuộn khi tải lại trang; smooth scrolling nay chỉ được…"
36
- },
37
- {
38
- "v": "1.0.27",
39
- "d": "08/06/2026",
40
- "type": "Fixed",
41
- "en": "The HTTP server's GET / route now serves the full HTML guide page to social and search crawlers (Facebook, Zalo, Twitter/X, LinkedIn, Slack,…",
42
- "vi": "Route GET / của HTTP server nay phục vụ trang hướng dẫn HTML đầy đủ cho các crawler mạng xã hội và công cụ tìm kiếm (Facebook, Zalo, Twitter/X,…"
43
43
  }
44
44
  ]
@@ -224,6 +224,7 @@ export const FORM = [
224
224
  useWhen: "International forms.",
225
225
  keySpecials: {
226
226
  field_name: "REQUIRED unique data key.",
227
+ field_placeholder: "REQUIRED — the disabled first-option label. Key is `field_placeholder`, NOT `placeholder`. The country-select renderer crashes if this is missing.",
227
228
  countries: "array of country dial-prefix codes (e.g. ['84','1','65']) shown in the dropdown and used to preload address data.",
228
229
  autofill_phone: "boolean — listen to sibling phone_number inputs to auto-select the country by dial prefix and auto-prepend the dial code.",
229
230
  },
@@ -231,6 +232,7 @@ export const FORM = [
231
232
  seedPosition(el);
232
233
  setBox(el, 150, 36);
233
234
  el.specials.field_name = `country_select_${el.id}`;
235
+ el.specials.field_placeholder = "Quốc gia";
234
236
  },
235
237
  },
236
238
  {
@@ -322,7 +324,7 @@ export const FORM = [
322
324
  useWhen: "Child of group-select only.",
323
325
  keySpecials: {
324
326
  field_name: "REQUIRED unique data key (becomes 'quantity' when field_quantity=true).",
325
- field_placeholder: "string — the item's visible label/placeholder (the editor seeds 'AttrName' for attribute items, 'Quantity' for the quantity item).",
327
+ field_placeholder: "REQUIRED string — the disabled first-option label (the editor seeds 'AttrName' for attribute items, 'Quantity' for the quantity item). Key is `field_placeholder`, NOT `placeholder`. The renderer crashes if this is missing.",
326
328
  field_quantity: "boolean — when true this item is the quantity selector (value goes to parent._setQuantity), not a product attribute. Only one item per group.",
327
329
  attrName: "string — the product attribute name this item maps to (e.g. 'Color','Size'); numeric strings ('1','2') index product_attributes for custom products; 'sprod-name'/'sprod-sku' show the product name/SKU.",
328
330
  options: "array [{id,name,value}] — STATIC option list used by the quantity item (field_quantity=true), e.g. 1..4; attribute items leave this empty and populate from the catalog at runtime.",
@@ -331,6 +333,7 @@ export const FORM = [
331
333
  },
332
334
  seed: (el) => {
333
335
  el.specials.field_name = `gs_${el.id}`;
336
+ el.specials.field_placeholder = "Chọn...";
334
337
  },
335
338
  },
336
339
  ];
@@ -20,6 +20,6 @@ MODEL (essentials):
20
20
  - CENTERING (the #1 layout defect — do the math, don't eyeball): to center a box compute left = round((canvas - width)/2) — 960 desktop, 420 mobile. textAlign:center only centers text inside the box, not the box itself. For a row of N items, center the whole row block (startLeft = round((canvas - (N*item + (N-1)*gap))/2)). Keep 0 ≤ left and left+width ≤ canvas on each breakpoint.
21
21
  - STICKY HEADER: a sticky/fixed header (config.sticky) OVERLAYS the page — it does NOT push sections below it down. Offset the first section's top content DOWN by the header height (~60–72px) so nothing hides behind it, and do NOT duplicate the shop name in both the header and the top of the hero. A non-sticky header stacks normally and needs no offset.
22
22
  - Visible content lives in specials (text, src, field_name…), never in styles. Colors as rgba(). Animation in config.animation={name,delay,duration,repeat}. Form inputs need a unique specials.field_name (use canonical keys: full_name, phone_number, email, address, quantity).
23
- - IMAGES: include them (hero/product, feature icons, about photo). No image API yet set image-block specials.src to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height> (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
23
+ - IMAGES: include them (hero/product, feature icons, about photo). PREFER REAL PHOTOS call search_images with a short English subject (e.g. 'fresh coffee cup') and put a returned URL (src.large for a hero/banner, src.medium for a card/thumb) into the image-block specials.src; it works out of the box (a shared proxy supplies images). Only if search_images returns ok:false, FALL BACK to a PLACEHOLDER sized to the box: https://placehold.co/<width>x<height>. (gallery.media = array of OBJECTS {type:'image',link:'<url>',linkVideo:'',typeVideo:'youtube',imageCompression:true} — NOT plain strings, the gallery reads item.link; video.specials.img = poster). NEVER leave src empty (renders blank). Ensure text contrasts with its section background.
24
24
 
25
- Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, list_organizations, create_page, list_pages, get_page, update_page.`;
25
+ Start by calling get_generation_guide. Tools: get_generation_guide, list_elements, get_element, new_element, new_page_skeleton, get_page_schema, validate_page, search_images, list_organizations, create_page, list_pages, get_page, update_page.`;
@@ -31,6 +31,11 @@ const TOP_LEVEL_TYPES = new Set(["section", "dynamic_page", "popup"]);
31
31
  // it; select shows a blank option). The correct option shape is {id, name} — NOT
32
32
  // the HTML-style {label, value}.
33
33
  const OPTION_NAME_FIELDS = new Set(["select", "radio", "checkbox-group"]);
34
+ // Fields whose renderer ALWAYS emits unescapeHTML(field_placeholder) with NO
35
+ // default (renderSelect / renderCountrySelect / renderGroupSelectItem) — a missing
36
+ // field_placeholder throws "Cannot read properties of undefined (reading 'replace')"
37
+ // and the whole page fails to render.
38
+ const PLACEHOLDER_REQUIRED_FIELDS = new Set(["select", "country-select", "group-select-item"]);
34
39
  // Fixed canvas reference (matches vocab CANVAS) used for the layout/bounds check.
35
40
  const CANVAS_DESKTOP = 960;
36
41
  const CANVAS_MOBILE = 420;
@@ -126,8 +131,8 @@ export function validatePage(input) {
126
131
  if (typeof specials.placeholder === "string" && !hasFieldPlaceholder) {
127
132
  warnings.push(`${path} (${type}): uses specials.placeholder — the renderer reads specials.field_placeholder. Rename "placeholder" → "field_placeholder".`);
128
133
  }
129
- if (type === "select" && !hasFieldPlaceholder) {
130
- errors.push(`${path} (select): needs a string specials.field_placeholder (the select renderer crashes without it).`);
134
+ if (PLACEHOLDER_REQUIRED_FIELDS.has(type) && !hasFieldPlaceholder) {
135
+ errors.push(`${path} (${type}): needs a string specials.field_placeholder (this element's renderer crashes without it).`);
131
136
  }
132
137
  }
133
138
  // select/radio/checkbox-group render each option from option.name; a missing
package/dist/env.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Tiny zero-dependency `.env` loader. We publish to npm, so every dependency
3
+ * ships to users — instead of pulling in `dotenv`, parse a `.env` ourselves.
4
+ *
5
+ * Looks for a `.env` in the current working directory first, then next to the
6
+ * running binary (dist/), so it works both for local dev and a deployed server.
7
+ * Only sets keys that are NOT already in process.env — real env vars and the
8
+ * per-request HTTP headers always win over the file. stdout is the MCP channel,
9
+ * so any parse note goes to stderr.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ function parseEnv(text) {
15
+ const out = {};
16
+ for (const rawLine of text.split(/\r?\n/)) {
17
+ const line = rawLine.trim();
18
+ if (!line || line.startsWith("#"))
19
+ continue;
20
+ const eq = line.indexOf("=");
21
+ if (eq === -1)
22
+ continue;
23
+ const key = line.slice(0, eq).trim();
24
+ if (!key)
25
+ continue;
26
+ let val = line.slice(eq + 1).trim();
27
+ // Strip a single surrounding pair of quotes, if present.
28
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
29
+ val = val.slice(1, -1);
30
+ }
31
+ out[key] = val;
32
+ }
33
+ return out;
34
+ }
35
+ /** Candidate `.env` locations, in priority order (cwd, then the binary's dir + parent). */
36
+ function candidatePaths() {
37
+ const here = dirname(fileURLToPath(import.meta.url)); // dist/ at runtime
38
+ return [join(process.cwd(), ".env"), join(here, ".env"), join(here, "..", ".env")];
39
+ }
40
+ /** Load the first readable `.env`, assigning only keys absent from process.env. */
41
+ export function loadDotenv() {
42
+ const seen = new Set();
43
+ for (const path of candidatePaths()) {
44
+ let text;
45
+ try {
46
+ text = readFileSync(path, "utf8");
47
+ }
48
+ catch {
49
+ continue; // no file here — try the next candidate
50
+ }
51
+ for (const [k, v] of Object.entries(parseEnv(text))) {
52
+ if (seen.has(k))
53
+ continue;
54
+ seen.add(k);
55
+ if (process.env[k] === undefined)
56
+ process.env[k] = v;
57
+ }
58
+ }
59
+ }
package/dist/http.js CHANGED
@@ -19,7 +19,9 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
19
19
  import { createServer } from "./server.js";
20
20
  import { ICON_SVG, ICON_MIME } from "./branding.js";
21
21
  import { guideHtml, ogImageSvg, normalizeLang } from "./web-guide.js";
22
+ import { searchPexels, resolvePexelsKey } from "./persistence/pexels-client.js";
22
23
  const MCP_PATH = "/mcp";
24
+ const IMAGES_PATH = "/api/images/search";
23
25
  // Social/search crawlers (Facebook, Zalo, Twitter/X, LinkedIn, Slack, Telegram,
24
26
  // WhatsApp, Discord, Google, Bing…) fetch the root with `Accept: */*` rather than
25
27
  // `text/html`, so they'd otherwise get the JSON health blob and never see the OG
@@ -86,6 +88,42 @@ function applyQueryAuth(req) {
86
88
  }
87
89
  }
88
90
  }
91
+ /**
92
+ * Shared image proxy: GET /api/images/search?query=…&per_page=…&orientation=…
93
+ * Holds the server's own PEXELS_API_KEY (from env/.env) and returns the normalized
94
+ * search result, so `npx` clients without a key get images via this host. CORS is
95
+ * permissive so a browser can call it too; the key is never sent to the client.
96
+ */
97
+ async function handleImageSearch(req, res) {
98
+ const cors = { "access-control-allow-origin": "*", "access-control-allow-headers": "*" };
99
+ if (req.method === "OPTIONS") {
100
+ res.writeHead(204, cors);
101
+ return res.end();
102
+ }
103
+ const sendImgJson = (status, body) => {
104
+ res.writeHead(status, { "content-type": "application/json", ...cors });
105
+ res.end(JSON.stringify(body));
106
+ };
107
+ const key = resolvePexelsKey();
108
+ if (!key) {
109
+ return sendImgJson(503, { ok: false, reason: "proxy_no_key", error: "Image proxy has no PEXELS_API_KEY configured." });
110
+ }
111
+ const sp = new URL(req.url ?? "/", "http://x").searchParams;
112
+ const query = sp.get("query")?.trim();
113
+ if (!query) {
114
+ return sendImgJson(400, { ok: false, reason: "missing_query", error: "Pass ?query=<subject>." });
115
+ }
116
+ const params = {
117
+ query,
118
+ perPage: sp.get("per_page") ? Number(sp.get("per_page")) : undefined,
119
+ page: sp.get("page") ? Number(sp.get("page")) : undefined,
120
+ orientation: sp.get("orientation") ?? undefined,
121
+ size: sp.get("size") ?? undefined,
122
+ color: sp.get("color") ?? undefined,
123
+ };
124
+ const result = await searchPexels(key, params);
125
+ return sendImgJson(result.ok ? 200 : result.status || 502, result);
126
+ }
89
127
  export async function startHttpServer(port) {
90
128
  // mcp-session-id -> live transport (each bound to its own McpServer instance).
91
129
  const transports = new Map();
@@ -134,6 +172,9 @@ export async function startHttpServer(port) {
134
172
  }
135
173
  return sendJson(res, 200, { ok: true, server: "webcake-landing", transport: "streamable-http", endpoint: MCP_PATH });
136
174
  }
175
+ // Shared image proxy (for `npx` clients without their own Pexels key).
176
+ if (path === IMAGES_PATH)
177
+ return handleImageSearch(req, res);
137
178
  if (path !== MCP_PATH)
138
179
  return rpcError(res, 404, `Not found. Send MCP requests to ${MCP_PATH}.`);
139
180
  // Accept credentials via ?jwt=/?api_base=/... (for clients that can't set headers).
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { createServer } from "./server.js";
20
+ import { loadDotenv } from "./env.js";
20
21
  import { ENVIRONMENTS, ENV_NAMES, isEnvName } from "./persistence/config.js";
21
22
  /**
22
23
  * Global `--env <local|staging|prod>` flag (or `--env=<name>`): selects the API +
@@ -72,6 +73,9 @@ function printHelp() {
72
73
  ].join("\n"));
73
74
  }
74
75
  async function main() {
76
+ // Load `.env` (if any) before anything reads process.env — real env vars and
77
+ // per-request headers still win over the file.
78
+ loadDotenv();
75
79
  // Resolve the named environment (--env / WEBCAKE_ENV) before any config is read.
76
80
  applyEnvFlag(process.argv);
77
81
  // Subcommand dispatch: `webcake-landing-mcp install|uninstall` runs the
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Thin HTTP client to the Pexels stock-photo API (https://www.pexels.com/api/).
3
+ * Lets the agent fetch REAL, relevant images for a landing page instead of the
4
+ * grey https://placehold.co/ placeholders — the returned URLs drop straight into
5
+ * an image element's `specials.src` (or a gallery item's `link`).
6
+ *
7
+ * The API key is a SECRET resolved exactly like the Webcake JWT (never hard-coded,
8
+ * the repo is public): the `PEXELS_API_KEY` env var (stdio / single-user), or the
9
+ * `x-pexels-key` header per request (remote / multi-user). Pexels allows hotlinking
10
+ * its image URLs as long as attribution is shown — `normalizePhoto` carries the
11
+ * photographer + page url so the agent can credit the source. Requires global
12
+ * fetch (Node 18+). `normalizePhoto` is a pure function so it can be smoke-tested.
13
+ *
14
+ * SHARED PROXY: when no key is configured locally (e.g. a plain `npx` user),
15
+ * `searchImagesViaProxy` calls a hosted endpoint (GET <base>/api/images/search,
16
+ * default https://mcp.toolvn.io.vn) that holds a shared key and returns the SAME
17
+ * normalized shape — so images work out of the box with zero setup. The proxy
18
+ * route is served by this same server in `serve` mode (see src/http.ts).
19
+ */
20
+ const PEXELS_SEARCH_ENDPOINT = "https://api.pexels.com/v1/search";
21
+ /** Default hosted proxy that holds a shared Pexels key (override with PEXELS_PROXY_BASE). */
22
+ export const PEXELS_PROXY_DEFAULT = "https://mcp.toolvn.io.vn";
23
+ /** Path of the proxy's image-search route (served by this server in `serve` mode). */
24
+ export const PEXELS_PROXY_PATH = "/api/images/search";
25
+ /** Resolve the Pexels API key: per-request override (header) wins over env. */
26
+ export function resolvePexelsKey(override) {
27
+ const key = override ?? process.env.PEXELS_API_KEY;
28
+ return key && key.trim() !== "" ? key.trim() : undefined;
29
+ }
30
+ /** Resolve the shared-proxy base URL: override → PEXELS_PROXY_BASE env → the default host. */
31
+ export function resolvePexelsProxyBase(override) {
32
+ const base = override ?? process.env.PEXELS_PROXY_BASE ?? PEXELS_PROXY_DEFAULT;
33
+ return base.replace(/\/+$/, "");
34
+ }
35
+ /** Pull the `x-pexels-key` header (remote/multi-user mode), if present. */
36
+ export function pexelsKeyFromHeaders(headers) {
37
+ const v = headers?.["x-pexels-key"];
38
+ const raw = Array.isArray(v) ? v[0] : v;
39
+ return raw && raw.trim() !== "" ? raw.trim() : undefined;
40
+ }
41
+ /** Map one raw Pexels API photo onto the trimmed PexelsPhoto shape (pure). */
42
+ export function normalizePhoto(p) {
43
+ return {
44
+ id: p?.id,
45
+ alt: typeof p?.alt === "string" ? p.alt : "",
46
+ width: p?.width,
47
+ height: p?.height,
48
+ avg_color: typeof p?.avg_color === "string" ? p.avg_color : null,
49
+ photographer: p?.photographer ?? "",
50
+ photographer_url: p?.photographer_url ?? "",
51
+ pexels_url: p?.url ?? "",
52
+ src: p?.src && typeof p.src === "object" ? p.src : {},
53
+ };
54
+ }
55
+ /** Build the shared query string for both the direct Pexels call and the proxy. */
56
+ export function buildSearchQuery(params) {
57
+ const q = new URLSearchParams();
58
+ q.set("query", params.query);
59
+ q.set("per_page", String(Math.min(Math.max(params.perPage ?? 5, 1), 80)));
60
+ if (params.page)
61
+ q.set("page", String(params.page));
62
+ if (params.orientation)
63
+ q.set("orientation", params.orientation);
64
+ if (params.size)
65
+ q.set("size", params.size);
66
+ if (params.color)
67
+ q.set("color", params.color);
68
+ return q;
69
+ }
70
+ /** Search Pexels DIRECTLY (needs a key). Returns normalized, hotlinkable URLs. */
71
+ export async function searchPexels(key, params) {
72
+ const url = `${PEXELS_SEARCH_ENDPOINT}?${buildSearchQuery(params).toString()}`;
73
+ let res;
74
+ try {
75
+ res = await fetch(url, { method: "GET", headers: { Authorization: key } });
76
+ }
77
+ catch (e) {
78
+ return { ok: false, status: 0, error: `Network error calling Pexels: ${e?.message ?? e}` };
79
+ }
80
+ const body = await res.text();
81
+ let json = null;
82
+ try {
83
+ json = JSON.parse(body);
84
+ }
85
+ catch {
86
+ /* non-JSON */
87
+ }
88
+ if (!res.ok) {
89
+ const msg = json?.error ?? body.slice(0, 200);
90
+ const hint = res.status === 401 ? " (check PEXELS_API_KEY / x-pexels-key — it may be invalid)" : "";
91
+ return { ok: false, status: res.status, error: `Pexels returned ${res.status}${msg ? `: ${msg}` : ""}${hint}` };
92
+ }
93
+ const photos = (json?.photos ?? []).map(normalizePhoto);
94
+ return { ok: true, status: res.status, query: params.query, total_results: json?.total_results, photos };
95
+ }
96
+ /**
97
+ * Search via the SHARED PROXY (no local key needed): GET <base>/api/images/search.
98
+ * The proxy returns the same normalized shape — we re-`normalizePhoto` defensively
99
+ * so a slightly different payload still yields the page-builder fields.
100
+ */
101
+ export async function searchImagesViaProxy(base, params) {
102
+ const url = `${base.replace(/\/+$/, "")}${PEXELS_PROXY_PATH}?${buildSearchQuery(params).toString()}`;
103
+ let res;
104
+ try {
105
+ res = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
106
+ }
107
+ catch (e) {
108
+ return { ok: false, status: 0, error: `Network error calling image proxy ${base}: ${e?.message ?? e}` };
109
+ }
110
+ const body = await res.text();
111
+ let json = null;
112
+ try {
113
+ json = JSON.parse(body);
114
+ }
115
+ catch {
116
+ /* non-JSON */
117
+ }
118
+ if (!res.ok) {
119
+ const msg = json?.error ?? json?.reason ?? body.slice(0, 200);
120
+ return { ok: false, status: res.status, error: `Image proxy returned ${res.status}${msg ? `: ${msg}` : ""}` };
121
+ }
122
+ const photos = (json?.photos ?? []).map(normalizePhoto);
123
+ return { ok: json?.ok !== false, status: res.status, query: params.query, total_results: json?.total_results, photos, via: "proxy" };
124
+ }
package/dist/smoke.js CHANGED
@@ -6,6 +6,7 @@ import { createElement, CONTAINER_TYPES, FIELD_TYPES, LIBRARY, ELEMENT_TYPES, EL
6
6
  import { validatePage, pageSchema } from "./domains/landing/validate.js";
7
7
  import { readConfig, resolveEnv, ENV_NAMES } from "./persistence/config.js";
8
8
  import { toEditorUrl } from "./persistence/webcake-client.js";
9
+ import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
9
10
  let failures = 0;
10
11
  const check = (name, cond, extra) => {
11
12
  if (cond) {
@@ -240,5 +241,36 @@ console.log("== config: named environment presets (local/staging/prod) ==");
240
241
  check("editor url from an absolute api url → builder host", toEditorUrl(localCfg, "http://localhost:5800/editor/v2/abc?x=1") === "http://builder.localhost:5800/editor/v2/abc?x=1");
241
242
  check("editor url passthrough when empty", toEditorUrl(localCfg, undefined) === undefined);
242
243
  }
244
+ console.log("== pexels: key resolution + photo normalization (offline, no network) ==");
245
+ {
246
+ for (const k of ["PEXELS_API_KEY"])
247
+ delete process.env[k];
248
+ check("no key → undefined", resolvePexelsKey() === undefined);
249
+ check("header key wins (override)", resolvePexelsKey("hdr-key") === "hdr-key");
250
+ process.env.PEXELS_API_KEY = " env-key ";
251
+ check("env key is read + trimmed", resolvePexelsKey() === "env-key");
252
+ delete process.env.PEXELS_API_KEY;
253
+ check("x-pexels-key header parsed", pexelsKeyFromHeaders({ "x-pexels-key": "abc" }) === "abc");
254
+ check("array header takes first", pexelsKeyFromHeaders({ "x-pexels-key": ["a", "b"] }) === "a");
255
+ check("absent header → undefined", pexelsKeyFromHeaders({}) === undefined);
256
+ const photo = normalizePhoto({
257
+ id: 42, alt: "a cat", width: 1200, height: 800, avg_color: "#446688",
258
+ photographer: "Jane", photographer_url: "https://pexels.com/@jane", url: "https://pexels.com/photo/42",
259
+ src: { large: "https://images.pexels.com/large.jpg", medium: "https://images.pexels.com/medium.jpg" },
260
+ });
261
+ check("normalizePhoto keeps the page-builder fields", photo.id === 42 && photo.alt === "a cat" && photo.avg_color === "#446688" && photo.src.large.includes("large.jpg") && photo.pexels_url.endsWith("/42"));
262
+ const sparse = normalizePhoto({ id: 1 });
263
+ check("normalizePhoto tolerates missing fields", sparse.alt === "" && sparse.avg_color === null && typeof sparse.src === "object");
264
+ // shared proxy fallback (used when no local key)
265
+ delete process.env.PEXELS_PROXY_BASE;
266
+ check("proxy base defaults to the hosted host", resolvePexelsProxyBase() === PEXELS_PROXY_DEFAULT);
267
+ check("proxy base override wins + trailing slash trimmed", resolvePexelsProxyBase("https://x.test/") === "https://x.test");
268
+ process.env.PEXELS_PROXY_BASE = "https://env.test";
269
+ check("proxy base read from PEXELS_PROXY_BASE env", resolvePexelsProxyBase() === "https://env.test");
270
+ delete process.env.PEXELS_PROXY_BASE;
271
+ const q = buildSearchQuery({ query: "coffee cup", perPage: 3, orientation: "landscape" });
272
+ check("buildSearchQuery encodes query + per_page + orientation", q.get("query") === "coffee cup" && q.get("per_page") === "3" && q.get("orientation") === "landscape");
273
+ check("buildSearchQuery clamps per_page to 1..80", buildSearchQuery({ query: "x", perPage: 999 }).get("per_page") === "80" && buildSearchQuery({ query: "x", perPage: 0 }).get("per_page") === "1");
274
+ }
243
275
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
244
276
  process.exit(failures === 0 ? 0 : 1);
@@ -1,8 +1,10 @@
1
1
  import { registerReferenceTools } from "./reference.js";
2
2
  import { registerGenerationTools } from "./generation.js";
3
+ import { registerMediaTools } from "./media.js";
3
4
  import { registerPersistenceTools } from "./persistence.js";
4
5
  export function registerTools(server, domain) {
5
6
  registerReferenceTools(server, domain);
6
7
  registerGenerationTools(server, domain);
8
+ registerMediaTools(server);
7
9
  registerPersistenceTools(server, domain);
8
10
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Media tools: fetch REAL stock images for a page instead of grey placeholders.
3
+ * `search_images` queries the Pexels API and returns ready-to-hotlink URLs the
4
+ * agent drops into an image element's `specials.src` (or a gallery item's `link`).
5
+ *
6
+ * The Pexels API key is a secret resolved per request: the `x-pexels-key` header
7
+ * (remote / multi-user) wins, else the `PEXELS_API_KEY` env var (stdio). With a key
8
+ * we call Pexels directly; WITHOUT one we fall back to the shared hosted proxy
9
+ * (https://mcp.toolvn.io.vn) so `npx` users get images with zero setup. The page can
10
+ * still fall back to placeholders if even the proxy is unreachable. No Webcake creds.
11
+ */
12
+ import { z } from "zod";
13
+ import { text } from "../mcp/response.js";
14
+ import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
15
+ export function registerMediaTools(server) {
16
+ // 13) Search images ---------------------------------------------------------
17
+ server.tool("search_images", "Find REAL stock photos (Pexels) for a page so images aren't grey placeholders. Works out of the box (a shared hosted proxy supplies images; set PEXELS_API_KEY env or the x-pexels-key header to use your own Pexels quota — free at https://www.pexels.com/api/). Pass a short English subject query (e.g. 'fresh coffee cup', 'modern office team'). Returns hotlinkable URLs at several sizes — put `src.large` (hero/banner) or `src.medium` (card/thumb) into an image element's specials.src, or a gallery item's `link`; `avg_color` helps pick a matching section background. Show photographer attribution when you use a photo.", {
18
+ query: z.string().describe("Short English subject to search for, e.g. 'fresh coffee cup', 'spa massage room'."),
19
+ per_page: z.number().int().min(1).max(80).optional().describe("How many results to return (default 5)."),
20
+ orientation: z
21
+ .enum(["landscape", "portrait", "square"])
22
+ .optional()
23
+ .describe("Preferred shape — 'landscape' for heroes/banners, 'portrait' for tall cards, 'square' for icons/avatars."),
24
+ size: z.enum(["large", "medium", "small"]).optional().describe("Minimum photo size to return (default any)."),
25
+ color: z.string().optional().describe("Optional color filter: a name (red, blue, …) or a hex like '6a8f3c'."),
26
+ page: z.number().int().min(1).optional().describe("Result page for pagination (default 1)."),
27
+ }, async ({ query, per_page, orientation, size, color, page }, extra) => {
28
+ const params = { query, perPage: per_page, page, orientation, size, color };
29
+ const key = resolvePexelsKey(pexelsKeyFromHeaders(extra?.requestInfo?.headers));
30
+ // With a key → call Pexels directly; without one → the shared hosted proxy.
31
+ const result = key
32
+ ? await searchPexels(key, params)
33
+ : await searchImagesViaProxy(resolvePexelsProxyBase(), params);
34
+ if (!result.ok) {
35
+ return text({
36
+ ...result,
37
+ hint: "Couldn't fetch images — set PEXELS_API_KEY (env) or the x-pexels-key header for your own Pexels key (free at https://www.pexels.com/api/), or fall back to https://placehold.co/<width>x<height> placeholders.",
38
+ });
39
+ }
40
+ return text(result);
41
+ });
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.32",
3
+ "version": "1.0.34",
4
4
  "description": "MCP server exposing Webcake landing-page element schemas + AI usage hints, and persisting LLM-generated page sources to a Webcake backend.",
5
5
  "type": "module",
6
6
  "license": "MIT",