webcake-landing-mcp 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -187,7 +187,7 @@ server, the `login` browser flow (+ backend contract), and how to grab a JWT by
187
187
  |-------|-------|-------|
188
188
  | **Reference** | `get_generation_guide` · `list_elements` · `get_element` · `get_page_schema` | nothing |
189
189
  | **Generation** | `new_element` · `new_page_skeleton` · `validate_page` · `layout` (exact centering/row/grid/stack coordinates, both breakpoints) | nothing |
190
- | **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) · `render_preview` (screenshot a page/URL so the model can see + compare it) | nothing |
190
+ | **Media** | `search_images` (real Pexels stock photos) · `get_icon_svg` (Material Symbols / Font Awesome icon names → inline SVG via Iconify) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine — files into your media collection when Webcake creds **and** an org are available; the org is required and must match the page's, so settle it up front — 1 org auto-selects, 2+ with none chosen returns `organization_required` + the list; no JWT → the public CDN) · `render_preview` (screenshot a page/URL so the model can see + compare it) | nothing |
191
191
  | **Ingest** | `ingest_html` · `ingest_url` (recreate an existing page) | nothing |
192
192
  | **Persistence** | `list_organizations` · `create_page` · `list_pages` · `find_pages` · `get_page` · `update_page` · `add_section` · `patch_page` · `publish_page` | `WEBCAKE_API_BASE` + `WEBCAKE_JWT` |
193
193
 
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.3.0",
4
+ "d": "17/07/2026",
5
+ "type": "Added",
6
+ "en": "upload_images and the automatic image re-hosting on create_page, update_page, add_section, and patch_page now file uploaded images into the…",
7
+ "vi": "upload_images và bước tự động re-host ảnh khi lưu trong create_page, update_page, add_section, patch_page nay lưu ảnh đã upload vào bộ sưu tập…"
8
+ },
2
9
  {
3
10
  "v": "1.2.1",
4
11
  "d": "21/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "en": "The server web guide (GET /) now includes a bilingual video guides section with per-app installation walkthrough cards (Claude, Codex, Antigravity);…",
35
42
  "vi": "Web guide của server (GET /) nay có thêm mục video hướng dẫn cài đặt song ngữ với các card theo từng ứng dụng (Claude, Codex, Antigravity); card có…"
36
- },
37
- {
38
- "v": "1.0.82",
39
- "d": "16/06/2026",
40
- "type": "Added",
41
- "en": "render_preview now accepts a tiles parameter: pass tiles:true to receive the page as a stack of top-to-bottom horizontal band images instead of one…",
42
- "vi": "render_preview nay nhận tham số tiles: truyền tiles:true để nhận trang dưới dạng stack các ảnh dải ngang từ trên xuống dưới thay vì một ảnh…"
43
43
  }
44
44
  ]
