webcake-landing-mcp 1.5.9 → 1.5.11

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.
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "v": "1.5.11",
4
+ "d": "13/10/2026",
5
+ "type": "Fixed",
6
+ "en": "The HTTP server now answers HEAD requests the same way it answers GET on every public read-only route (/privacy, /privacy-policy, /terms, /tos,…",
7
+ "vi": "HTTP server nay trả lời request HEAD giống như GET trên mọi route công khai chỉ-đọc (/privacy, /privacy-policy, /terms, /tos, /favicon.ico,…"
8
+ },
9
+ {
10
+ "v": "1.5.10",
11
+ "d": "13/10/2026",
12
+ "type": "Changed",
13
+ "en": "Rewrote every tool and parameter description (new_element, new_page_skeleton, validate_page, layout, ingest_html, ingest_url, search_images,…",
14
+ "vi": "Viết lại toàn bộ mô tả tool và tham số (new_element, new_page_skeleton, validate_page, layout, ingest_html, ingest_url, search_images, get_icon_svg,…"
15
+ },
2
16
  {
3
17
  "v": "1.5.9",
4
18
  "d": "13/10/2026",
@@ -26,19 +40,5 @@
26
40
  "type": "Fixed",
27
41
  "en": "The HTTP server now serves a 192px PNG at /favicon.ico, /favicon.png, and /apple-touch-icon.png (with matching <link rel=\"icon\">/<link…",
28
42
  "vi": "HTTP server nay phục vụ PNG 192px tại /favicon.ico, /favicon.png, và /apple-touch-icon.png (kèm thẻ <link rel=\"icon\">/<link rel=\"apple-touch-icon\">…"
29
- },
30
- {
31
- "v": "1.5.5",
32
- "d": "13/10/2026",
33
- "type": "Fixed",
34
- "en": "The page/source/sections/patches parameters on validate_page, create_page, update_page, add_section, and patch_page now declare a typed JSON Schema…",
35
- "vi": "Các tham số page/source/sections/patches của validate_page, create_page, update_page, add_section, và patch_page nay khai báo JSON Schema có kiểu rõ…"
36
- },
37
- {
38
- "v": "1.5.4",
39
- "d": "09/10/2026",
40
- "type": "Added",
41
- "en": "ingest_html and ingest_url now return font_groups (the page's self-hosted @font-face fonts, already shaped for settings.fontGroups) and…",
42
- "vi": "ingest_html và ingest_url nay trả về font_groups (các font @font-face tự host của trang, đã đóng gói theo đúng dạng settings.fontGroups) và…"
43
43
  }
44
44
  ]
@@ -168,4 +168,15 @@ REFERENCE INPUT (HTML page to clone or adapt)
168
168
  - Map AST section roles to Webcake elements: hero → section (background image/overlay) + text-block H1 + text-block subheading + button; features → group per card (icon rectangle + text-block title + text-block body); stats bar → group with text-block per stat; pricing → group with text-block list + button; footer → section (dark bg) + text-block + links. When the ingested page contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box, NOT as element soup. The full AST hands you the SOURCE for this: section.widgets = [{ hint, html, css? }] — the widget's cleaned outerHTML plus the stylesheet rules that style it. Build the html-box FROM that html verbatim: inline each css rule into the matching elements' style="" attributes (an html-box has no <style> scope), wrap in a root div with width:100%;height:100%;box-sizing:border-box;overflow:hidden, and size the html-box to the widget's box — do NOT re-imagine the widget's markup from the summary fields.
169
169
  - Reference images are the user's assets — for BOTH intents (adapt AND clone), carry every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the matching slot; the save auto-hosts it to the Webcake CDN (no upload_images needed, no hotlinking), so never replace it with a search_images stock photo or a placeholder. intent='adapt' rewrites TEXT for the user's brand, not the imagery; search_images only fills slots that have no source image.
