webcake-landing-mcp 1.0.33 → 1.0.35

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.35",
4
+ "d": "08/06/2026",
5
+ "type": "Changed",
6
+ "en": "get_generation_guide now includes an explicit workflow step (3b) directing the agent to call search_images for every image a page needs (hero,…",
7
+ "vi": "get_generation_guide nay bổ sung bước workflow rõ ràng (3b) yêu cầu agent gọi search_images cho mọi hình ảnh trang cần (hero, sản phẩm, giới thiệu,…"
8
+ },
9
+ {
10
+ "v": "1.0.34",
11
+ "d": "08/06/2026",
12
+ "type": "Added",
13
+ "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…",
14
+ "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…"
15
+ },
2
16
  {
3
17
  "v": "1.0.33",
4
18
  "d": "08/06/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Changed",
27
41
  "en": "The server instructions now direct the agent to gather all get_element and get_generation_guide results before assembling the page source, build the…",
28
42
  "vi": "Hướng dẫn cho agent nay yêu cầu thu thập toàn bộ kết quả get_element và get_generation_guide trước khi lắp ráp nguồn trang, xây dựng cây phần tử đầy…"
29
- },
30
- {
31
- "v": "1.0.29",
32
- "d": "08/06/2026",
33
- "type": "Fixed",
34
- "en": "The GET / guide page now takes full control of scroll restoration across reloads: native browser scroll restoration is disabled in the <head> script…",
35
- "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…"
36
- },
37
- {
38
- "v": "1.0.28",
39
- "d": "08/06/2026",
40
- "type": "Fixed",
41
- "en": "The GET / guide page no longer animates the browser's scroll-position restoration on reload; smooth scrolling is now enabled one animation frame…",
42
- "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…"
43
43
  }
44
44
  ]
@@ -78,7 +78,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
78
78
  RULES
79
79
  - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
80
80
  - Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px).
81
- - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). There is NO image API yet, so set image-block specials.src to a PLACEHOLDER URL sized to the box: "https://placehold.co/<width>x<height>". NEVER leave src empty — it renders blank and the page looks broken. gallery.media = array of OBJECTS {type:'image', link:'<placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster placeholder. The user replaces these later.
81
+ - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). PREFER REAL PHOTOS: call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src — use src.large for a hero/banner, src.medium for a card/thumb (avg_color helps pick a matching section background). ONLY if search_images returns ok:false (or is unreachable) FALL BACK to a PLACEHOLDER sized to the box: "https://placehold.co/<width>x<height>". NEVER leave src empty — it renders blank and the page looks broken. gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder).
82
82
  - CONTRAST: text must contrast with the section background (dark text on light sections, light text on dark sections). Don't put light-gray text on white or faint text on a dark background.
83
83
  - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
84
84
  - Every form input MUST have a unique specials.field_name.
@@ -108,6 +108,7 @@ WORKFLOW (recommended)
108
108
  1. Call get_generation_guide (this) once, then new_page_skeleton for the top-level shape.
109
109
  2. For each element type you'll use, call get_element to learn its specials & see an example.
110
110
  3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
111
+ 3b. For every image the page needs (hero, product, about, feature, gallery), call search_images and put a returned URL into specials.src / gallery item.link. Use placehold.co ONLY when search_images returns ok:false.
111
112
  4. Assemble { page, popup, settings, options, cartConfigs }.
112
113
  5. Call validate_page and fix every error.
113
114
  6. To save: call list_organizations, show the orgs to the user and ask which to use (default to is_default). Then create_page (dry_run first, then dry_run:false with the chosen organization_id).
@@ -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.`;
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.33",
3
+ "version": "1.0.35",
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",