@@ -7,7 +7,8 @@ import { ELEMENT_TYPES, CATALOG_SUMMARY } from "./elements/index.js";
7
7
  export const INSTRUCTIONS = `webcake-landing builds and edits Webcake landing pages (the editor "page_source" JSON). Act as a PROFESSIONAL landing-page designer doing client work: consult to understand the brief, LOCK a design system (exact palette + type scale + spacing grid + button/card specs derived from the customer's primary color), then build EVERY element from those tokens so the page looks studio-made and consistent — never invent a color/size/radius per element. The customer is an ORDINARY person, not a designer: talk to them only in plain everyday words and visual outcomes ("tông cam ấm, chữ to dễ đọc, ảnh lớn ở đầu trang"), NEVER jargon (hero/CTA/palette/type scale/rgba/8px grid) — read the intent behind how they phrase it and translate it into the design yourself; keep all technical terms internal.
8
8
 
9
9
  RULES (follow for every request):
10
- - INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization. CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
10
+ - APPROVE THE TOOLS ONCE, UP FRONT — building one page fires MANY tool calls (get_element, search_images, validate_page, create_page, add_section, patch_page, publish_page…), and a per-call permission prompt stops the build dead each time. So in the SAME message as your intake questions, ask the user ONCE to allow this server's tools for the session — in plain words, e.g. "Để mình dựng trang liên tục không bị ngắt quãng, bạn bấm cho phép (Allow / Always allow) cho bộ công cụ webcake-landing một lần nhé — mình sẽ cần gọi khá nhiều bước." Say it once, then get on with the intake; do NOT re-ask, and never treat it as a blocker: if the user prefers approving each call, just build anyway. Mutating steps still follow the dry-run rules below — blanket tool approval is about not being interrupted, NOT permission to skip confirmation of the design before you save.
11
+ - INTAKE FIRST — do this EVERY time, even for a "quick"/"test" page. Do NOT jump straight to new_page_skeleton/create_page on the same turn as the request: ask the essentials, restate an outline, get a "yes", THEN build. Ask ONE short batch (3–6, with sensible defaults so the user answers fast) enough to understand the page's PURPOSE, name, look and layout: page purpose/goal, brand/page name, what they sell + price (sales/ads pages), primary color + logo/branding, sections & layout in order, primary CTA + destination, desktop+mobile or mobile-only, which organization (call list_organizations FIRST — only ask when the account has 2+ orgs; the org must be settled here, before any image or build work). CONSULT, don't interrogate: SUGGEST so the user reacts to something concrete — propose a section flow (pick the archetype matching the page type) + a look (hero treatment + color/tone), and when the user is vague offer 2–3 directions to choose from; proactively suggest sections that fit their goal (social-proof, FAQ, countdown), but ask, don't silently add. Then restate the proposed design (section flow + CTA + color/tone) and WAIT for the user's confirmation, iterating until it matches their intent, before generating. Never assume or silently placeholder the page name, product, price, or colors — ask; only placeholder a core fact when the user explicitly declines to give it.
11
12
  - ASK for any real data the page will display — never invent it, and don't silently placeholder it. This includes: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, and exact stats/social-proof numbers. If a value the page needs is missing, ASK the user for it (in intake, or pause and ask before generating). Use a clearly-labelled placeholder ONLY when the user explicitly says to skip it — then tell them exactly what to fill in.
12
13
  - LANGUAGE: write ALL page copy in the SAME language the user is chatting in, with FULL, CORRECT diacritics/accents. For Vietnamese, every word MUST carry its proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025") — NEVER emit accent-stripped "không dấu" text. Never romanize or drop accent marks from any language.
13
14
  - ALWAYS call validate_page and fix every error before create_page / update_page. WARNINGS ARE A FIX LIST, NOT NOISE: each warning is a visible defect the customer will see (text spilling onto the element below, off-canvas boxes, empty bands, missing field_name, dead event targets). Fix every warning too — before the first save when building new, or via patch_page right after saving an edit — and re-validate until the list is empty. Only a warning you can demonstrate is a false positive may remain (name it to the user and say why). Never tell the user the page is done while warnings stand.
@@ -24,7 +25,7 @@ RULES (follow for every request):
24
25
  COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
25
26
  A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~2 h.
26
27
  - DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
27
- - Organizations: call list_organizations before saving. If the account has exactly ONE org, create_page auto-selects it — no need to ask. If there are MULTIPLE orgs, show them and ask the user which to use (is_default is the suggested default); pass the chosen organization_id to create_page. Pass organization_id:"personal" only when the user explicitly wants no org. create_page enforces this itself (it refuses to guess between multiple orgs). Endpoints are owner-scoped (only the account's own pages).
28
+ - ORGANIZATION FIRST — the org is REQUIRED for the API and must be settled in INTAKE, before any image or build work. Call list_organizations at the START (part of the same first batch as your intake questions). Exactly ONE org → it is auto-selected everywhere, no need to ask. MULTIPLE orgs → show them and ask the user which to use (is_default is the suggested default) as one of your intake questions, then pass that SAME organization_id to create_page AND to upload_images. Both refuse to guess: they return ok:false with reason:"organization_required" and the org list. Pass organization_id:"personal" only when the user explicitly wants no org. Endpoints are owner-scoped (only the account's own pages). WHY IT MUST COME FIRST: the org decides WHICH media collection the page's images are filed into, so an image uploaded before the org is settled has no collection to go into — do NOT call search_images/upload_images or start building until the org is known. One org, chosen once, used for the page and every image on it.
28
29
  - REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements using role→element hints (hero → section with background image/overlay + heading + paragraph + button; features → group blocks with icon/title/text; stats bar → group with heading+text per stat; pricing → group with text list + button; footer → section with text + links). When the reference contains a composite widget (phone/device mockup, chat thread, mini dashboard, browser frame) → rebuild it as ONE html-box (clone its HTML with all styles inlined), not as element soup. (2) HTML string → call ingest_html(html, detail:'full') to get the richer AST when cloning; use detail:'compact' (default) for a layout-only reference. (3) URL → call ingest_url(url, detail:'full') for the same richer AST. (4) GOOGLE STITCH design (when a Stitch MCP is connected and the user points at a Stitch screen/project) → bridge the two MCPs: call the Stitch tool get_screen, take the returned htmlCode.downloadUrl, and pass THAT url to ingest_url(detail:'full') (or fetch the HTML and use ingest_html) — Stitch output is a Tailwind-CDN page, so the AST comes back with a token-named palette + design_tokens (the real spacing/radii/type-scale) + gradients (Tailwind gradient utilities reconstructed as linear-gradient(...) with resolved color stops — apply them as the element's background, e.g. on CTA/hero) + per-section hover_effects (the interactions Stitch packs in: scale/image-zoom/lift/underline/color-change — REPRODUCE them via Webcake hover events: change_color (change_color_type text/background/border) for color/underline, the button's hovered* specials or animation_hover for scale/lift/zoom — don't ship a static page); LOCK the design system from those tokens, reuse the page's image URLs (they auto-host on save — Stitch's googleusercontent images included), then create_page. ABSOLUTE-CANVAS builder exports (LadiPage-family / Webcake-published HTML) are AUTO-DETECTED by ingest_html/ingest_url and converted DETERMINISTICALLY into a ready-to-save source (folded into the response as source + clone_notes + clone_notice, the per-element geometry summarized to canvas_summary) — a faithful 1:1 clone on the matching 420/960 canvas: pass that source STRAIGHT to create_page (dry_run:true first to validate, then dry_run:false) instead of hand-rebuilding; its images auto-host on save (no upload_images), and clone_notes lists the few lossy approximations (fixed/floating elements, svg-less shapes, skipped social-proof toasts) to patch_page afterward. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts), a token-named palette + design_tokens (for Tailwind-CDN pages like Google Stitch, lifted from the page's tailwind.config: palette names every color token e.g. primary→#a43b38, and design_tokens carries the resolved spacing grid + corner radii + TYPE SCALE e.g. display-lg→48px — rebuild sizing/spacing/color FROM these tokens, not by guessing), background_images from stylesheets, gradients (incl. reconstructed Tailwind gradient backgrounds), per-section hover_effects (reproduce them — via hover events, or the custom-CSS escape hatch below), and in full mode: per-section blocks (cards/tiles/steps with title/body/image/cta) and li lists — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). IMAGES FROM THE REFERENCE ARE THE USER'S ASSETS — for BOTH intents (adapt AND clone), put every real image URL found in the AST (images, background_images, og_image, canvas src/background) straight into the corresponding slot's specials.src / url(...) background / gallery link; the save AUTO-HOSTS it to the Webcake CDN, so you do NOT need to call upload_images first — just carry the URL through un-altered (never drop it, never swap it for a search_images stock photo or a placeholder). Call search_images ONLY for slots that have no source image in the reference. intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt' (adapt rewrites TEXT, not the imagery). The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
29
30
 
30
31
  MODEL (essentials):
@@ -37,7 +38,7 @@ MODEL (essentials):
37
38
  - PREMIUM CRAFT (read "sang"): generous whitespace (don't cram; ~48–72px above each band's first element, ≥16–24px between elements); clear type scale (H1 40–56 / body 16–18, big jump); ONE accent used sparingly + neutrals; snap spacing to an 8px grid; reuse the same content width / margin / card+button radius across sections.
38
39
  - 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.
39
40
  - 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).
40
- - IMAGES: include them (hero/product, feature icons, about photo). The server AUTO-HOSTS external image URLs on every save: any real http(s) image URL you put in specials.src / a url(...) background / gallery item.link / video poster is downloaded and re-hosted to the Webcake CDN automatically by create_page/update_page/add_section/patch_page (the result's rehost field reports how many) — so you do NOT have to pre-call upload_images for reference or web images. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL → put the REAL source URL straight into specials.src and let the save host it (use those exact images, never swap them for stock photos; the original URL must reach the save un-altered — NEVER replace a real source image URL with a placeholder). The ONE case you MUST still call upload_images yourself: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images urls — the save can't read local files; NEVER upload a user's local file to a third-party host like catbox or imgur first), then use the returned URL. (2) only for slots with NO source image → 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); (3) if search_images returns ok:false / is unreachable / has no fitting photo → find a real image YOURSELF using whatever web search/fetch capability you have (brand site, product page, free-to-use source) and re-host it via upload_images; (4) a PLACEHOLDER sized to the box (https://placehold.co/<width>x<height>) is the LAST resort, only after (2) AND (3) both failed. (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.
41
+ - IMAGES: include them (hero/product, feature icons, about photo). The server AUTO-HOSTS external image URLs on every save: any real http(s) image URL you put in specials.src / a url(...) background / gallery item.link / video poster is downloaded and re-hosted automatically by create_page/update_page/add_section/patch_page (the result's rehost field reports how many) — so you do NOT have to pre-call upload_images for reference or web images. WHERE THEY LAND: with credentials AND the settled org, every 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; rehost.collection:true (and images[x].collection:true from upload_images) confirms it. This is why the org must be settled before any image work (see ORGANIZATION FIRST). With no credentials the image goes to the public CDN instead — the URL works and the page renders, but it is absent from every collection (collection:false); that is a server-setup issue to report, not something to fix by hand-uploading elsewhere. SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL → put the REAL source URL straight into specials.src and let the save host it (use those exact images, never swap them for stock photos; the original URL must reach the save un-altered — NEVER replace a real source image URL with a placeholder). The ONE case you MUST still call upload_images yourself: LOCAL FILE PATHS from the user's computer (pass the path directly in upload_images urls — the save can't read local files; NEVER upload a user's local file to a third-party host like catbox or imgur first), then use the returned URL. (2) only for slots with NO source image → 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); (3) if search_images returns ok:false / is unreachable / has no fitting photo → find a real image YOURSELF using whatever web search/fetch capability you have (brand site, product page, free-to-use source) and re-host it via upload_images; (4) a PLACEHOLDER sized to the box (https://placehold.co/<width>x<height>) is the LAST resort, only after (2) AND (3) both failed. (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.
41
42
 
42
43
  - BEYOND ELEMENT CAPABILITY: when a reference effect can't be expressed with an element's built-in specials (hover scale/lift/zoom & transitions, gradients, glassmorphism/backdrop-blur, custom shadows, gradient/clipped text, keyframe animations outside the 9 entrance types) DON'T drop it — use the escape hatches so the page is as complete as possible: element specials.custom_css (extra DECLARATIONS in #w-<id>{…}; declarations-only) + specials.custom_class (both need specials.customAdvance:true) for per-element styling, and page settings.extra_css (a full raw stylesheet in <head> — where :hover/@keyframes/media-queries live, target #w-<element id>) + settings.extra_script (raw JS) + settings.bhet/bbet (raw HTML blocks at end of <head>/<body> — for webfont <link>s, <meta>/verification, analytics/pixels, chat widgets, third-party embeds). get_generation_guide has the recipes. SAFETY (custom is raw + global → sloppy custom BREAKS the whole UI): SCOPE every extra_css rule to "#w-<id>" or a specials.custom_class — NEVER a bare tag / "*" / a Webcake-internal class (.section-container/.rectangle-css/.text-block-css/.group-*…); keep custom_css to VISUAL props only (no position/top/left/width/height/display/float); close every bhet/bbet tag; don't restructure "#w-…" elements in extra_script. validate_page flags unscoped selectors, layout props in custom_css, unbalanced braces/tags, missing customAdvance, and CSS/JS in the wrong field — fix every such warning.
43
44
  - PREVIEW vs PUBLISH: for review share the EDITOR url (the builder renders the raw source). The editor_url SIGNS THE BROWSER IN automatically (it routes through the builder's /transport with the account token), so it works even when the user isn't logged in — but for the same reason it must go to the PAGE OWNER ONLY, never into anything public. create_page AUTO-PUBLISHES on success (builds the rendered app + publish_html), so a fresh page's preview_url renders right away — but the preview host (preview.localhost:5800 local / staging.webcake.me staging / www.webcake.me prod) only serves it for ~10 MINUTES after the last publish, then shows "Preview page is expired". The EDIT routes (update_page/add_section/patch_page) store source only — after finishing a round of edits, run publish_page({ page_id, dry_run:false }) to rebuild the rendered app (else the preview shows the STALE pre-edit build). ONLY a custom_domain (publish_page({ page_id, custom_domain, dry_run:false })) gives a permanent public URL; without one the page has just the ephemeral preview link — say so and suggest attaching a domain the user already points at Webcake.
@@ -16,30 +16,38 @@ const REDIS_PREFIX = "wcl:rehost:";
16
16
  // Hosted URLs are effectively permanent on the CDN; cap Redis growth with a long TTL.
17
17
  const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
18
18
  const memory = new Map();
19
- /** Look up the hosted URL previously stored for `url`, or null on a miss. */
20
- export async function rehostGet(url) {
19
+ /**
20
+ * Entries are namespaced by SCOPE — `org:<id>` when the upload files an Asset
21
+ * into that org's media collection, else `public`. The hosted URL is
22
+ * content-addressed and would be byte-identical across orgs, but the Asset row
23
+ * is per-org: sharing one key would let a second org hit the cache and skip the
24
+ * upload, leaving the image absent from ITS collection.
25
+ */
26
+ const key = (url, scope) => `${REDIS_PREFIX}${scope}:${url}`;
27
+ /** Look up the hosted URL previously stored for `url` in `scope`, or null on a miss. */
28
+ export async function rehostGet(url, scope = "public") {
21
29
  const redis = getRedis();
22
30
  if (redis) {
23
31
  try {
24
- return await redis.get(REDIS_PREFIX + url);
32
+ return await redis.get(key(url, scope));
25
33
  }
26
34
  catch (e) {
27
35
  console.error("[rehost-cache] redis get failed, using memory:", e?.message ?? e);
28
36
  }
29
37
  }
30
- return memory.get(url) ?? null;
38
+ return memory.get(key(url, scope)) ?? null;
31
39
  }
32
- /** Remember that `url` is now hosted at `hosted`. */
33
- export async function rehostSet(url, hosted) {
40
+ /** Remember that `url` is now hosted at `hosted` within `scope`. */
41
+ export async function rehostSet(url, hosted, scope = "public") {
34
42
  const redis = getRedis();
35
43
  if (redis) {
36
44
  try {
37
- await redis.set(REDIS_PREFIX + url, hosted, "PX", TTL_MS);
45
+ await redis.set(key(url, scope), hosted, "PX", TTL_MS);
38
46
  return;
39
47
  }
40
48
  catch (e) {
41
49
  console.error("[rehost-cache] redis set failed, using memory:", e?.message ?? e);
42
50
  }
43
51
  }
44
- memory.set(url, hosted);
52
+ memory.set(key(url, scope), hosted);
45
53
  }
@@ -21,6 +21,30 @@ function timeoutOrNetworkError(url, e) {
21
21
  return { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
22
22
  }
23
23
  const UPLOAD_FILE_ENDPOINT = "/external/upload_file";
24
+ /**
25
+ * The builder's OWN media-library upload (AssetsController.upload, router scope
26
+ * `host: "builder."` → `/api/persona/upload`, piped through :org_check). This is
27
+ * the "bộ sưu tập" route the editor's media picker calls. Unlike the public
28
+ * /external/upload_file — which only PUTs bytes to S3 and hands back a CDN URL —
29
+ * this one also creates the `Image` row AND the `Asset` row in the org's folder,
30
+ * so the image shows up in the user's collection and can be re-picked later.
31
+ * Requires a JWT and an `x-org-id` (the controller drops the asset in the org
32
+ * folder resolved by :org_check; with no org it cannot file the asset).
33
+ */
34
+ const ASSET_UPLOAD_ENDPOINT = "/api/persona/upload";
35
+ /**
36
+ * The org's folder listing (AssetsController.get_all_folders_organization_by_type,
37
+ * builder host, behind :org_check). Needed because `AssetsController.upload`
38
+ * defaults `folder_id` to the account's PERSONA folder — sending only `x-org-id`
39
+ * would stamp the asset `organization_id: <org>` while filing it in the personal
40
+ * folder, and the collection listings query by `folder_id`, so the image would
41
+ * surface in neither. The editor avoids this by resolving the org's ROOT folder
42
+ * (`type == -1`) and posting it as `in_folder` (builderx_spa: landingLibrary.js
43
+ * `folders.find(f => f.type == -1)` → personalApi.upload). We mirror that.
44
+ */
45
+ const ORG_FOLDERS_ENDPOINT = "/api/organization/folders/all";
46
+ /** The org root folder's `type` discriminator in the folder listing. */
47
+ const ORG_ROOT_FOLDER_TYPE = -1;
24
48
  const BUILD_ENDPOINT = "/render/build";
25
49
  const CREATE_ENDPOINT = "/api/v1/ai/create_page_from_source";
26
50
  /**
@@ -199,7 +223,9 @@ export async function listOrganizations(config) {
199
223
  export async function createPage(config, name, source, orgId) {
200
224
  // Host external image URLs to the Webcake CDN before storing, so a clone never
201
225
  // keeps hotlinked/expiring source URLs. Failures keep the original URL.
202
- const { source: hostedSource, report: rehost } = await rehostSourceImages(config.base, source);
226
+ // `orgId` is the org resolved for THIS create and outranks config.orgId, so the
227
+ // images are filed into the same collection the page itself lands in.
228
+ const { source: hostedSource, report: rehost } = await rehostSourceImages(orgId ? { ...config, orgId } : config, source);
203
229
  const req = buildRequest(config, name, hostedSource, orgId);
204
230
  let res;
205
231
  try {
@@ -344,7 +370,7 @@ export function buildAppendRequestRedacted(config, pageId, sections) {
344
370
  */
345
371
  export async function appendSection(config, pageId, sections) {
346
372
  const url = `${config.base}${APPEND_ENDPOINT}`;
347
- const { source: hostedSections, report: rehost } = await rehostSourceImages(config.base, sections);
373
+ const { source: hostedSections, report: rehost } = await rehostSourceImages(config, sections);
348
374
  let res;
349
375
  try {
350
376
  res = await fetch(url, {
@@ -396,7 +422,7 @@ export async function appendSection(config, pageId, sections) {
396
422
  /** Overwrite an existing page's source (source-only). */
397
423
  export async function updatePageSource(config, pageId, source) {
398
424
  const url = `${config.base}${UPDATE_ENDPOINT}`;
399
- const { source: hostedSource, report: rehost } = await rehostSourceImages(config.base, source);
425
+ const { source: hostedSource, report: rehost } = await rehostSourceImages(config, source);
400
426
  let res;
401
427
  try {
402
428
  res = await fetch(url, {
@@ -682,6 +708,187 @@ export async function uploadImageMultipart(base, bytes, filename, contentType) {
682
708
  }
683
709
  return { ok: true, url: hostedUrl, status: res.status };
684
710
  }
711
+ /**
712
+ * Upload an image into the account's MEDIA COLLECTION (bộ sưu tập) via the
713
+ * builder's own media route — the same one the editor's media picker uses.
714
+ * Creates the Image + Asset rows in the org folder, so the image is re-pickable
715
+ * in the editor instead of being a loose CDN URL.
716
+ *
717
+ * Needs `config.jwt` AND an org — the org is REQUIRED, by project policy: an
718
+ * image must land in the collection of the org the page belongs to, so the org
719
+ * is settled up front rather than guessed at upload time.
720
+ *
721
+ * The org must reach the backend TWO ways, and both matter:
722
+ * 1. `x-org-id` — :org_check assigns the organization, stamping the Asset's
723
+ * `organization_id`.
724
+ * 2. `in_folder` — the org's ROOT folder. Without it the controller defaults
725
+ * `folder_id` to the persona folder, and since the library lists by
726
+ * `folder_id` the asset would appear in NO collection. Passing only the
727
+ * header is the subtle failure this guards against.
728
+ *
729
+ * `folderId` overrides (1) with an explicit sub-folder. Callers without a
730
+ * JWT/org should use `uploadImageMultipart` (public, no collection entry).
731
+ */
732
+ export async function uploadImageToCollection(config, bytes, filename, contentType, opts = {}) {
733
+ const builder = config.builderBase ?? config.base;
734
+ const org = opts.orgId ?? config.orgId;
735
+ if (!config.jwt)
736
+ return { ok: false, status: 0, error: "no JWT — cannot upload to the collection" };
737
+ if (org == null || `${org}` === "") {
738
+ return { ok: false, status: 0, error: "no org id — an organization must be chosen before uploading to the collection" };
739
+ }
740
+ // File into the org's own library. An explicit folderId wins; otherwise resolve
741
+ // the org root — never let the backend default to the persona folder, which
742
+ // would strand the asset outside every collection listing.
743
+ const folderId = opts.folderId ?? (await resolveOrgRootFolderId(config, `${org}`));
744
+ if (!folderId) {
745
+ return {
746
+ ok: false,
747
+ status: 0,
748
+ error: `could not resolve the root collection folder for org ${org} — uploading without it would file the image outside the org's collection`,
749
+ };
750
+ }
751
+ const url = `${builder.replace(/\/+$/, "")}${ASSET_UPLOAD_ENDPOINT}`;
752
+ const form = new FormData();
753
+ form.append("file", new Blob([bytes], { type: contentType }), filename);
754
+ form.append("in_folder", `${folderId}`);
755
+ let res;
756
+ try {
757
+ res = await fetch(url, {
758
+ method: "POST",
759
+ // No Content-Type — fetch sets the multipart boundary from the FormData.
760
+ // Auth mirrors authHeaders() but without its JSON Content-Type.
761
+ headers: {
762
+ Accept: "application/json",
763
+ Authorization: `Bearer ${config.jwt}`,
764
+ Cookie: `jwt=${config.jwt}`,
765
+ "x-org-id": `${org}`,
766
+ },
767
+ body: form,
768
+ signal: timeoutSignal(UPLOAD_MULTIPART_TIMEOUT_MS),
769
+ });
770
+ }
771
+ catch (e) {
772
+ const e2 = timeoutOrNetworkError(url, e);
773
+ return { ok: false, status: e2.status, error: e2.error };
774
+ }
775
+ const rawText = await res.text();
776
+ let json = null;
777
+ try {
778
+ json = JSON.parse(rawText);
779
+ }
780
+ catch {
781
+ /* non-JSON */
782
+ }
783
+ if (!res.ok || json?.success === false) {
784
+ const reason = json?.reason ?? json?.message ?? (json ? undefined : rawText.slice(0, 200));
785
+ return { ok: false, status: res.status, error: `Collection upload returned ${res.status}${reason ? `: ${reason}` : ""}` };
786
+ }
787
+ // AssetsController returns {success:true, asset: Asset.json(asset+image)} —
788
+ // the CDN link is the asset's txtdata, with the joined image row as a backup.
789
+ const asset = json?.asset ?? json?.data?.asset ?? json?.data;
790
+ const hostedUrl = (typeof asset?.txtdata === "string" ? asset.txtdata : undefined) ??
791
+ (typeof asset?.image?.link === "string" ? asset.image.link : undefined);
792
+ if (!hostedUrl) {
793
+ return { ok: false, status: res.status, error: "Collection upload succeeded but returned no URL" };
794
+ }
795
+ return { ok: true, url: hostedUrl, asset_id: asset?.id, status: res.status };
796
+ }
797
+ /**
798
+ * Memo of {jwt+base → resolved org id} so a multi-image save resolves the org
799
+ * once instead of calling list_organizations per image. Keyed by JWT so a
800
+ * multi-user `serve` process never leaks one caller's org to another.
801
+ */
802
+ const uploadOrgMemo = new Map();
803
+ /** Memo of {jwt+builder+org → org root folder id}, same rationale as uploadOrgMemo. */
804
+ const orgFolderMemo = new Map();
805
+ /**
806
+ * Resolve an org's ROOT collection folder id (`type === -1`) — the folder the
807
+ * editor's media picker treats as that org's library. Uploads must carry it as
808
+ * `in_folder`, otherwise the backend files the asset in the account's persona
809
+ * folder and it shows up in neither library (see ORG_FOLDERS_ENDPOINT).
810
+ *
811
+ * Returns undefined when the listing fails or has no root folder; the caller
812
+ * then has no verified org folder to file into.
813
+ */
814
+ export async function resolveOrgRootFolderId(config, orgId) {
815
+ const builder = config.builderBase ?? config.base;
816
+ const memoKey = `${builder}|${config.jwt}|${orgId}`;
817
+ if (orgFolderMemo.has(memoKey))
818
+ return orgFolderMemo.get(memoKey);
819
+ // `type` is required by the controller; organization-image is the media library.
820
+ const url = `${builder.replace(/\/+$/, "")}${ORG_FOLDERS_ENDPOINT}?type=organization-image`;
821
+ let resolved;
822
+ try {
823
+ const res = await fetch(url, {
824
+ method: "GET",
825
+ headers: { ...authHeaders(config, orgId) },
826
+ signal: timeoutSignal(HTTP_TIMEOUT_MS),
827
+ });
828
+ if (res.ok) {
829
+ const json = await res.json().catch(() => null);
830
+ const folders = Array.isArray(json?.folders) ? json.folders : [];
831
+ const root = folders.find((f) => Number(f?.type) === ORG_ROOT_FOLDER_TYPE);
832
+ if (root?.id != null)
833
+ resolved = `${root.id}`;
834
+ }
835
+ }
836
+ catch {
837
+ /* leave undefined — the caller decides how to proceed */
838
+ }
839
+ orgFolderMemo.set(memoKey, resolved);
840
+ return resolved;
841
+ }
842
+ /**
843
+ * Resolve the org to file uploaded assets into — the collection route cannot
844
+ * file an asset without one (:org_check assigns the organization only when
845
+ * `x-org-id` is present).
846
+ *
847
+ * Mirrors create_page's org policy: an explicit `config.orgId` wins; otherwise
848
+ * an account with exactly ONE org has it auto-selected. With 0 orgs, or 2+ (too
849
+ * ambiguous to guess), this returns undefined and the caller uploads to the
850
+ * public CDN endpoint instead — an image never blocks on an org prompt.
851
+ */
852
+ export async function resolveCollectionOrgId(config) {
853
+ if (config.orgId != null && `${config.orgId}` !== "")
854
+ return `${config.orgId}`;
855
+ if (!config.jwt)
856
+ return undefined;
857
+ const memoKey = `${config.base}|${config.jwt}`;
858
+ if (uploadOrgMemo.has(memoKey))
859
+ return uploadOrgMemo.get(memoKey);
860
+ const res = await listOrganizations(config);
861
+ let resolved;
862
+ if (res.ok && res.organizations && res.organizations.length === 1) {
863
+ resolved = `${res.organizations[0].id}`;
864
+ }
865
+ uploadOrgMemo.set(memoKey, resolved);
866
+ return resolved;
867
+ }
868
+ /**
869
+ * Upload an image, PREFERRING the media collection and falling back to the
870
+ * public CDN endpoint. This is the single entry point every upload path uses so
871
+ * that images land in the user's bộ sưu tập whenever we have the credentials to
872
+ * file them, while a credential-less run (`npx`, no env) still works exactly as
873
+ * before. A collection failure is never fatal — it degrades to the public upload
874
+ * and reports which route produced the URL.
875
+ *
876
+ * Requires a JWT AND an org: by policy the org is settled up front and the image
877
+ * belongs in THAT org's collection. Without either, the public endpoint is used
878
+ * (the URL works but no Asset row exists) — callers that must not silently skip
879
+ * the collection should check the org themselves and refuse first.
880
+ */
881
+ export async function uploadImagePreferCollection(config, bytes, filename, contentType, opts = {}) {
882
+ const org = opts.orgId ?? config.orgId;
883
+ if (config.jwt && org != null && `${org}` !== "") {
884
+ const viaCollection = await uploadImageToCollection(config, bytes, filename, contentType, opts);
885
+ if (viaCollection.ok)
886
+ return { ...viaCollection, collection: true };
887
+ console.error(`[upload] collection upload failed (${viaCollection.error}) — falling back to the public CDN endpoint`);
888
+ }
889
+ const viaPublic = await uploadImageMultipart(config.base, bytes, filename, contentType);
890
+ return { ...viaPublic, collection: false };
891
+ }
685
892
  // ---------------------------------------------------------------------------