170
170
  - FONTS + TYPE SCALE (clone/adapt): the AST's \`font_groups\` are the page's SELF-HOSTED fonts (from its @font-face rules — the platform doesn't have them). Put them into settings.fontGroups VERBATIM and set each text element's styles.fontFamily to "'<group name>', sans-serif" (fontGeneral too when it's the body font); keep the original file URLs — every save downloads them, uploads them to the media collection and files the group into the org's Font Groups (the result's \`fonts\` report), so never skip or substitute the font. Families NOT in font_groups are Google fonts and load by name. Each section's \`text_styles\` {heading, body, cta} and the page-level \`typography\` are the source's COMPUTED type (cascade, @media, var(), clamp(), Tailwind classes): set desktop styles.fontSize = font_size, mobile styles.fontSize = mobile_font_size (else font_size, shrunk only if the mobile text math needs it), and fontWeight/fontFamily/lineHeight/letterSpacing EXACTLY — never eyeball a size the AST already gives. Any fontWeight works (500/600/800 included): the save adds the Google weight links the build would otherwise skip. An absolute-canvas \`source\` already carries all of this. For ingest_html of HTML copied from a live site, pass base_url so external stylesheets (and their fonts) are read. Custom font files belong to the source site's license — clone fonts only for pages the user owns or is allowed to copy; for a third-party ADAPT, prefer the nearest Google family over its font_groups.
171
- - VERIFY THE CLONE VISUALLY (clones only — where fidelity is the whole point): for a clone/adapt build, do the VISUAL CHECK from BUILDING step 7 ONCE — screenshot the rendered preview with your own browser/screenshot capability (or render_preview as fallback), compare it side-by-side with the reference (screenshot the reference URL too if you only have HTML), patch_page the clear mismatches, and re-check ONLY if you changed something material (don't loop reflexively). For a NON-clone page, validate_page + the save-time auto-fix are the gate — skip the screenshot unless the user asks or you have a specific aesthetic doubt. Eyeballing a reference and rebuilding once typically lands ~60% similar; a targeted look-and-patch pass closes the gap when fidelity matters.`;
171
+ - VERIFY THE CLONE VISUALLY (clones only — where fidelity is the whole point): for a clone/adapt build, do the VISUAL CHECK from BUILDING step 7 ONCE — screenshot the rendered preview with your own browser/screenshot capability (or render_preview as fallback), compare it side-by-side with the reference (screenshot the reference URL too if you only have HTML), patch_page the clear mismatches, and re-check ONLY if you changed something material (don't loop reflexively). For a NON-clone page, validate_page + the save-time auto-fix are the gate — skip the screenshot unless the user asks or you have a specific aesthetic doubt. Eyeballing a reference and rebuilding once typically lands ~60% similar; a targeted look-and-patch pass closes the gap when fidelity matters.
172
+
173
+ TOOL NOTES (how to sequence the tools — kept here rather than in the tool descriptions):
174
+ - Coordinates: call layout for centered boxes, rows, grids and stacks instead of hand-computing left/top; drop the returned boxes into responsive.<bp>.styles in the order you passed them.
175
+ - Elements: author nodes in the sparse shape new_element / get_element return; fetch several types at once with get_element({ types:[...] }).
176
+ - Validation: validate_page before every save and treat warnings as a fix list — fix and re-validate until only demonstrable false positives remain.
177
+ - Reference pages (ingest_html / ingest_url): use detail:'full' for clone-faithful rebuilds; rebuild sizes and colors from design_tokens/palette and copy typography/text_styles exactly; rebuild each widget VERBATIM as ONE html-box (inline its css). Pass base_url to ingest_html when the HTML came from a live site. If ingest_url warns the body was empty (client-rendered), work from a screenshot instead. An absolute-canvas result's \`source\` saves straight to create_page; patch the items in clone_notes afterwards with patch_page. When canvas_summary.truncated is true, re-call with sections:[id] and append each section's source.page[0] via add_section.
178
+ - Images: reference images (ingest images/background_images/og_image) and images the user supplies are the user's assets for BOTH intents — re-host them with upload_images (or let the save auto-host remote URLs) and never swap them for stock photos; use search_images only for slots with no source image, batching all slots in one \`queries\` call and picking the variant whose width matches the slot (size_guide). Pass a user's LOCAL file paths straight to upload_images — never upload them to a third-party host (catbox, imgur, transfer.sh…) to get a URL. Wait for the returned images map and use every hosted URL; never placeholder a slot whose upload succeeded. Settle the organization with the user first (same org as the page) — on reason:'organization_required', ask and re-call with organization_id.
179
+ - Icons: for each get_icon_svg result, build a SQUARE rectangle element with the svg in BOTH responsive.desktop.config.svgMask and responsive.mobile.config.svgMask and styles.background = the icon color (without a background the icon is blank).
180
+ - Saving: on a failed create_page / add_section / update_page, fix only the reported elements with patch_page({ draft_id, patches }) instead of rebuilding; after a timeout, retry with patch_page({ draft_id }) and no patches. To edit an existing page: find_pages → get_page → patch_page (preferred) or update_page. Pass force:true to update_page/patch_page only after the user explicitly agrees to discard changes made in the editor. update_page / patch_page / add_section save the SOURCE only — run publish_page afterwards so the preview and live page show the edit.
181
+ - Visual check: screenshot the preview with your OWN browser/screenshot ability when you have one (fresh and unlimited); use render_preview only as the fallback. If it returns ok:false (rate-limited), skip the visual check that round rather than failing. Do it promptly after create_page/publish_page — a domain-less preview expires ~10 minutes after the last publish; re-publish before re-checking a stale page.
182
+ - Publishing: a page without a custom domain has no lasting public URL — tell the user to attach a domain (publish_page with custom_domain) for a permanent link.`;
package/dist/http.js CHANGED
@@ -81,6 +81,12 @@ function iconPng() {
81
81
  return null;
82
82
  }
83
83
  }
84
+ // Public pages/assets answer HEAD like GET (Node drops the body on HEAD): link
85
+ // checkers — incl. the connector-directory form — probe with HEAD, and a 404
86
+ // there made /privacy and /terms look missing.
87
+ function isRead(req) {
88
+ return req.method === "GET" || req.method === "HEAD";
89
+ }
84
90
  function sendJson(res, status, body) {
85
91
  res.writeHead(status, { "content-type": "application/json" });
86
92
  res.end(JSON.stringify(body));
@@ -186,12 +192,12 @@ function htmlError(res, status, message) {
186
192
  async function handleOAuth(req, res, path) {
187
193
  const issuer = publicBase(req);
188
194
  // ---- Metadata (RFC 8414 / RFC 9728) ----
189
- if (req.method === "GET" && path === WELL_KNOWN_PR) {
195
+ if (isRead(req) && path === WELL_KNOWN_PR) {
190
196
  res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*" });
191
197
  res.end(JSON.stringify(protectedResourceMetadata(`${issuer}${MCP_PATH}`, issuer)));
192
198
  return true;
193
199
  }
194
- if (req.method === "GET" && path === WELL_KNOWN_AS) {
200
+ if (isRead(req) && path === WELL_KNOWN_AS) {
195
201
  res.writeHead(200, { "content-type": "application/json", "access-control-allow-origin": "*" });
196
202
  res.end(JSON.stringify(authServerMetadata(issuer)));
197
203
  return true;
@@ -397,21 +403,21 @@ export async function startHttpServer(port) {
397
403
  const path = (req.url ?? "").split("?")[0];
398
404
  // Brand icon — clients (e.g. the claude.ai connector) fetch a favicon from the
399
405
  // server origin; without one they show a generic globe. Served raw as SVG.
400
- if (req.method === "GET" && (path === "/favicon.ico" || path === "/favicon.png" || path === "/icon.png" || path === "/apple-touch-icon.png")) {
406
+ if (isRead(req) && (path === "/favicon.ico" || path === "/favicon.png" || path === "/icon.png" || path === "/apple-touch-icon.png")) {
401
407
  const png = iconPng();
402
408
  if (png) {
403
409
  res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400" });
404
410
  return res.end(png);
405
411
  }
406
412
  }
407
- if (req.method === "GET" && (path === "/favicon.ico" || path === "/favicon.svg" || path === "/icon.svg")) {
413
+ if (isRead(req) && (path === "/favicon.ico" || path === "/favicon.svg" || path === "/icon.svg")) {
408
414
  res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
409
415
  return res.end(ICON_SVG);
410
416
  }
411
417
  // Social-card image referenced by the landing page's og:image / twitter:image.
412
418
  // PNG is the canonical og:image (unfurls everywhere); the SVG stays for clients
413
419
  // that prefer it (Slack/Telegram/Discord) and as a fallback if og.png is absent.
414
- if (req.method === "GET" && path === "/og.png") {
420
+ if (isRead(req) && path === "/og.png") {
415
421
  const png = ogImagePng();
416
422
  if (png) {
417
423
  res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400" });
@@ -421,21 +427,21 @@ export async function startHttpServer(port) {
421
427
  res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
422
428
  return res.end(ogImageSvg());
423
429
  }
424
- if (req.method === "GET" && path === "/og.svg") {
430
+ if (isRead(req) && path === "/og.svg") {
425
431
  res.writeHead(200, { "content-type": ICON_MIME, "cache-control": "public, max-age=86400" });
426
432
  return res.end(ogImageSvg());
427
433
  }
428
434
  // Public legal pages — required URLs for the Claude/ChatGPT directory submission.
429
- if (req.method === "GET" && (path === "/privacy" || path === "/privacy-policy")) {
435
+ if (isRead(req) && (path === "/privacy" || path === "/privacy-policy")) {
430
436
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
431
437
  return res.end(privacyHtml());
432
438
  }
433
- if (req.method === "GET" && (path === "/terms" || path === "/tos")) {
439
+ if (isRead(req) && (path === "/terms" || path === "/tos")) {
434
440
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" });
435
441
  return res.end(termsHtml());
436
442
  }
437
443
  // Lightweight health check for hosting platforms.
438
- if (req.method === "GET" && (path === "/" || path === "/health")) {
444
+ if (isRead(req) && (path === "/" || path === "/health")) {
439
445
  // A browser/connector probing the root with `Accept: text/html` gets a tiny
440
446
  // page that links the favicon (helps icon discovery); programmatic probes
441
447
  // (the container healthcheck uses `Accept: */*`) still get the JSON health.
@@ -194,14 +194,33 @@ export async function fetchHtml(url, opts = {}) {
194
194
  const ctrl = new AbortController();
195
195
  const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? FETCH_TIMEOUT_MS);
196
196
  try {
197
- const res = await fetch(url, {
198
- headers: {
199
- "User-Agent": opts.userAgent ?? "Mozilla/5.0 (compatible; webcake-landing-mcp/ingest_url)",
200
- Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
201
- },
202
- signal: ctrl.signal,
203
- redirect: "follow",
204
- });
197
+ // Same SSRF policy as fetchCss: on the remote transport the URL is caller-chosen,
198
+ // so no private/loopback/link-local hosts, and redirects are followed MANUALLY so
199
+ // every hop is re-checked (RENDER_ALLOW_PRIVATE=1 opts out, e.g. a local dev page).
200
+ let target = url;
201
+ let res;
202
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
203
+ const allowed = isAllowedScreenshotUrl(target);
204
+ if (!allowed.ok)
205
+ return { ok: false, error: `URL not allowed: ${allowed.error ?? "private or non-public host"}` };
206
+ res = await fetch(target, {
207
+ headers: {
208
+ "User-Agent": opts.userAgent ?? "Mozilla/5.0 (compatible; webcake-landing-mcp/ingest_url)",
209
+ Accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5",
210
+ },
211
+ signal: ctrl.signal,
212
+ redirect: "manual",
213
+ });
214
+ if (res.status < 300 || res.status >= 400)
215
+ break;
216
+ const loc = res.headers.get("location");
217
+ if (!loc)
218
+ return { ok: false, status: res.status, error: `Redirect ${res.status} without a Location header` };
219
+ target = new URL(loc, target).href;
220
+ res = undefined;
221
+ }
222
+ if (!res)
223
+ return { ok: false, error: `Too many redirects (> ${MAX_REDIRECTS})` };
205
224
  if (!res.ok)
206
225
  return { ok: false, status: res.status, error: `Server returned ${res.status}` };
207
226
  const ctype = res.headers.get("content-type") ?? "";
package/dist/smoke.js CHANGED
@@ -10,7 +10,7 @@ import { pkgVersion } from "./server.js";
10
10
  import { validatePage, pageSchema } from "./domains/landing/validate.js";
11
11
  import { expandSource } from "./core/expand.js";
12
12
  import { compactSource, deepEq, sparseTemplate } from "./core/compact.js";
13
- import { parseHtml, extractTailwindConfig, extractFontFaces, applyCloneFonts, googleWeightLinks, withGoogleWeightLinks, toFontGroups, stylesheetTags, fetchStylesheets, parseCssRules, resolveCssVars } from "./persistence/html-ingest.js";
13
+ import { fetchHtml, parseHtml, extractTailwindConfig, extractFontFaces, applyCloneFonts, googleWeightLinks, withGoogleWeightLinks, toFontGroups, stylesheetTags, fetchStylesheets, parseCssRules, resolveCssVars } from "./persistence/html-ingest.js";
14
14
  import { registerFontGroups } from "./persistence/webcake-client.js";
15
15
  import { warningsField } from "./mcp/response.js";
16
16
  import { readConfig, resolveEnv, ENV_NAMES, configFromHeaders } from "./persistence/config.js";
@@ -710,6 +710,8 @@ console.log("== fonts: review hardening (cascade order, media, rem root, UA, Tai
710
710
  // SSRF: stylesheet hrefs chosen by the crawled page never reach private hosts.
711
711
  const priv = await fetchStylesheets(`<link rel="stylesheet" href="http://169.254.169.254/latest/meta-data"><link rel="stylesheet" href="http://127.0.0.1:1/a.css"><style>@import url(http://10.0.0.1/x.css);</style>`, "https://example.com/");
712
712
  check("ssrf: private/link-local stylesheet + @import targets are not fetched", priv.length === 0, priv);
713
+ const privPage = await Promise.all(["http://169.254.169.254/latest/meta-data", "http://127.0.0.1:1/", "http://10.0.0.1/"].map((u) => fetchHtml(u)));
714
+ check("ingest_url SSRF: private/loopback/link-local page fetch blocked before any request", privPage.every((r) => !r.ok && /not allowed/.test(r.error ?? "")), privPage);
713
715
  }
714
716
  console.log("== settings: width_section defaulted (build host crashes without it) ==");
715
717
  {
@@ -9,7 +9,7 @@ import { text, warningsField, autoFixedField } from "../mcp/response.js";
9
9
  import { objectOrJson } from "../mcp/params.js";
10
10
  export function registerGenerationTools(server, domain) {
11
11
  // 5) New element ------------------------------------------------------------
12
- server.tool("new_element", "Returns a default element node for a type in the SPARSE authoring shape (fresh id, both breakpoints' seeded styles, seeded specials). Emit elements exactly like this — fill in specials + top/left coordinates; OMIT properties/runtime/empty events/config (the server hydrates them from factory defaults on validate/persist).", {
12
+ server.tool("new_element", "Returns a default element node for a type in the sparse authoring shape: a fresh id, seeded styles for both breakpoints, and seeded specials. properties, runtime, empty events and per-breakpoint config are omitted because the server fills them from factory defaults on validate and save.", {
13
13
  type: z.string().describe("Element type to create."),
14
14
  name: z.string().optional().describe("Optional properties.name override (layer label)."),
15
15
  }, { title: "New Element Node", readOnlyHint: true, openWorldHint: false }, async ({ type, name }) => {
@@ -22,7 +22,7 @@ export function registerGenerationTools(server, domain) {
22
22
  return text(el);
23
23
  });
24
24
  // 6) New page skeleton ------------------------------------------------------
25
- server.tool("new_page_skeleton", "Returns an empty but complete top-level page source { page:[], popup:[], settings:{...defaults}, options:{...}, cartConfigs:{} } matching the real editor shape. Pass desktopWidth/mobileWidth to set the canvas width (settings.width_section) up front — pick desktop 1200 for wide/multi-column/editorial pages or when cloning a reference wider than 960 (e.g. Google Stitch ~1280), else 960; then place every element's coords in that width's space.", {
25
+ server.tool("new_page_skeleton", "Returns an empty, complete top-level page source { page:[], popup:[], settings, options, cartConfigs } in the Webcake editor's shape. desktopWidth (960 or 1200) and mobileWidth (420 or 360) set settings.width_section, the canvas width that all element coordinates are measured in.", {
26
26
  mobileOnly: z.boolean().optional().describe("true if the page renders mobile-only."),
27
27
  desktopWidth: z.union([z.literal(960), z.literal(1200)]).optional().describe("Desktop canvas width (settings.width_section.desktop). 960 (default, simple/narrow) or 1200 (wide/multi-column/editorial, or cloning a >960 reference)."),
28
28
  mobileWidth: z.union([z.literal(420), z.literal(360)]).optional().describe("Mobile canvas width (settings.width_section.mobile). 420 (default) or 360 (to match a ~360–390 mobile design)."),
@@ -33,7 +33,7 @@ export function registerGenerationTools(server, domain) {
33
33
  : undefined,
34
34
  })));
35
35
  // 7) Validate page ----------------------------------------------------------
36
- server.tool("validate_page", "Validates a page source against the schema + semantic rules (unique ids, dangling event targets, children only on containers, missing field_name, top-level types) plus form-data bindings (duplicate field_name within one form, dangling option-event promoId / connectedSurvey / connectedForm / set_field_value targets). FIRST auto-fixes the layout defects that can be resolved deterministically (off-canvas boxes pulled on-canvas; elements below wrapped text pushed down to clear the spill — the same corrections create_page/add_section apply on save) and reports them in auto_fixed. Then returns errors (blocking — fix before persisting) and warnings (visible design defects — fix these too and re-validate to an empty list; only a demonstrably false positive may remain).", {
36
+ server.tool("validate_page", "Validates a page source against the JSON schema and semantic rules: unique ids, dangling event targets, children only on container types, missing field_name, top-level types, and form-data bindings (duplicate field_name within one form; dangling promoId / connectedSurvey / connectedForm / set_field_value targets). Before validating it applies deterministic layout corrections (off-canvas boxes moved on-canvas, elements below wrapped text moved down) and lists them in auto_fixed. Returns errors (structural problems that block saving) and warnings (visible layout defects such as overlapping text, off-canvas bounds, or empty bands).", {
37
37
  page: objectOrJson()
38
38
  .describe("The page source object { page:[...], settings:{} } OR a JSON string of it."),
39
39
  }, { title: "Validate Page Source", readOnlyHint: true, openWorldHint: false }, async ({ page }) => {
@@ -47,7 +47,7 @@ export function registerGenerationTools(server, domain) {
47
47
  return text({ ...result, ...autoFixedField(autoFixed), ...warningsField(result.warnings) });
48
48
  });
49
49
  // 7b) Layout coordinates -----------------------------------------------------
50
- server.tool("layout", "Computes EXACT on-canvas coordinates (top/left/width/height) for a group of elements, for BOTH breakpoints, following the guide's layout math — so you NEVER hand-compute `left`/`top` (the #1 source of off-center defects) or write a script to do it. Drop the returned boxes straight into each element's responsive.<bp>.styles (results are in the same order you passed items). Four modes: 'center' (one box centered on the canvas); 'row' (N boxes in a horizontally-centered row on desktop that STACK into a single mobile column — feature cards / stats / logo strip); 'grid' (N uniform cells in `cols` columns, block centered; stacks on mobile); 'stack' (a vertical list down the shared content column on both breakpoints). Honours the page-margin axis (content column 80..880 desktop / 20..400 mobile by default). Pure math — no env, no network. `notes` flags off-canvas / over-wide inputs.", {
50
+ server.tool("layout", "Computes on-canvas coordinates (top/left/width/height) for a group of elements on both breakpoints. Modes: 'center' (one box centered on the canvas), 'row' (N boxes in a centered horizontal row on desktop, stacked into one column on mobile), 'grid' (N uniform cells in `cols` columns, stacked on mobile), 'stack' (a vertical list down the content column). Respects the page margins (content column 80–880 on desktop and 20–400 on mobile by default). Returns one box per item in input order, plus `notes` for off-canvas or over-wide inputs. Pure computation: no credentials, no network.", {
51
51
  mode: z.enum(["center", "row", "grid", "stack"]).describe("Layout pattern. center=one box; row=horizontal row (stacks on mobile); grid=cols×rows (stacks on mobile); stack=vertical list."),
52
52
  items: z
53
53
  .array(z.object({ width: z.number(), height: z.number() }))
@@ -31,11 +31,11 @@ import { parseHtml, fetchHtml, fetchStylesheets, applyCloneFonts } from "../pers
31
31
  const sectionsParam = z
32
32
  .array(z.string())
33
33
  .optional()
34
- .describe("Absolute-canvas mode only: return ONLY these canvas section ids (use the ids from a previous call's canvas_summary.sections[].id; 'SECTION_POPUP' selects the popups). When a full-page call comes back canvas_summary.truncated:true (styles pruned to fit the size cap), re-call per section to get each section's `source` in FULL untrimmed detail — pairs naturally with building the page incrementally via add_section (pass that call's source.page[0] to add_section).");
34
+ .describe("Absolute-canvas mode only: return only these canvas section ids (from a previous result's canvas_summary.sections[].id; 'SECTION_POPUP' selects the popups). A full-page result marked canvas_summary.truncated:true has its styles pruned to fit the size cap; per-section results carry each section's `source` untrimmed, and `source.page[0]` is a single section node.");
35
35
  const detailParam = z
36
36
  .enum(["compact", "full"])
37
37
  .optional()
38
- .describe("Level of detail in the returned AST. Both levels also lift the design system from a `tailwind.config` block when present (Google Stitch / Tailwind-CDN pages keep the WHOLE design system there, not in CSS): `palette` is named by token (primary→#a43b38, surface-container-low→#f3f3f3 — map utility classes like text-primary/bg-surface-container-low back to it), `colors` is usage-ranked from the classes actually on the page, and `design_tokens` carries the resolved spacing grid, corner radii, and TYPE SCALE (e.g. display-lg→48px, xl→80px) — REBUILD from these tokens so sizing/spacing/color match the original. 'compact' (default) — backward-compatible ~2-5 KB shape with top colors/fonts from inline styles. 'full' — richer AST: CSS custom-property palette (design tokens by name), background_images from stylesheets, gradients, per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, extended paragraphs, images as { src, alt } objects, and per-section widgets = { hint, html, css? } — the cleaned source HTML + matching CSS of composite visuals (phone/device mockup, chat thread, dashboard, browser frame) to rebuild VERBATIM as ONE html-box (inline the css; don't re-imagine the markup). Use 'full' for clone-faithful rebuilds. Image URLs found in the result (images, background_images, og_image) are the user's assets: re-host them via upload_images and reuse them in the generated page for BOTH intents (never hotlink, never replace them with search_images stock photos). NOTE: for absolute-canvas builder exports the deterministic `source` is returned regardless of detail.");
38
+ .describe("Level of detail in the returned AST. 'compact' (default, ~2–5 KB): sections, top colors and fonts. 'full': adds the CSS custom-property palette (design tokens by name), background_images from stylesheets, gradients, per-section blocks (repeating card/tile/step structures with title/body/image/cta), li lists, extended paragraphs, images as { src, alt }, and per-section widgets = { hint, html, css? } — the cleaned source HTML + matching CSS of composite visuals (device mockup, chat thread, dashboard, browser frame). Both levels lift the design system from a `tailwind.config` block when present (Google Stitch / Tailwind-CDN pages): `palette` named by token (e.g. primary → #a43b38), `colors` ranked by usage, and `design_tokens` with the resolved spacing grid, corner radii and type scale (e.g. display-lg → 48px). Image URLs in the result (images, background_images, og_image) belong to the source page. Absolute-canvas builder exports return the deterministic `source` at either level.");
39
39
  /** Folded into the response when a clone `source` is produced from an absolute-canvas export. */
40
40
  const CLONE_NOTICE = "ABSOLUTE-CANVAS DETECTED → a deterministic clone `source` is included. It is a faithful 1:1 rebuild of the original page (exact boxes/styles/images/behaviors on the matching 420/960 canvas), NOT a layout reference — do NOT hand-rebuild from canvas_summary. SAVE IT DIRECTLY: call create_page with this `source` (dry_run:true first to validate + preview; then dry_run:false). External images in it are AUTO-HOSTED to the Webcake CDN on save — do NOT pre-run upload_images for them. Review `clone_notes` for the few lossy approximations (fixed/floating elements, svg-less shapes, skipped social-proof toasts) and patch_page them after the page exists. If a `sections` filter was used, this `source` holds only those sections — pass source.page[0] to add_section instead.";
41
41
  /** Slim the heavy per-element canvas to a navigable summary (the `source` supersedes it). */
@@ -72,29 +72,29 @@ function withCloneSource(domain, parsed) {
72
72
  };
73
73
  }
74
74
  export function registerIngestTools(server, domain) {
75
- server.tool("ingest_html", "Parses an HTML string into a reference AST: title, description, og_image, language, and sections classified by role (header, hero, features, about, form, cta, gallery, testimonials, pricing, faq, footer, unknown) with headings, subheadings, paragraphs, images, ctas, links, form fields, and a size_hint (desktop section height in px — from the source CSS when explicit, else a content-volume estimate; set the rebuilt section's desktop height from it) — plus top colors, fonts, palette, and background_images. The palette is pulled from CSS custom-properties AND, for Tailwind-CDN pages (Google Stitch output), from the page's `tailwind.config` — which also yields `design_tokens` (the resolved spacing grid, corner radii, and type scale) so the rebuild matches the source's exact sizing and colors instead of guessing. Returns ~2-5KB (compact) or up to ~25KB (full). Use detail:'full' for clone-faithful rebuilds — it adds per-section blocks (cards/tiles/steps), li lists, gradients, images as { src, alt } objects, and widgets (the source HTML + CSS of composite mockups, to paste into ONE html-box). ABSOLUTE-CANVAS builder exports (LadiPage-family pages / Webcake-published HTML — bare positioned divs whose layout lives in per-id CSS rules) are AUTO-DETECTED and converted DETERMINISTICALLY into a ready-to-save Webcake `source` (folded into the response as `source` + `clone_notes` + `clone_notice`): a faithful 1:1 clone on the matching 420/960 canvas — save it straight to create_page instead of hand-rebuilding. The heavy per-element geometry is summarized to `canvas_summary` { builder, width, mobile_only, element_count, sections:[{id,height,elements}], popups }. External images in `source` are auto-hosted on save (no upload_images needed); `clone_notes` lists the few lossy approximations to patch_page afterward. Garbled Vietnamese mojibake (UTF-8 mis-read as Latin-1) is auto-repaired with a warning. FONTS: `font_groups` = the page's self-hosted fonts (from @font-face) in the settings.fontGroups shape — copy into settings.fontGroups and use the group name as styles.fontFamily (files are uploaded to the media collection on save); `typography` + per-section `text_styles` = the computed font_size (desktop px) / mobile_font_size / font_weight / font_family / line_height — copy them exactly.", {
75
+ server.tool("ingest_html", "Parses an HTML string into a reference AST for rebuilding the page in Webcake: title, description, og_image, language, and sections classified by role (header, hero, features, about, form, cta, gallery, testimonials, pricing, faq, footer, unknown), each with headings, paragraphs, images, CTAs, links, form fields and a size_hint (desktop section height in px — from CSS when explicit, else estimated). Also returns top colors, fonts, palette and background_images; for Tailwind-CDN pages (e.g. Google Stitch) the palette and `design_tokens` (spacing grid, corner radii, type scale) come from the page's tailwind.config. detail:'compact' (default, ~2–5 KB) or 'full' (up to ~25 KB: per-section blocks such as cards and steps, lists, gradients, images as { src, alt }, and `widgets` — the cleaned HTML + CSS of composite visuals such as device mockups). Fonts: `font_groups` lists self-hosted fonts from @font-face rules in the settings.fontGroups shape; `typography` and per-section `text_styles` give the computed font size (desktop and mobile), weight, family, line height and letter spacing. Absolute-canvas builder exports (LadiPage-family or Webcake-published HTML) are detected and converted deterministically into a complete Webcake page `source`, with `clone_notes` listing approximations and `canvas_summary` summarizing per-section geometry; external images in that source are re-hosted automatically when it is saved. Garbled Vietnamese text (UTF-8 read as Latin-1) is repaired, with a warning. No credentials, no writes.", {
76
76
  html: z.string().describe("Raw HTML of a page or a section."),
77
77
  intent: z
78
78
  .enum(["adapt", "clone"])
79
79
  .optional()
80
- .describe("How the caller intends to use the result. 'adapt' (default) — use as a layout reference and rewrite the TEXT for the user's brand (images from the reference are still re-hosted via upload_images and reused). 'clone' — keep text and images close to the original. For absolute-canvas exports the deterministic `source` is a clone either way."),
80
+ .describe("How the result will be used. 'adapt' (default): the reference provides layout and imagery while the text is rewritten for the user's brand. 'clone': text and images stay close to the original. Absolute-canvas exports return a clone `source` either way."),
81
81
  detail: detailParam,
82
82
  sections: sectionsParam,
83
83
  base_url: z
84
84
  .string()
85
85
  .optional()
86
- .describe("The page's original URL, when the HTML was saved/copied from a live site. Resolves relative font/image urls AND fetches the page's external stylesheets (+ @imports) so self-hosted fonts (`font_groups`) and the computed type (`typography`/`text_styles`) defined outside the HTML are found — pass it whenever you know where the HTML came from."),
86
+ .describe("The page's original URL, when the HTML was saved or copied from a live site. Resolves relative font and image URLs and fetches the page's external stylesheets (+ @imports), so self-hosted fonts (`font_groups`) and computed type (`typography` / `text_styles`) defined outside the HTML are included."),
87
87
  }, { title: "Ingest HTML Reference", readOnlyHint: true, openWorldHint: true }, async ({ html, intent, detail, sections, base_url }) => {
88
88
  const baseUrl = base_url && /^https?:\/\//i.test(base_url) ? base_url : undefined;
89
89
  const extraCss = baseUrl ? await fetchStylesheets(html, baseUrl) : undefined;
90
90
  return text({ intent: intent ?? "adapt", ...withCloneSource(domain, parseHtml(html, detail ?? "compact", { sections, baseUrl, extraCss })) });
91
91
  });
92
- server.tool("ingest_url", "Fetches a public webpage (GET, 10s timeout, 2MB cap) and parses it into the same reference AST as ingest_html (including per-section size_hint desktop heights). Returns a warning when the page appears client-rendered (empty <body>) so the caller can fall back to a screenshot — Claude can analyze a screenshot natively without this tool. Does not execute JavaScript; sites built with React/Vue/Next.js may return little content. Use detail:'full' for clone-faithful rebuilds — adds CSS palette, background_images, per-section blocks, lists, images as { src, alt } objects, and widgets (source HTML + CSS of composite mockups for html-box rebuilds). ABSOLUTE-CANVAS builder exports (LadiPage-family / Webcake-published pages) are auto-detected the same way as ingest_html and converted DETERMINISTICALLY into a ready-to-save `source` (+ `clone_notes` + `clone_notice`, with the per-element geometry summarized to `canvas_summary`) — save it straight to create_page; external images auto-host on save. Image URLs in the result are the user's assets — re-host them via upload_images and reuse them for BOTH intents; use search_images only for slots with no source image. Also fetches the page's external stylesheets (+ @imports) so `font_groups` (self-hosted fonts → settings.fontGroups, uploaded on save) and the computed `typography`/`text_styles` (font_size/mobile_font_size/font_weight — copy exactly) see rules outside the HTML. Docs: https://github.com/vuluu2k/webcake-landing-mcp/blob/main/docs/tools.md (ingest_url).", {
92
+ server.tool("ingest_url", "Fetches a public webpage (GET, 10 s timeout, 2 MB cap) together with its external stylesheets and @imports, and parses it into the same reference AST as ingest_html: role-classified sections with size_hint heights, palette, design tokens, self-hosted fonts (`font_groups`), computed typography, images, and — with detail:'full' — per-section blocks, lists and widgets. Absolute-canvas builder exports (LadiPage-family or Webcake-published pages) are detected and converted deterministically into a complete Webcake page `source` with `clone_notes` and `canvas_summary`. JavaScript is not executed, so client-rendered sites (React/Vue/Next.js) may return little content; the result then carries a warning that the body was empty. Fetches are restricted to public hosts (private and link-local addresses are blocked). No credentials, no writes. Docs: https://github.com/vuluu2k/webcake-landing-mcp/blob/main/docs/tools.md", {
93
93
  url: z.string().describe("Public HTTP(S) URL of the page to fetch."),
94
94
  intent: z
95
95
  .enum(["adapt", "clone"])
96
96
  .optional()
97
- .describe("How the caller intends to use the result. 'adapt' (default) — use as a layout reference and rewrite the TEXT for the user's brand (images from the reference are still re-hosted via upload_images and reused). 'clone' — keep text and images close to the original. For absolute-canvas exports the deterministic `source` is a clone either way."),
97
+ .describe("How the result will be used. 'adapt' (default): the reference provides layout and imagery while the text is rewritten for the user's brand. 'clone': text and images stay close to the original. Absolute-canvas exports return a clone `source` either way."),
98
98
  detail: detailParam,
99
99
  sections: sectionsParam,
100
100
  }, { title: "Ingest URL Reference", readOnlyHint: true, openWorldHint: true }, async ({ url, intent, detail, sections }) => {
@@ -196,12 +196,12 @@ export function localContentType(ext, buf) {
196
196
  }
197
197
  export function registerMediaTools(server, allowLocalFiles = true) {
198
198
  // 13) Search images ---------------------------------------------------------
199
- server.tool("search_images", "Searches Pexels stock photos (see https://www.pexels.com/api/) by short English subject queries. Returns hotlinkable URLs at several sizes, `avg_color` for matching section backgrounds, plus photographer name and attribution URL. PICK BY SIZE, NOT JUST TOPIC: each photo carries a `sizes` map (delivered WxH px per variant) and the response includes a `size_guide` — match the variant width to the slot's rendered width (hero/banner → src.large ~940px or src.large2x ~1880px retina; card/thumb → src.medium ~350px; avatar → src.tiny), because a too-small variant stretched across a big slot pixelates ('vỡ ảnh') and src.original / an oversized variant in a small card bloats the page ('nặng trang'). BATCH MODE: pass `queries: [...]` to fetch multiple subjects in PARALLEL — e.g. ['fresh coffee cup','barista pouring','interior cafe'] for hero + about + gallery — returns { queries: { [q]: result } } so the caller picks one image per slot in a single round-trip; default `pick='best'` returns only the top photo per query (compact, drop-in for specials.src), `pick='all'` returns the full list. `query` (single) returns the full result like before. Works out of the box via a shared hosted proxy; set PEXELS_API_KEY env or x-pexels-key header to use your own quota. ONLY for image slots with NO source image: when the user supplied images or the reference HTML/URL contains image URLs (ingest AST images/background_images/og_image), re-host THOSE via upload_images instead of searching stock photos.", {
200
- query: z.string().optional().describe("Single subject query — backward-compat. Prefer `queries` when the page needs 2+ images."),
199
+ server.tool("search_images", "Searches Pexels stock photos (API: https://www.pexels.com/api/documentation/) by short English subject queries. Each photo includes hotlinkable URLs at several sizes, a `sizes` map with each variant's pixel dimensions, `avg_color`, and the photographer's name and attribution URL; the response also includes a `size_guide` mapping slot widths to variants (hero/banner ~940–1880px → large/large2x, card ~350px → medium, avatar → tiny). `queries: [...]` runs several searches in parallel and returns { queries: { [q]: result } } — `pick:'best'` (default) keeps only the top photo per query, `pick:'all'` the full list; `query` runs a single search. Works without configuration through a shared hosted proxy; a PEXELS_API_KEY env var or x-pexels-key header uses a personal Pexels quota.", {
200
+ query: z.string().optional().describe("Single subject query; returns the full result."),
201
201
  queries: z
202
202
  .array(z.string())
203
203
  .optional()
204
- .describe("Multiple subject queries (one per image slot) to run in parallel — recommended for a page with 2+ images so each only costs ONE round-trip."),
204
+ .describe("Several subject queries (typically one per image slot), run in parallel in one call."),
205
205
  per_page: z.number().int().min(1).max(80).optional().describe("Photos per query (default 5)."),
206
206
  pick: z
207
207
  .enum(["best", "all"])
@@ -261,7 +261,7 @@ export function registerMediaTools(server, allowLocalFiles = true) {
261
261
  return text({ queries: out, size_guide: IMAGE_SIZE_GUIDE });
262
262
  });
263
263
  // 13b) Resolve icon-font names to real inline SVGs --------------------------
264
- server.tool("get_icon_svg", "Resolves icon-font NAMES into real inline SVG markup via the public Iconify API — so a clone reproduces a reference's icons (esp. Google Stitch, which renders icons with a Material Symbols / Font Awesome CLASS, not an image). ingest_html/ingest_url surface those icons as block.icon \"ms:<name>\" (Material Symbols) / \"fa:<name>\" (Font Awesome); pass them here to get the SVG. ACCEPTS: \"ms:verified\", \"fa:chart-line\", a real Iconify id (\"mdi:home\"), or a bare name (assumed Material Symbols); underscores are normalized to hyphens, and Material Symbols resolve to the OUTLINED variant (the Stitch look) with a filled fallback. Returns { icons: { \"<ref>\": { ok, svg, iconify } } }. RENDER each svg as Webcake's native icon element — a RECTANGLE: put the svg in BOTH responsive.desktop.config.svgMask AND responsive.mobile.config.svgMask, set styles.background = the icon color, and keep the box SQUARE (width === height). The svg is only a MASK (its own fill is ignored), so the icon is BLANK without a solid styles.background; the renderer reads each breakpoint's svgMask separately (no fallback) and forces preserveAspectRatio='none' (a non-square box stretches it). No Webcake credentials needed.", {
264
+ server.tool("get_icon_svg", "Resolves icon-font names into inline SVG markup via the public Iconify API (https://iconify.design/docs/api/). Accepts Material Symbols refs (\"ms:verified\"), Font Awesome refs (\"fa:chart-line\"), Iconify ids (\"mdi:home\"), or bare names (treated as Material Symbols); underscores become hyphens, and Material Symbols resolve to the outlined variant with a filled fallback. These are the same `ms:` / `fa:` refs that ingest results report in block.icon. Returns { icons: { \"<ref>\": { ok, svg, iconify } } }. In Webcake an icon renders as an SVG mask on a rectangle element: the SVG goes in responsive.desktop.config.svgMask and responsive.mobile.config.svgMask (each breakpoint is read separately), the visible color comes from styles.background (the SVG's own fill is ignored), and the mask is stretched to the box (preserveAspectRatio='none'), so a square box keeps its proportions. No Webcake credentials needed.", {
265
265
  icons: z
266
266
  .array(z.string())
267
267
  .min(1)
@@ -291,11 +291,11 @@ export function registerMediaTools(server, allowLocalFiles = true) {
291
291
  });
292
292
  });
293
293
  // 13c) Render a page/URL to a screenshot the model can SEE --------------------
294
- server.tool("render_preview", "Renders a PUBLIC URL to a PNG and returns it as an image so the model can SEE the result and compare it visually to the reference — the fidelity-check step of the clone loop (build → see → patch_page → re-check). Pass `page_id` to shoot a created page's preview (/preview/<id>) or `url` for any public page (e.g. the reference you're cloning). full_page defaults to true (whole scrollable page). AGENT-FIRST: if YOU already have a screenshot/browser capability (a shell + headless browser, or a screenshot tool), screenshot the preview URL YOURSELF instead — it's fresh and unlimited; use this tool only when you cannot. ENGINE: zero-config via Microlink's free tier (rate-limited ~50/day PER IP, so heavy looping can hit HTTP 429 — then this returns ok:false and you should SKIP the visual check that round, not fail); a host can set RENDER_SCREENSHOT_BASE (or the x-render-screenshot-base header) to a keyed proxy, or MICROLINK_API_KEY / x-microlink-key for a higher quota. NOTE: a no-domain preview only renders for ~10 minutes after the last publish — call this promptly after create_page/publish_page, and re-publish before re-checking a stale page. TALL PAGES: pass tiles:true to get the page as a STACK of top→bottom band images (each readable at full detail) instead of one full-page image squished small — needs a self-hosted Playwright host (RENDER_SCREENSHOT_BASE); falls back to a single image otherwise. Docs: https://github.com/vuluu2k/webcake-landing-mcp/blob/main/docs/tools.md (render_preview); engine API: https://microlink.io/docs/api/getting-started/overview.", {
294
+ server.tool("render_preview", "Renders a public URL to a PNG screenshot and returns it as an image. `page_id` targets a created page's preview URL (/preview/<id>); `url` targets any public page and takes precedence. full_page (default true) captures the whole scrollable page and `width` sets the viewport; `tiles:true` returns a tall page as several top-to-bottom band images (requires a self-hosted Playwright host via RENDER_SCREENSHOT_BASE, otherwise a single image is returned). Engine: Microlink's free tier by default (API: https://microlink.io/docs/api/getting-started/overview; about 50 requests/day per IP — when rate-limited the result is ok:false with HTTP 429), or a self-hosted screenshot proxy set by RENDER_SCREENSHOT_BASE / x-render-screenshot-base; MICROLINK_API_KEY / x-microlink-key raises the quota. A page published without a custom domain serves its preview for about 10 minutes after the last publish; after that the preview shows 'Preview page is expired'. Docs: https://github.com/vuluu2k/webcake-landing-mcp/blob/main/docs/tools.md", {
295
295
  page_id: z.string().optional().describe("A created page's id — screenshots its /preview/<id> URL (built from the preview base). Provide page_id OR url."),
296
296
  url: z.string().optional().describe("Any public http(s) URL to screenshot (e.g. the reference page being cloned). Wins over page_id."),
297
297
  full_page: z.boolean().optional().describe("Capture the whole scrollable page (default true) vs just the viewport."),
298
- width: z.number().int().min(320).max(2560).optional().describe("Viewport width in px (default 1280; use ~960/1200 to match the page canvas, ~420 for mobile)."),
298
+ width: z.number().int().min(320).max(2560).optional().describe("Viewport width in px (default 1280; ~960 or 1200 matches the desktop page canvas, ~420 the mobile one)."),
299
299
  tiles: z.boolean().optional().describe("Tall pages: return the page as MULTIPLE top→bottom band images (each readable in detail) instead of one squished full-page image. Requires a Playwright host (RENDER_SCREENSHOT_BASE); without one it falls back to a single image."),
300
300
  }, { title: "Render Preview Screenshot", readOnlyHint: true, openWorldHint: true }, async ({ page_id, url, full_page, width, tiles }, extra) => {
301
301
  const headers = extra?.requestInfo?.headers;
@@ -347,7 +347,7 @@ export function registerMediaTools(server, allowLocalFiles = true) {
347
347
  return image(r.dataBase64, r.mimeType ?? "image/png", `Rendered ${target} (${r.bytes} bytes, via ${r.via}). Compare this to the reference: check section order, colors, spacing, image placement, and text. For each mismatch, patch_page the offending element by id, re-publish, then render_preview again until it matches.`);
348
348
  });
349
349
  // 14) Upload images to Webcake -----------------------------------------------
350
- server.tool("upload_images", "Converts external image URLs (typically collected from ingest_html/ingest_url results), data: URIs, or LOCAL FILE PATHS from the user's computer into Webcake-hosted URLs (statics.pancake.vn) by reading/downloading each image and re-uploading it to the Webcake backend via multipart upload (200 MB backend limit). Use this whenever the page is built from a reference HTML/URL (BOTH intents — adapt AND clone), the user supplies their own image URLs, OR the user provides local image files from their machine — pass the path directly in `urls`; NEVER upload a user's local file to a third-party host (catbox, imgur, transfer.sh…) to obtain a URL first. The returned URLs go directly into specials.src — same as search_images results. Processes up to 20 entries per call in parallel, with a 200 MB per-image cap. UPLOAD TARGET: with Webcake credentials (WEBCAKE_JWT) AND an organization, each image is filed into that ORG's MEDIA COLLECTION (bộ sưu tập) — the same library the editor's media picker reads — so the user can re-pick it later; the response marks those entries collection:true with their asset_id. THE ORG IS REQUIRED and must be the same one the page is created in: pass organization_id, or set WEBCAKE_ORG_ID / x-webcake-org-id; it is auto-selected only when the account has exactly ONE org. With 2+ orgs and none chosen this returns ok:false + reason:'organization_required' and the org list — settle the org (ask the user) and re-call, exactly as with create_page. WITHOUT credentials it falls back to the public CDN endpoint: the URLs still work and the page still renders, but the images do NOT appear in any collection (collection:false). UPLOADS BY DEFAULT (dry_run defaults to FALSE — unlike the page-persistence tools, this touches no page data, so the default is the real upload): the call downloads/reads each entry, uploads it, and returns the images map (original URL → hosted URL); WAIT for that map before assembling the page and never fall back to a placeholder for a slot whose upload succeeded. Pass dry_run:true only to preview what would be processed without any network/filesystem activity. Use search_images instead when you need stock photos. Local file paths are only permitted when the MCP server runs locally (stdio mode); on the remote HTTP transport they are rejected per-entry.", {
350
+ server.tool("upload_images", "Re-hosts images on Webcake's CDN (statics.pancake.vn) and returns a map from each original entry to its hosted URL. Accepts up to 20 entries per call, processed in parallel, up to 200 MB each: http(s) image URLs, data:image/…;base64 URIs, and — only when the server runs locally over stdio — local file paths (absolute, ~/, file://, or Windows drive paths; rejected per entry on the remote HTTP transport). Upload target: with Webcake credentials and a resolved organization, each image is filed into that organization's media collection (the library the Webcake editor's media picker shows) and marked collection:true with its asset_id; without credentials the public CDN endpoint is used (the URL works, but the image is not in any collection: collection:false). Organization resolution: organization_id, then WEBCAKE_ORG_ID / x-webcake-org-id, then automatic selection when the account has exactly one organization; with two or more and none given, the result is ok:false with reason:'organization_required' and the organization list. dry_run defaults to false, because this only adds images to the media library and changes no page data; dry_run:true lists what would be processed without any network or filesystem access.", {
351
351
  urls: z
352
352
  .array(z.string())
353
353
  .min(1)
@@ -364,7 +364,7 @@ export function registerMediaTools(server, allowLocalFiles = true) {
364
364
  organization_id: z
365
365
  .string()
366
366
  .optional()
367
- .describe("Organization whose media collection the images are filed into — REQUIRED when the account has 2+ orgs, and it must match the org the page is created in. Omit only to use WEBCAKE_ORG_ID / x-webcake-org-id, or to auto-select when the account has exactly ONE org; with 2+ orgs and none given the call returns reason:'organization_required' with the org list. Ignored when there are no credentials (public CDN fallback)."),
367
+ .describe("Organization whose media collection receives the images — normally the organization the page lives in. Optional when WEBCAKE_ORG_ID / x-webcake-org-id is set or the account has exactly one organization; with two or more and none given, the result is reason:'organization_required' with the organization list. Ignored without credentials (public CDN fallback)."),
368
368
  dry_run: z
369
369
  .boolean()
370
370
  .optional()
@@ -175,19 +175,19 @@ export function registerPersistenceTools(server, domain) {
175
175
  return text(await listOrganizations(config));
176
176
  });
177
177
  // 9) Create page (persist) --------------------------------------------------
178
- server.tool("create_page", "Persists a page source to the configured Webcake backend: creates a NEW page, saves the source, then AUTO-PUBLISHES it (builds the rendered app on the build host + publishes via the editor's publish_html route) so the preview renders immediately — set publish:false to skip, and note the no-domain preview link still expires ~10 minutes after each publish (publish_page with custom_domain gives a permanent URL). A failed auto-publish never fails the create (result.publish says how to retry). Auto-fixes the deterministically-resolvable layout defects first (off-canvas boxes pulled on-canvas; elements below wrapped text pushed down to clear the spill; containers grown to fit) and reports them in auto_fixed — so the saved tree is corrected without a patch round-trip. Then validates. DEFAULTS to dry_run=true (validates, caches the source as draft_id, returns the HTTP request it WOULD send, token masked); dry_run=false to actually create. Accepts draft_id from a previous call (validation failure, dry_run, or a timed-out create) — re-runs from the cached source without re-sending the full JSON. Organization resolution on the real run (dry_run=false): (1) explicit organization_id wins; pass the string 'personal' to save without any org. (2) WEBCAKE_ORG_ID env / x-webcake-org-id header wins. (3) Otherwise list_organizations is called: 0 orgs or lookup fails → personal (no org); exactly 1 org → used automatically (result includes organization_auto_selected:true); 2+ orgs → returns ok:false with the org list and asks the caller to re-call with organization_id. Real writes need WEBCAKE_API_BASE + WEBCAKE_JWT.", {
178
+ server.tool("create_page", "Creates a new page in the user's Webcake account from a page source { page, popup, settings, options, cartConfigs }, saves it, and then publishes it (builds the rendered app on the Webcake build host and publishes it through the editor's publish route) so its preview renders; publish:false skips publishing, and a failed publish does not fail the create (result.publish reports the outcome). Before validating it applies deterministic layout corrections (off-canvas boxes, elements below wrapped text, containers grown to fit) and lists them in auto_fixed. External images and self-hosted font files in the source are re-hosted on Webcake's CDN during the save. dry_run defaults to true: it validates, caches the source under a draft_id, and returns the request it would send with the token masked; dry_run:false performs the create. A draft_id (from a validation failure, dry run, or timed-out create) re-runs from the cached source. Organization: explicit organization_id ('personal' = no organization), then WEBCAKE_ORG_ID / x-webcake-org-id, then automatic — no organizations → personal, exactly one → selected (organization_auto_selected:true), two or more → ok:false with the organization list. A page without a custom domain has a preview link that renders for about 10 minutes after each publish. Requires Webcake credentials.", {
179
179
  source: objectOrJson()
180
180
  .optional()
181
- .describe("Page source { page, popup, settings, options, cartConfigs } (object or JSON string). Required unless draft_id is given. Author elements SPARSE — only id, type, responsive.<bp>.styles for BOTH breakpoints, specials, and real events; OMIT properties/runtime/empty events+children/per-breakpoint config — the server hydrates them from factory defaults (a full node also works)."),
181
+ .describe("Page source { page, popup, settings, options, cartConfigs } (object or JSON string). Required unless draft_id is given. Elements may be sparse — id, type, responsive.<bp>.styles for both breakpoints, specials and real events; omitted properties/runtime/empty events and children/per-breakpoint config are filled from factory defaults (full nodes also work)."),
182
182
  draft_id: z
183
183
  .string()
184
184
  .optional()
185
- .describe("A draft_id from a previous create_page call (validation failure, dry_run=true, or a timed-out/failed create). Loads the cached source — no need to re-send the full JSON. Use for dry-run → real call transitions, fix-after-error rounds, and retrying after a timeout."),
185
+ .describe("A draft_id from a previous create_page call (validation failure, dry_run=true, or a timed-out/failed create). Loads the cached source, so the full JSON is not re-sent; covers the dry-run → real call step, fixes after errors, and retries after a timeout."),
186
186
  name: z.string().optional().describe("Page name (default 'AI Page')."),
187
187
  organization_id: z
188
188
  .union([z.string(), z.number()])
189
189
  .optional()
190
- .describe("Organization to create the page in (id from list_organizations). Pass the string 'personal' to explicitly save without any organization (skips auto-resolution). Omit to fall back to WEBCAKE_ORG_ID env; if that is also unset, the server calls list_organizations: 1 org → auto-selected; 2+ orgs → returns the list and asks you to pick; 0 orgs → personal."),
190
+ .describe("Organization to create the page in (an organization id). The string 'personal' saves without any organization and skips auto-resolution. When omitted: WEBCAKE_ORG_ID / x-webcake-org-id, else automatic — one organization is selected, two or more return the list (ok:false), none means personal."),
191
191
  dry_run: z
192
192
  .boolean()
193
193
  .optional()
@@ -450,7 +450,7 @@ export function registerPersistenceTools(server, domain) {
450
450
  // not limited to the 50 most-recent). If that route is missing (older backend
451
451
  // → 404) it FALLS BACK to listing pages and filtering client-side by name/id
452
452
  // (domain search is unavailable in the fallback — list_pages omits domains).
453
- server.tool("find_pages", "Searches the account's pages by name, domain, and/or page id so you can locate the page to edit, then pass its id to get_page → update_page/add_section. Filters are AND-combined (e.g. name='sale' + domain='shop.com'). Each result includes id, name, organization_id, custom_domain, default_domain, updated_at. With no filters it returns the most-recent pages (like list_pages). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
453
+ server.tool("find_pages", "Searches the account's pages by name, domain and/or page id. Filters are AND-combined; name and domain are case-insensitive substrings, and domain matches custom_domain or default_domain. Each result includes id, name, organization_id, custom_domain, default_domain and updated_at. With no filters it returns the most recently updated pages. Requires Webcake credentials.", {
454
454
  name: z.string().optional().describe("Case-insensitive substring of the page name to match."),
455
455
  domain: z
456
456
  .string()
@@ -487,7 +487,7 @@ export function registerPersistenceTools(server, domain) {
487
487
  });
488
488
  });
489
489
  // 11) Get page (read source) ------------------------------------------------
490
- server.tool("get_page", "Fetches an existing page's decoded source tree { page, popup, settings, options, cartConfigs } plus name and organization_id. By DEFAULT the source is COMPACTED: boilerplate every element shares (properties/runtime/empty events+children/per-breakpoint config + factory-default style keys) is stripped, leaving the sparse authoring shape — edit it and send it back as-is; update_page/patch_page re-hydrate from factory defaults. Pass compact:false for the raw stored tree. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
490
+ server.tool("get_page", "Fetches an existing page's source tree { page, popup, settings, options, cartConfigs } with its name, organization_id and source_version. By default the tree is compacted: boilerplate shared by every element (properties, runtime, empty events/children, per-breakpoint config and factory-default style keys) is stripped, giving the same sparse shape the save tools accept and re-hydrate. compact:false returns the raw stored tree. Requires Webcake credentials.", {
491
491
  page_id: z.string().describe("The page id (from list_pages or a URL)."),
492
492
  compact: z
493
493
  .boolean()
@@ -516,7 +516,7 @@ export function registerPersistenceTools(server, domain) {
516
516
  });
517
517
  });
518
518
  // 12) Update page (edit existing) -------------------------------------------
519
- server.tool("update_page", "Overwrites an EXISTING page's source with an edited tree (source-only; re-render in the editor for preview/publish). Validates first. DEFAULTS to dry_run=true (validates, caches the source as draft_id, previews the request, token masked); dry_run=false to actually save. Accepts draft_id from a previous call (dry_run, or a timed-out/failed update) — re-runs from the cached source without re-sending the full JSON. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
519
+ server.tool("update_page", "Replaces an existing page's stored source with an edited tree (source only; the published build is not refreshed). Validates first. Concurrency check: the save is refused with page_changed_externally when the page was edited elsewhere since it was last read (base_version, or the version the server recorded), or with unverified_overwrite when an unverified save would delete live element ids; force:true overrides. dry_run defaults to true: it validates, caches the source under a draft_id and returns the masked request; dry_run:false saves. A draft_id from a dry run or a failed/timed-out update re-runs from the cached source. Requires Webcake credentials.", {
520
520
  page_id: z.string().optional().describe("The page id to update (must be owned by the account). Required unless draft_id is given (the page_id is stored in the draft)."),
521
521
  source: objectOrJson()
522
522
  .optional()
@@ -524,15 +524,15 @@ export function registerPersistenceTools(server, domain) {
524
524
  draft_id: z
525
525
  .string()
526
526
  .optional()
527
- .describe("A draft_id from a previous update_page call (dry_run=true or a timed-out/failed update). Loads the cached source — no need to re-send the full JSON. Use for dry-run → real call transitions and retrying after a timeout."),
527
+ .describe("A draft_id from a previous update_page call (dry_run=true or a timed-out/failed update). Loads the cached source, so the full JSON is not re-sent; covers the dry-run → real call step and retries after a timeout."),
528
528
  base_version: z
529
529
  .string()
530
530
  .optional()
531
- .describe("The source_version get_page returned for this page. When it no longer matches the live page the save is REJECTED instead of reverting whatever was edited in the Webcake editor meanwhile. Optional — omit it and the server uses the version it recorded on your last read/write of this page."),
531
+ .describe("The page's source_version as returned by get_page. When it no longer matches the live page, the save is rejected rather than reverting edits made in the Webcake editor meanwhile. Optional — when omitted, the server uses the version it recorded at this session's last read or write of the page."),
532
532
  force: z
533
533
  .boolean()
534
534
  .optional()
535
- .describe("Default false. true = save even when the page changed outside this session, discarding those changes. Only pass it after the user has said to."),
535
+ .describe("Default false. true saves even when the page changed outside this session, discarding those outside changes."),
536
536
  dry_run: z.boolean().optional().describe("Default TRUE — validate, cache the source as draft_id, and preview without sending. Set false to actually save."),
537
537
  }, { title: "Update Webcake Page (Overwrite)", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ page_id, source, draft_id, base_version, force, dry_run }, extra) => {
538
538
  const isDry = dry_run !== false;
@@ -695,15 +695,15 @@ export function registerPersistenceTools(server, domain) {
695
695
  return Array.isArray(v) ? v : [v];
696
696
  };
697
697
  const sectionLabel = (s) => s?.properties?.name ?? s?.specials?.name ?? s?.id ?? s?.type ?? "section";
698
- server.tool("add_section", "Appends one or more SECTIONS to an existing page WITHOUT re-sending the whole source — the incremental-build path that avoids large create_page payloads. The backend appends section(s) to the END of `page` server-side and rejects duplicate element ids, so the caller sends only the new section(s) (no whole-source get+put). DEFAULTS to dry_run=true (validates the section(s) + previews the request; caches the payload as draft_id so you never have to re-send sections between dry-run → real call); dry_run=false to actually append. On validation failure also returns a draft_id — call patch_page({ draft_id, patches, dry_run:false }) to fix ONLY the bad elements without rebuilding the batch. Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
698
+ server.tool("add_section", "Appends one or more section nodes to the end of an existing page's `page` array, server-side: only the new sections are sent, and the backend rejects element ids that already exist on the page. The sections are validated first. dry_run defaults to true: it validates, caches the payload under a draft_id and previews the request; dry_run:false appends. A validation failure also returns a draft_id, which patch_page accepts for element-level fixes. Section-level saves cannot change page settings, so font weights that need new font links are reported in font_weights_notice. Requires Webcake credentials.", {
699
699
  page_id: z.string().describe("The page id to append to (from create_page or list_pages; must be owned by the account)."),
700
700
  sections: objectsOrJson()
701
701
  .optional()
702
- .describe("One section node, or an array of section nodes, to append to the END of `page` (object/array or JSON string). Each is a normal section element { id, type:'section', responsive, children, … } with a UNIQUE id; they stack vertically after the existing sections. Author SPARSE nodes — omit properties/runtime/empty events+children/per-breakpoint config; the server hydrates them from factory defaults. Required unless draft_id is supplied."),
702
+ .describe("One section node, or an array of section nodes, appended to the end of `page` (object/array or JSON string). Each is a normal section element { id, type:'section', responsive, children, … } with a unique id; sections stack vertically after the existing ones. Nodes may be sparse — omitted properties/runtime/empty events and children/per-breakpoint config are filled from factory defaults. Required unless draft_id is supplied."),
703
703
  draft_id: z
704
704
  .string()
705
705
  .optional()
706
- .describe("A draft id returned by a previous add_section call (dry_run=true or validation failure). Loads the cached section payload — no need to re-send the sections JSON. Use for dry-run → real call transitions and fix-after-error rounds."),
706
+ .describe("A draft id returned by a previous add_section call (dry_run=true or validation failure). Loads the cached section payload, so the sections are not re-sent."),
707
707
  dry_run: z
708
708
  .boolean()
709
709
  .optional()
@@ -981,12 +981,12 @@ export function registerPersistenceTools(server, domain) {
981
981
  }
982
982
  return touched;
983
983
  };
984
- server.tool("patch_page", "Edits a page by element id WITHOUT re-sending the whole source — the surgical-edit and fix-after-error path. Targets EITHER a live page (page_id) OR a cached draft source (draft_id). Draft sources come from: (a) create_page — failed validation or timed-out network call → patched/committed tree is CREATED as a new page once valid; (b) add_section dry_run or validation/network failure → patched/committed shell is APPENDED to the stored page once valid; (c) update_page or live-page patch_page — timed-out/failed network call → re-committed via updatePageSource. Send only a list of per-element ops; the MCP loads the source, applies them, validates the WHOLE merged tree (blocks on errors), and saves. Ops: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} (shallow-merges; op defaults to 'update'; `type` fixes a wrong element type; update CANNOT delete an existing/stray key — schema 'additional properties' errors need op:'replace'), {op:'replace',id,element}, {op:'remove',id}, {op:'add',parent_id,element}. EMPTY/OMITTED patches with a draft_id = commit the cached draft as-is (skip apply, still validate, then honor dry_run) — this is the RETRY PATH after a timeout. Use this to fix the elements a failed create_page/update_page/add_section reported instead of rebuilding. DEFAULTS to dry_run=true (loads + merges + validates + previews, no write); dry_run=false to save. Needs WEBCAKE_API_BASE + WEBCAKE_JWT (a draft_id sections-patch only needs creds to actually append; a page_id patch reads the live page so needs creds even on dry_run).", {
985
- page_id: z.string().optional().describe("Edit a LIVE page by id (from create_page, list_pages, or find_pages; must be owned by the account). Provide page_id OR draft_id. For a sections or update draft_id you may also pass page_id here to override the stored page target."),
984
+ server.tool("patch_page", "Edits a page by element id without sending the whole source. The target is either a live page (page_id) or a cached draft (draft_id) returned by a failed or dry-run create_page, add_section or update_page call. Ops: {op:'update', id, type?, specials?, styles?:{desktop?,mobile?}, config?:{desktop?,mobile?}, events?, properties?} shallow-merges fields (op defaults to 'update'; it cannot delete existing keys), {op:'replace', id, element} swaps the node, {op:'remove', id} deletes it, and {op:'add', parent_id, element} appends a child to a container; `element` may be sparse. The server loads the source, applies the ops, validates the whole merged tree (errors block the save) and saves: a live page is updated in place, a create draft becomes a new page, a section draft is appended, and an update draft is re-saved. With a draft_id and no patches, the cached draft is committed as-is (the retry after a timeout). dry_run defaults to true (load, merge, validate, preview; no write); dry_run:false saves. Requires Webcake credentials; a page_id patch reads the live page, so it needs them even on a dry run.", {
985
+ page_id: z.string().optional().describe("A live page id to edit (must be owned by the account). Provide page_id OR draft_id; with a sections or update draft_id, page_id overrides the stored target page."),
986
986
  draft_id: z.string().optional().describe("Commit or fix a CACHED source: from create_page (failed/timed-out → new page created once valid), add_section (dry_run or failure → sections appended), or update_page/live-page patch (timed-out/failed → updatePageSource retried). The originating tool's error/dry_run response returns draft_id. Provide page_id OR draft_id. Empty/omitted patches = commit the cached draft as-is (the RETRY PATH after a timeout)."),
987
987
  patches: objectsOrJson()
988
988
  .optional()
989
- .describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; set `type` to fix a wrong element type; update MERGES — it cannot DELETE an existing/stray key, so 'must NOT have additional properties' errors need op:'replace' with a clean node); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a SPARSE node (id/type/styles/specials/events only) — the server hydrates omitted boilerplate from factory defaults. OMIT (or pass empty array) when draft_id is given and you just want to commit/retry the cached draft as-is."),
989
+ .describe("One op object or an array of them (object/array or JSON string). Each targets an element by id: {op:'update',id,type?,specials?,styles?:{desktop?,mobile?},config?:{desktop?,mobile?},events?,properties?} merges fields into the element (op may be omitted; `type` changes the element type; a merge cannot delete an existing key, so schema 'must NOT have additional properties' errors are fixed with op:'replace' and a clean node); {op:'replace',id,element} swaps the node; {op:'remove',id} deletes it; {op:'add',parent_id,element} appends a child to a container. `element` may be a sparse node (id/type/styles/specials/events); omitted boilerplate is filled from factory defaults. Empty or omitted with a draft_id: the cached draft is committed as-is."),
990
990
  force: z
991
991
  .boolean()
992
992
  .optional()
@@ -1400,7 +1400,7 @@ export function registerPersistenceTools(server, domain) {
1400
1400
  });
1401
1401
  });
1402
1402
  // 15) Publish page (go live) -------------------------------------------------
1403
- server.tool("publish_page", "Publishes an EXISTING page LIVE via the editor's publish_html route: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; prod default https://build.webcake.io, override with WEBCAKE_BUILD_BASE env / x-webcake-build-base header), then creates/updates the PagePublishedV2 record — the record ALL public serving reads. With custom_domain the page goes live at that domain (it must already point at Webcake); OMIT custom_domain to reuse the page's currently-attached domain (mirrors the editor's publish modal — republishing keeps the page live at the same URL), falling back to a find_pages lookup by id. WITHOUT any domain (the page has none, or you pass custom_domain='') there is NO permanent public URL: the returned preview link (<previewBase>/preview/<page_id>) only renders for ~10 minutes after the publish, then shows 'Preview page is expired' — tell the user to attach a domain for a lasting URL. If no build host is configured or the build fails, falls back to the LEGACY source-only publish route with a warning (saves a version; nothing goes live; the page stays blank). DEFAULTS to dry_run=true (network-free: does NOT call the build host on dry_run). Needs WEBCAKE_API_BASE + WEBCAKE_JWT.", {
1403
+ server.tool("publish_page", "Publishes an existing page: builds the rendered app on the Webcake build host (POST <buildBase>/render/build; https://build.webcake.io in production, overridable with WEBCAKE_BUILD_BASE / x-webcake-build-base) and then creates or updates the published record that public serving reads. With custom_domain (already pointed at Webcake) the page is served at that domain; when omitted, the page's currently attached domain is reused; with no domain, or custom_domain:'', only the preview link (<previewBase>/preview/<page_id>) is returned, and it renders for about 10 minutes after the publish. Without a build host, or if the build fails, it falls back to a source-only publish that saves a version but serves nothing, and returns a warning. dry_run defaults to true and makes no network calls; dry_run:false builds and publishes. Requires Webcake credentials.", {
1404
1404
  page_id: z.string().describe("The page id to publish (must be owned by the account)."),
1405
1405
  custom_domain: z
1406
1406
  .string()
@@ -25,12 +25,12 @@ export function registerReferenceTools(server, domain) {
25
25
  return text({ total: domain.elementTypes.length, categories: byCategory });
26
26
  });
27
27
  // 3) Get element ------------------------------------------------------------
28
- server.tool("get_element", "Returns detailed usage for one element type — or for many in a single call (BATCH MODE): summary, when to use it, key `specials` fields, a SPARSE skeleton node (the exact shape to emit — the server hydrates omitted boilerplate), and (for common types) a filled example. Pass `types: [...]` to fetch a whole section's worth of element types at once (e.g. ['section','text-block','image-block','button']) — returns { elements: { [type]: details } } and saves a round-trip per type. `type` (single) returns the doc directly for backward compatibility.", {
29
- type: z.string().optional().describe("Single element type — backward-compat. Prefer `types` when fetching more than one."),
28
+ server.tool("get_element", "Returns usage details for one element type, or for several via `types: [...]`: a summary, when it applies, key `specials` fields, a sparse skeleton node (the server fills omitted boilerplate), and for common types a filled example. `types` returns { elements: { [type]: details } }; `type` returns a single doc.", {
29
+ type: z.string().optional().describe("Single element type."),
30
30
  types: z
31
31
  .array(z.string())
32
32
  .optional()
33
- .describe("Multiple element types to fetch in one call (recommended for a section that needs several types, e.g. ['section','text-block','button','form','input'])."),
33
+ .describe("Several element types fetched in one call, e.g. ['section','text-block','button','form','input']."),
34
34
  }, { title: "Get Element Details", readOnlyHint: true, openWorldHint: false }, async ({ type, types }) => {
35
35
  const list = types && types.length ? types : type ? [type] : [];
36
36
  if (list.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.5.9",
3
+ "version": "1.5.11",
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
  "mcpName": "io.github.vuluu2k/webcake-landing-mcp",
6
6
  "type": "module",