686
893
  // Server-side image re-host (runs on every real create/update/append save)
687
894
  // ---------------------------------------------------------------------------
@@ -696,8 +903,12 @@ function rehostExtFromContentType(ct) {
696
903
  };
697
904
  return map[sub] ?? (sub.replace("image/", "") || "jpg");
698
905
  }
699
- /** Download one remote image and re-upload it to the Webcake CDN; returns the hosted URL or null. */
700
- async function fetchAndHostOne(apiBase, src) {
906
+ /**
907
+ * Download one remote image and re-upload it to Webcake; returns the hosted URL
908
+ * or null. Prefers the media collection (so auto-hosted images join the user's
909
+ * bộ sưu tập) and degrades to the public CDN endpoint without creds/org.
910
+ */
911
+ async function fetchAndHostOne(config, src) {
701
912
  let res;
702
913
  try {
703
914
  res = await fetch(src, {
@@ -738,9 +949,25 @@ async function fetchAndHostOne(apiBase, src) {
738
949
  if (buf.byteLength > REHOST_MAX_BYTES)
739
950
  return null;
740
951
  const ext = rehostExtFromContentType(contentType);
741
- const up = await uploadImageMultipart(apiBase, Buffer.from(buf), `rehost.${ext}`, contentType);
952
+ // Name the asset from the source URL so the collection shows something
953
+ // recognisable instead of a wall of "rehost.jpg" rows.
954
+ const filename = rehostFilename(src, ext);
955
+ const up = await uploadImagePreferCollection(config, Buffer.from(buf), filename, contentType);
742
956
  return up.ok && up.url ? up.url : null;
743
957
  }
958
+ /** Derive a human-readable collection filename from the source URL. */
959
+ function rehostFilename(src, ext) {
960
+ let stem = "";
961
+ try {
962
+ const p = new URL(src).pathname;
963
+ stem = decodeURIComponent(p.slice(p.lastIndexOf("/") + 1)).replace(/\.[^.]*$/, "");
964
+ }
965
+ catch {
966
+ /* fall through to the generic name */
967
+ }
968
+ stem = stem.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
969
+ return `${stem || "rehost"}.${ext}`;
970
+ }
744
971
  /**
745
972
  * Upload every external image URL in `source` to the Webcake CDN and return the
746
973
  * source with those URLs rewritten in place (specials.src, `url(...)`
@@ -749,25 +976,41 @@ async function fetchAndHostOne(apiBase, src) {
749
976
  * from the rehost cache (Redis-or-memory, ./rehost-cache.ts). A per-URL failure leaves the original URL untouched and
750
977
  * never throws — the save proceeds either way. Returns `{ source }` unchanged
751
978
  * with no report when the source has no external images.
979
+ *
980
+ * Uploads go to the account's media collection when the config carries a JWT and
981
+ * an org, else to the public CDN endpoint. The cache is therefore keyed PER ORG:
982
+ * the hosted URL is content-addressed and identical across orgs, but the Asset
983
+ * row is per-org, so a shared key would let org B reuse org A's URL and never get
984
+ * the image filed into its own collection.
752
985
  */
753
- export async function rehostSourceImages(apiBase, source) {
986
+ export async function rehostSourceImages(config, source) {
754
987
  const candidates = collectExternalImageUrls(source);
755
988
  if (candidates.length === 0)
756
989
  return { source };
757
990
  const capped = candidates.slice(0, MAX_REHOST_PER_SAVE);
758
991
  const skipped = candidates.length - capped.length;
992
+ // Resolve the collection org ONCE for the whole save, then pin it onto the
993
+ // config every upload sees — otherwise each image would re-resolve it.
994
+ // No org resolvable → the images take the public endpoint rather than the
995
+ // collection. This never blocks the save: a page's images are not worth
996
+ // failing a write over, and create_page already refuses to save at all when
997
+ // the org is ambiguous, so by this point an org is normally settled.
998
+ const orgId = config.jwt ? await resolveCollectionOrgId(config) : undefined;
999
+ if (orgId)
1000
+ config = { ...config, orgId };
1001
+ const scope = config.jwt && orgId ? `org:${orgId}` : "public";
759
1002
  // Resolve from cache first; upload the rest with a small concurrency pool.
760
- const cacheHits = await Promise.all(capped.map((u) => rehostGet(u)));
1003
+ const cacheHits = await Promise.all(capped.map((u) => rehostGet(u, scope)));
761
1004
  const toUpload = capped.filter((_, i) => !cacheHits[i]);
762
1005
  for (let i = 0; i < toUpload.length; i += REHOST_CONCURRENCY) {
763
1006
  const batch = toUpload.slice(i, i + REHOST_CONCURRENCY);
764
- const hosted = await Promise.all(batch.map((u) => fetchAndHostOne(apiBase, u)));
765
- await Promise.all(batch.map((u, j) => (hosted[j] ? rehostSet(u, hosted[j]) : Promise.resolve())));
1007
+ const hosted = await Promise.all(batch.map((u) => fetchAndHostOne(config, u)));
1008
+ await Promise.all(batch.map((u, j) => (hosted[j] ? rehostSet(u, hosted[j], scope) : Promise.resolve())));
766
1009
  }
767
1010
  const map = new Map();
768
1011
  const failed = [];
769
1012
  for (const u of capped) {
770
- const h = await rehostGet(u);
1013
+ const h = await rehostGet(u, scope);
771
1014
  if (h)
772
1015
  map.set(u, h);
773
1016
  else
@@ -779,6 +1022,8 @@ export async function rehostSourceImages(apiBase, source) {
779
1022
  rehosted: map.size,
780
1023
  failed: failed.length,
781
1024
  skipped,
1025
+ collection: Boolean(config.jwt && orgId),
1026
+ ...(orgId ? { collection_org_id: orgId } : {}),
782
1027
  ...(failed.length ? { failed_urls: failed.slice(0, 8) } : {}),
783
1028
  };
784
1029
  return { source: rewritten, report };
@@ -29,9 +29,27 @@ import { fileURLToPath } from "node:url";
29
29
  import { text, image, images } from "../mcp/response.js";
30
30
  import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
31
31
  import { captureScreenshot, captureScreenshotTiles, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
32
- import { uploadImageMultipart } from "../persistence/webcake-client.js";
32
+ import { uploadImageMultipart, uploadImagePreferCollection, resolveCollectionOrgId, listOrganizations, } from "../persistence/webcake-client.js";
33
33
  import { resolveIconSvg } from "../persistence/icon-client.js";
34
- import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
34
+ import { configFromHeaders, ENVIRONMENTS, readConfig, stripTrailingSlash } from "../persistence/config.js";
35
+ /**
36
+ * Name the uploaded asset after its source file so the collection lists
37
+ * something recognisable rather than a wall of identical "upload.jpg" rows.
38
+ */
39
+ function uploadFilename(entry, ext) {
40
+ let stem = "";
41
+ if (!entry.startsWith("data:")) {
42
+ try {
43
+ const path = /^https?:\/\//i.test(entry) ? new URL(entry).pathname : entry;
44
+ stem = decodeURIComponent(path.slice(path.lastIndexOf("/") + 1)).replace(/\.[^.]*$/, "");
45
+ }
46
+ catch {
47
+ /* fall through to the generic name */
48
+ }
49
+ }
50
+ stem = stem.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
51
+ return `${stem || "upload"}.${ext}`;
52
+ }
35
53
  /** Resolve just the API base (no JWT required) from per-request headers → env → WEBCAKE_ENV preset → prod default. */
36
54
  function resolveApiBase(headers) {
37
55
  const overrides = configFromHeaders(headers);
@@ -325,7 +343,7 @@ export function registerMediaTools(server, allowLocalFiles = true) {
325
343
  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.`);
326
344
  });
327
345
  // 14) Upload images to Webcake -----------------------------------------------
328
- 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. No Webcake credentials required (the upload endpoint is public). UPLOADS BY DEFAULT (dry_run defaults to FALSE — unlike the page-persistence tools, this touches no account 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.", {
346
+ 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.", {
329
347
  urls: z
330
348
  .array(z.string())
331
349
  .min(1)
@@ -335,13 +353,47 @@ export function registerMediaTools(server, allowLocalFiles = true) {
335
353
  "• data:image/...;base64,... URIs (inline image data)\n" +
336
354
  "• Local file paths from the user's machine: absolute POSIX paths (/home/user/photo.jpg), home-dir paths (~/Pictures/logo.png), file:// URIs, or Windows drive paths (C:\\Users\\…). Local paths are only allowed when the server runs in stdio mode (the user's own machine); they are rejected on the remote HTTP transport.\n" +
337
355
  "Up to 200 MB per image (the backend multipart limit)."),
356
+ in_folder: z
357
+ .string()
358
+ .optional()
359
+ .describe("Collection folder id to file the uploaded assets into (the media library's folder). Omit to use the account's root folder. Ignored when the upload falls back to the public CDN endpoint (no credentials)."),
360
+ organization_id: z
361
+ .string()
362
+ .optional()
363
+ .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)."),
338
364
  dry_run: z
339
365
  .boolean()
340
366
  .optional()
341
367
  .describe("Default FALSE — the call actually reads/downloads and uploads, returning hosted URLs. Set true to only preview the endpoint and entries that WOULD be processed, without any network or filesystem activity (local paths: reports whether the file exists and its size)."),
342
- }, { title: "Upload Images to Webcake", readOnlyHint: false, openWorldHint: true }, async ({ urls, dry_run }, extra) => {
368
+ }, { title: "Upload Images to Webcake", readOnlyHint: false, openWorldHint: true }, async ({ urls, in_folder, organization_id, dry_run }, extra) => {
343
369
  const isDry = dry_run === true;
344
- const base = resolveApiBase(extra?.requestInfo?.headers);
370
+ const headers = extra?.requestInfo?.headers;
371
+ const base = resolveApiBase(headers);
372
+ // Full creds (JWT + org) let the upload file each image into the account's
373
+ // media collection. Missing/partial creds are NOT an error here — this tool
374
+ // has always worked without them, so it degrades to the public endpoint.
375
+ const { config: credConfig } = readConfig({
376
+ ...configFromHeaders(headers),
377
+ ...(organization_id ? { orgId: organization_id } : {}),
378
+ });
379
+ // The org must be settled BEFORE any image work: an image belongs in the
380
+ // collection of the org its page lives in, and that cannot be guessed.
381
+ // With creds but an ambiguous org (2+ and none chosen) we refuse rather
382
+ // than quietly dumping the images outside every collection — mirroring
383
+ // create_page, which refuses to save for the same reason.
384
+ const uploadOrgId = credConfig ? await resolveCollectionOrgId(credConfig) : undefined;
385
+ if (credConfig && !uploadOrgId) {
386
+ const orgs = await listOrganizations(credConfig);
387
+ if (orgs.ok && orgs.organizations && orgs.organizations.length > 1) {
388
+ return text({
389
+ ok: false,
390
+ reason: "organization_required",
391
+ organizations: orgs.organizations.map((o) => ({ id: o.id, name: o.name, is_default: o.is_default })),
392
+ error: "This account has multiple organizations, so the images have no collection to go into. Re-call upload_images with organization_id set to the org this page belongs to — the SAME org you pass to create_page. Ask the user which one if it is not settled yet.",
393
+ });
394
+ }
395
+ }
396
+ const uploadConfig = credConfig && uploadOrgId ? { ...credConfig, orgId: uploadOrgId } : null;
345
397
  // Stdio transport: extra.requestInfo is undefined (no HTTP request headers).
346
398
  // HTTP transport: extra.requestInfo is always present.
347
399
  // We derive allowLocalFiles from the parameter passed into registerMediaTools
@@ -370,7 +422,14 @@ export function registerMediaTools(server, allowLocalFiles = true) {
370
422
  return text({
371
423
  ok: true,
372
424
  dry_run: true,
373
- endpoint: `${base}/external/upload_file`,
425
+ endpoint: uploadConfig
426
+ ? `${uploadConfig.builderBase ?? uploadConfig.base}/api/persona/upload`
427
+ : `${base}/external/upload_file`,
428
+ collection: Boolean(uploadConfig),
429
+ ...(uploadConfig ? { collection_org_id: uploadConfig.orgId } : {}),
430
+ collection_note: uploadConfig
431
+ ? `Images will be filed into the media collection of org ${uploadConfig.orgId} and be re-pickable in the editor.`
432
+ : "No credentials (WEBCAKE_JWT) resolved — images will go to the public CDN endpoint. The URLs work and the page renders, but the images will NOT appear in any collection.",
374
433
  urls_to_upload: urlsInfo,
375
434
  action_required: "DRY RUN ONLY — nothing was uploaded and NO hosted URLs exist yet. Do NOT build the page and do NOT fall back to placeholders: re-call upload_images WITHOUT dry_run (uploads by default; batch >20 entries into multiple calls) and WAIT for the returned images map before filling any specials.src / gallery link / background.",
376
435
  });
@@ -463,12 +522,20 @@ export function registerMediaTools(server, allowLocalFiles = true) {
463
522
  return [originalEntry, { ok: false, error: `Not an image — content-type: ${contentType}` }];
464
523
  }
465
524
  const ext = extFromContentType(contentType);
466
- const filename = `upload.${ext}`;
467
- const result = await uploadImageMultipart(base, bytes, filename, contentType);
525
+ const filename = uploadFilename(originalEntry, ext);
526
+ // With creds + an org, file the image into the account's media
527
+ // collection so it is re-pickable in the editor; otherwise fall back
528
+ // to the public CDN endpoint (unchanged zero-config behaviour).
529
+ const result = uploadConfig
530
+ ? await uploadImagePreferCollection(uploadConfig, bytes, filename, contentType, { folderId: in_folder })
531
+ : { ...(await uploadImageMultipart(base, bytes, filename, contentType)), collection: false };
468
532
  if (!result.ok) {
469
533
  return [originalEntry, { ok: false, error: result.error ?? "Upload failed" }];
470
534
  }
471
- return [originalEntry, { ok: true, url: result.url }];
535
+ return [
536
+ originalEntry,
537
+ { ok: true, url: result.url, collection: result.collection, ...(result.asset_id != null ? { asset_id: result.asset_id } : {}) },
538
+ ];
472
539
  }
473
540
  catch (e) {
474
541
  return [originalEntry, { ok: false, error: `Unexpected error: ${e?.message ?? e}` }];
@@ -477,10 +544,14 @@ export function registerMediaTools(server, allowLocalFiles = true) {
477
544
  const images = {};
478
545
  let uploaded = 0;
479
546
  let failed = 0;
547
+ let inCollection = 0;
480
548
  for (const [entry, result] of results) {
481
549
  images[entry] = result;
482
- if (result.ok)
550
+ if (result.ok) {
483
551
  uploaded++;
552
+ if (result.collection)
553
+ inCollection++;
554
+ }
484
555
  else
485
556
  failed++;
486
557
  }
@@ -489,6 +560,11 @@ export function registerMediaTools(server, allowLocalFiles = true) {
489
560
  images,
490
561
  uploaded,
491
562
  failed,
563
+ collection_uploads: inCollection,
564
+ ...(uploadConfig ? { collection_org_id: uploadConfig.orgId } : {}),
565
+ collection_note: inCollection > 0
566
+ ? `${inCollection} image(s) were filed into the media collection (bộ sưu tập) of org ${uploadConfig?.orgId} and are re-pickable in the editor.`
567
+ : "Images went to the public CDN endpoint, NOT the media collection — no credentials were resolved. The URLs work and the page renders; to file them into the collection, set WEBCAKE_JWT and an org (WEBCAKE_ORG_ID, or pass organization_id).",
492
568
  usage: "Put images[<original>].url into EVERY element that used <original> (image specials.src, gallery item.link, section/box background url(...)). Slots whose entry uploaded ok MUST use the hosted URL — never a placeholder. Only for entries marked ok:false, fall back to the image-source chain (search_images → your own web search + re-upload → placeholder LAST)." +
493
569
  (failed > 0 ? ` ${failed} entr${failed === 1 ? "y" : "ies"} failed — handle them via that fallback chain now.` : ""),
494
570
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
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",