webcake-landing-mcp 1.0.66 → 1.0.67

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` | nothing |
190
- | **Media** | `search_images` (real Pexels stock photos) · `upload_images` (re-host external images) | nothing |
190
+ | **Media** | `search_images` (real Pexels stock photos) · `upload_images` (re-host external images, data: URIs, or local file paths from the user's machine) | 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.0.67",
4
+ "d": "12/06/2026",
5
+ "type": "Added",
6
+ "en": "upload_images now accepts local file paths in the urls parameter — absolute POSIX paths (/…), home-directory paths (~/…), file:// URIs, and Windows…",
7
+ "vi": "upload_images nay chấp nhận đường dẫn file cục bộ trong tham số urls — đường dẫn POSIX tuyệt đối (/…), đường dẫn thư mục home (~/…), URI file://, và…"
8
+ },
2
9
  {
3
10
  "v": "1.0.66",
4
11
  "d": "12/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "en": "validate_page now warns when a text-block's estimated rendered height overflows onto a sibling element placed directly below its declared box; the…",
35
42
  "vi": "validate_page nay cảnh báo khi chiều cao render ước tính của text-block tràn xuống phần tử anh em đặt ngay phía dưới khung khai báo; cảnh báo nêu…"
36
- },
37
- {
38
- "v": "1.0.61",
39
- "d": "11/06/2026",
40
- "type": "Added",
41
- "en": "ingest_html and ingest_url now return a size_hint field ({ height, basis, css? }) on every AST section, providing the desktop section height in px…",
42
- "vi": "ingest_html và ingest_url nay trả về trường size_hint ({ height, basis, css? }) trên mỗi section trong AST, cung cấp chiều cao desktop của section…"
43
43
  }
44
44
  ]
@@ -114,7 +114,7 @@ SECTION BUILD HINTS (apply to whichever sections the chosen archetype uses)
114
114
  RULES
115
115
  - Visible content goes in "specials" (text-block.specials.text, image-block.specials.src…), NEVER in "styles".
116
116
  - Colors as rgba(r,g,b,a). fontSize/borderWidth/top/left/width/height are NUMBERS (px). borderRadius is a STRING with CSS units ("8px", "50%", "16px 16px 0 0") — a bare number or unit-less string is auto-coerced to px by the server, but write the unit explicitly to avoid surprises.
117
- - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). SOURCE PRIORITY: (1) images the user supplied or that exist in the reference HTML/URL (ingest AST images/background_images/og_image) → re-host them via upload_images and use those EXACT images in their slots — never swap them for stock photos; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src — use src.large for a hero/banner, src.medium for a card/thumb (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
117
+ - IMAGES: a real landing page has images (hero/product shot, feature icons, about photo). SOURCE PRIORITY: (1) images the user supplied — including local file paths from their computer (pass the path directly in upload_images 'urls'; NEVER upload a user's local file to a third-party host like catbox or imgur to obtain a URL first) — or that exist in the reference HTML/URL (ingest AST images/background_images/og_image) → re-host them via upload_images and use those EXACT images in their slots — never swap them for stock photos; (2) only for slots with NO source image → call search_images with a short English subject (e.g. 'fresh coffee cup', 'modern office team') and put a returned URL into image-block specials.src — use src.large for a hero/banner, src.medium for a card/thumb (avg_color helps pick a matching section background); (3) if search_images returns ok:false, is unreachable, or has NO photo that fits the slot → find a real image YOURSELF using whatever web search/fetch capability you have (the brand's own site, the product page, a free-to-use source), then re-host it via upload_images and use the returned URL; (4) a PLACEHOLDER is the LAST resort, ONLY after (2) AND (3) both failed — sized to the box: "https://placehold.co/<width>x<height>". Never jump straight from a failed search to a placeholder without trying (3). NEVER leave src empty — it renders blank on the live page. The server automatically derives styles.background from specials.src on every expand (create/update/validate) using the editor's exact format: 'center center/ cover no-repeat scroll content-box url(<src>) border-box' — you do NOT need to set styles.background manually; if you do hand-write it, it must contain url(...). A SECTION background may layer a gradient overlay over an image: 'linear-gradient(...), center center/ cover no-repeat scroll content-box url(<src>) border-box' — the server canonicalises any url() layer into that exact editor shorthand on expand (other url() formats survive the first save but get mangled to 'undefined/ undefined/ …' the moment the page is edited in the Webcake editor). gallery.media = array of OBJECTS {type:'image', link:'<real-or-placeholder-url>', linkVideo:'', typeVideo:'youtube', imageCompression:true} (NOT plain URL strings — the gallery reads item.link); video.specials.img = a poster image (real photo, else placeholder). Do NOT set a flat (no url()) styles.background on a video element — it suppresses the poster image.
118
118
  - CONTRAST (check EVERY text element against the band it sits on, especially SATURATED / mid-tone bands like yellow, orange, teal, pink — there "light vs dark text" is not obvious, so decide by the band's luminance): light bands → near-black text (e.g. rgba(20,30,25,1)); dark bands → near-white text; a saturated/mid-tone band → whichever of near-black or near-white actually reads (for a bright yellow/amber band that means DARK text, not white/grey). NEVER use muted-grey, low-alpha (alpha < ~0.85), or near-white text on a colored band — that is exactly what makes labels look faded/sunken. Muted-grey is ONLY for secondary text on a white/very-light band. Icons and their captions follow the SAME rule as the text beside them.
119
119
  - movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
120
120
  - Every form input MUST have a unique specials.field_name.
@@ -146,7 +146,7 @@ WORKFLOW (recommended)
146
146
  1. Call get_generation_guide (this) once, then new_page_skeleton for the top-level shape.
147
147
  2. For each element type you'll use, call get_element to learn its specials & see an example.
148
148
  3. Optionally call new_element to get a correct skeleton, then fill specials + coordinates.
149
- 3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image (user-supplied or from the reference HTML/URL), upload_images it and use the returned Webcake URL; otherwise call search_images and put a returned URL into specials.src / gallery item.link. If search_images fails or has no fitting photo, find a real image yourself (web search/fetch → upload_images). Use placehold.co ONLY as the last resort when both search_images AND your own search failed.
149
+ 3b. For every image the page needs (hero, product, about, feature, gallery): if the slot has a source image (user-supplied — including local file paths from their computer, pass the path directly in upload_images 'urls'; NEVER relay the user's local file through a third-party host like catbox or imgur — or from the reference HTML/URL), upload_images it and use the returned Webcake URL; otherwise call search_images and put a returned URL into specials.src / gallery item.link. If search_images fails or has no fitting photo, find a real image yourself (web search/fetch → upload_images). Use placehold.co ONLY as the last resort when both search_images AND your own search failed.
150
150
  4. Assemble { page, popup, settings, options, cartConfigs }.
151
151
  5. Call validate_page and fix every error AND every warning — warnings are visible defects (text spilling onto the element below, off-canvas boxes, empty bands at a section's bottom, missing field_name, dead event targets), not advisory polish. Re-validate until the warning list is empty; only a demonstrably false positive may remain (tell the user which and why).
152
152
  6. To save: call list_organizations. If the account has EXACTLY ONE organization, create_page will auto-select it — no need to ask. If there are MULTIPLE organizations, show them to the user and ask which to use (highlight is_default as the suggested default); pass the chosen organization_id to create_page. If the user explicitly wants to save without any organization, pass organization_id:"personal". Then create_page (dry_run first, then dry_run:false). Note: create_page itself enforces this — it refuses to guess between multiple orgs and returns the org list asking you to pick.
@@ -37,7 +37,7 @@ MODEL (essentials):
37
37
  - 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
38
  - 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
39
  - 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). SOURCE PRIORITY: (1) images the user provided or that exist in the reference HTML/URL → re-host via upload_images and use those exact images; (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.
40
+ - IMAGES: include them (hero/product, feature icons, about photo). SOURCE PRIORITY: (1) images the user supplied (including local file paths from their computer — pass the path directly in upload_images urls; NEVER upload a user's local file to a third-party host like catbox or imgur first) or that exist in the reference HTML/URL → re-host via upload_images and use those exact images; (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
41
 
42
42
  - 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.
43
43
 
package/dist/http.js CHANGED
@@ -203,7 +203,7 @@ export async function startHttpServer(port) {
203
203
  if (transport.sessionId)
204
204
  transports.delete(transport.sessionId);
205
205
  };
206
- const server = createServer();
206
+ const server = createServer({ allowLocalFiles: false });
207
207
  await server.connect(transport);
208
208
  await transport.handleRequest(req, res, body);
209
209
  return;
@@ -609,6 +609,60 @@ export async function uploadImageBase64(base, b64, ext, contentType) {
609
609
  }
610
610
  return { ok: true, url: hostedUrl, status: res.status };
611
611
  }
612
+ /** Timeout used for multipart uploads — generous to accommodate large files. */
613
+ const UPLOAD_MULTIPART_TIMEOUT_MS = 120_000;
614
+ /**
615
+ * Upload an image to the Webcake backend via multipart/form-data.
616
+ * The /external/upload_file endpoint is public — no JWT required.
617
+ * The backend derives `ext` from the last dot segment of `filename` and uses
618
+ * `file.content_type` from the part headers — so `filename` must carry the
619
+ * correct extension and `contentType` must be set explicitly.
620
+ * Supports up to 200 MB (the backend's multipart Plug.Parsers limit).
621
+ * Returns `{ ok: true, url }` on success or `{ ok: false, error }` on failure.
622
+ */
623
+ export async function uploadImageMultipart(base, bytes, filename, contentType) {
624
+ const url = `${base.replace(/\/+$/, "")}${UPLOAD_FILE_ENDPOINT}`;
625
+ const form = new FormData();
626
+ // Attach the blob with an explicit content type so the backend picks it up
627
+ // from file.content_type, and a filename so it can derive the extension.
628
+ form.append("file", new Blob([bytes], { type: contentType }), filename);
629
+ let res;
630
+ try {
631
+ res = await fetch(url, {
632
+ method: "POST",
633
+ // Do NOT set Content-Type manually — fetch sets it with the correct
634
+ // multipart boundary when the body is a FormData instance.
635
+ headers: { Accept: "application/json" },
636
+ body: form,
637
+ signal: timeoutSignal(UPLOAD_MULTIPART_TIMEOUT_MS),
638
+ });
639
+ }
640
+ catch (e) {
641
+ const e2 = timeoutOrNetworkError(url, e);
642
+ return { ok: false, status: e2.status, error: e2.error };
643
+ }
644
+ const rawText = await res.text();
645
+ let json = null;
646
+ try {
647
+ json = JSON.parse(rawText);
648
+ }
649
+ catch {
650
+ /* non-JSON */
651
+ }
652
+ if (!res.ok || json?.success === false) {
653
+ const reason = json?.reason ?? json?.message ?? (json ? undefined : rawText.slice(0, 200));
654
+ return {
655
+ ok: false,
656
+ status: res.status,
657
+ error: `Backend returned ${res.status}${reason ? `: ${reason}` : ""}`,
658
+ };
659
+ }
660
+ const hostedUrl = typeof json?.data === "string" ? json.data : undefined;
661
+ if (!hostedUrl) {
662
+ return { ok: false, status: res.status, error: "Backend returned success but no URL in data field" };
663
+ }
664
+ return { ok: true, url: hostedUrl, status: res.status };
665
+ }
612
666
  /**
613
667
  * POST to a host-scoped route. Node's fetch cannot reach `*.localhost` hosts
614
668
  * (browsers special-case .localhost; Node's DNS does not, and undici forbids a
package/dist/server.js CHANGED
@@ -10,7 +10,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { landingDomain } from "./domains/landing/index.js";
11
11
  import { registerTools } from "./tools/index.js";
12
12
  import { ICON_DATA_URI, ICON_MIME, BRAND } from "./branding.js";
13
- export function createServer() {
13
+ /**
14
+ * Create the MCP server.
15
+ * @param allowLocalFiles Set to false in remote HTTP (serve) mode to prevent
16
+ * upload_images from reading arbitrary files off the host filesystem.
17
+ * Defaults to true (stdio / single-user mode on the user's own machine).
18
+ */
19
+ export function createServer({ allowLocalFiles = true } = {}) {
14
20
  const server = new McpServer({
15
21
  name: "webcake-landing",
16
22
  version: "1.0.0",
@@ -20,6 +26,6 @@ export function createServer() {
20
26
  websiteUrl: BRAND.websiteUrl,
21
27
  icons: [{ src: ICON_DATA_URI, mimeType: ICON_MIME, sizes: ["any"] }],
22
28
  }, { instructions: landingDomain.instructions });
23
- registerTools(server, landingDomain);
29
+ registerTools(server, landingDomain, { allowLocalFiles });
24
30
  return server;
25
31
  }
package/dist/smoke.js CHANGED
@@ -14,6 +14,7 @@ import { toEditorUrl, toEditorLoginUrl, toPreviewUrl, buildPublishRequestRedacte
14
14
  import { normalizePhoto, resolvePexelsKey, pexelsKeyFromHeaders, resolvePexelsProxyBase, buildSearchQuery, PEXELS_PROXY_DEFAULT } from "./persistence/pexels-client.js";
15
15
  import { putDraft, getDraft, updateDraft, deleteDraft } from "./persistence/draft-cache.js";
16
16
  import { buildConnectUrl, parseCallback } from "./auth/login.js";
17
+ import { isLocalPath, resolveLocalPath, sniffMime, localContentType } from "./tools/media.js";
17
18
  let failures = 0;
18
19
  const check = (name, cond, extra) => {
19
20
  if (cond) {
@@ -1174,5 +1175,67 @@ console.log("== validator: pill/badge label alignment ==");
1174
1175
  const rWide = validatePage(expandSource(badge(108, 330, 300, { fontSize: 16, fontWeight: 700 }, { width: 220, left: 370 }), createElement));
1175
1176
  check("pill: label wider than pill → spill warning", rWide.warnings.some((w) => w.includes("spills past")), rWide.warnings);
1176
1177
  }
1178
+ console.log("== upload_images: local-path detector (pure, offline) ==");
1179
+ {
1180
+ // isLocalPath: recognised forms
1181
+ check("localPath: absolute POSIX /…", isLocalPath("/home/user/photo.jpg"));
1182
+ check("localPath: home-dir ~/…", isLocalPath("~/Pictures/logo.png"));
1183
+ check("localPath: file:// URI", isLocalPath("file:///tmp/img.png"));
1184
+ check("localPath: Windows drive C:\\…", isLocalPath("C:\\Users\\user\\img.jpg"));
1185
+ check("localPath: Windows drive C:/…", isLocalPath("C:/Users/user/img.jpg"));
1186
+ // isLocalPath: things that must NOT match
1187
+ check("localPath: http URL → false", !isLocalPath("https://example.com/img.jpg"));
1188
+ check("localPath: data URI → false", !isLocalPath("data:image/png;base64,abc"));
1189
+ check("localPath: relative path → false", !isLocalPath("images/photo.jpg"));
1190
+ // resolveLocalPath
1191
+ const home = (await import("node:os")).homedir();
1192
+ check("resolveLocalPath: ~/… expands homedir", resolveLocalPath("~/foo/bar.jpg") === home + "/foo/bar.jpg");
1193
+ check("resolveLocalPath: /abs passes through", resolveLocalPath("/abs/path.jpg") === "/abs/path.jpg");
1194
+ // file:// resolution is handled by fileURLToPath; test the passthrough for absolute paths
1195
+ check("resolveLocalPath: Windows C:\\ passes through", resolveLocalPath("C:\\Users\\x.jpg") === "C:\\Users\\x.jpg");
1196
+ }
1197
+ console.log("== upload_images: magic-byte sniffer (pure, offline) ==");
1198
+ {
1199
+ // JPEG: FF D8 FF
1200
+ const jpegBuf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
1201
+ check("sniff: JPEG magic → image/jpeg", sniffMime(jpegBuf) === "image/jpeg");
1202
+ // PNG: 89 50 4E 47
1203
+ const pngBuf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d]);
1204
+ check("sniff: PNG magic → image/png", sniffMime(pngBuf) === "image/png");
1205
+ // GIF: 47 49 46 38
1206
+ const gifBuf = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39]);
1207
+ check("sniff: GIF magic → image/gif", sniffMime(gifBuf) === "image/gif");
1208
+ // BMP: 42 4D
1209
+ const bmpBuf = Buffer.from([0x42, 0x4d, 0x00, 0x00, 0x00]);
1210
+ check("sniff: BMP magic → image/bmp", sniffMime(bmpBuf) === "image/bmp");
1211
+ // WEBP: RIFF????WEBP
1212
+ const webpBuf = Buffer.from([
1213
+ 0x52, 0x49, 0x46, 0x46, // RIFF
1214
+ 0x00, 0x00, 0x00, 0x00, // file size (ignored)
1215
+ 0x57, 0x45, 0x42, 0x50, // WEBP
1216
+ ]);
1217
+ check("sniff: WEBP magic → image/webp", sniffMime(webpBuf) === "image/webp");
1218
+ // unknown bytes
1219
+ const unknownBuf = Buffer.from([0x00, 0x01, 0x02, 0x03]);
1220
+ check("sniff: unknown bytes → undefined", sniffMime(unknownBuf) === undefined);
1221
+ // too-short buffer
1222
+ check("sniff: 2-byte buffer → undefined", sniffMime(Buffer.from([0xff, 0xd8])) === undefined);
1223
+ }
1224
+ console.log("== upload_images: localContentType (ext + magic, pure offline) ==");
1225
+ {
1226
+ const jpegBuf = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]);
1227
+ const pngBuf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d]);
1228
+ const unknownBuf = Buffer.from([0x00, 0x01, 0x02, 0x03]);
1229
+ // magic wins over extension when they agree
1230
+ check("localContentType: .jpg + JPEG magic → image/jpeg", localContentType("jpg", jpegBuf) === "image/jpeg");
1231
+ check("localContentType: .png + PNG magic → image/png", localContentType("png", pngBuf) === "image/png");
1232
+ // magic wins over (wrong) extension
1233
+ check("localContentType: .png ext but JPEG magic → image/jpeg (magic wins)", localContentType("png", jpegBuf) === "image/jpeg");
1234
+ // extension fallback when magic is unknown
1235
+ check("localContentType: unknown magic + .png ext → image/png (ext fallback)", localContentType("png", unknownBuf) === "image/png");
1236
+ check("localContentType: unknown magic + .svg ext → image/svg+xml (ext fallback)", localContentType("svg", unknownBuf) === "image/svg+xml");
1237
+ // both unknown → undefined
1238
+ check("localContentType: unknown magic + unknown ext → undefined", localContentType("xyz", unknownBuf) === undefined);
1239
+ }
1177
1240
  console.log(`\n${failures === 0 ? "ALL GOOD" : failures + " FAILURE(S)"}`);
1178
1241
  process.exit(failures === 0 ? 0 : 1);
@@ -3,10 +3,10 @@ import { registerGenerationTools } from "./generation.js";
3
3
  import { registerMediaTools } from "./media.js";
4
4
  import { registerIngestTools } from "./ingest.js";
5
5
  import { registerPersistenceTools } from "./persistence.js";
6
- export function registerTools(server, domain) {
6
+ export function registerTools(server, domain, { allowLocalFiles = true } = {}) {
7
7
  registerReferenceTools(server, domain);
8
8
  registerGenerationTools(server, domain);
9
- registerMediaTools(server);
9
+ registerMediaTools(server, allowLocalFiles);
10
10
  registerIngestTools(server);
11
11
  registerPersistenceTools(server, domain);
12
12
  }
@@ -2,8 +2,15 @@
2
2
  * Media tools: fetch REAL stock images for a page instead of grey placeholders.
3
3
  * `search_images` queries the Pexels API and returns ready-to-hotlink URLs the
4
4
  * agent drops into an image element's `specials.src` (or a gallery item's `link`).
5
- * `upload_images` converts external image URLs (or data: URIs) into Webcake-hosted
6
- * URLs (statics.pancake.vn) so generated pages don't hotlink third-party hosts.
5
+ * `upload_images` converts external image URLs (or data: URIs) OR LOCAL FILE PATHS
6
+ * (absolute POSIX, ~/…, file://, Windows drive paths) into Webcake-hosted URLs
7
+ * (statics.pancake.vn) so generated pages don't hotlink third-party hosts and the
8
+ * AI never needs to proxy the user's files through a third-party service.
9
+ * Uses multipart/form-data upload (200 MB backend limit).
10
+ *
11
+ * LOCAL FILE PATHS are only allowed on the stdio transport (server running on the
12
+ * user's own machine). On the remote HTTP transport (serve mode, multi-user) every
13
+ * local-path entry is rejected per-entry to prevent arbitrary-file-read attacks.
7
14
  *
8
15
  * The Pexels API key is a secret resolved per request: the `x-pexels-key` header
9
16
  * (remote / multi-user) wins, else the `PEXELS_API_KEY` env var (stdio). With a key
@@ -16,9 +23,12 @@
16
23
  * public). Defaults to dry_run=true.
17
24
  */
18
25
  import { z } from "zod";
26
+ import { promises as fs } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { fileURLToPath } from "node:url";
19
29
  import { text } from "../mcp/response.js";
20
30
  import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
21
- import { uploadImageBase64 } from "../persistence/webcake-client.js";
31
+ import { uploadImageMultipart } from "../persistence/webcake-client.js";
22
32
  import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
23
33
  /** Resolve just the API base (no JWT required) from per-request headers → env → WEBCAKE_ENV preset → prod default. */
24
34
  function resolveApiBase(headers) {
@@ -36,8 +46,8 @@ function resolveApiBase(headers) {
36
46
  // Default to prod.
37
47
  return ENVIRONMENTS.prod.apiBase;
38
48
  }
39
- const UPLOAD_TIMEOUT_MS = 20_000;
40
- const UPLOAD_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
49
+ const UPLOAD_FETCH_TIMEOUT_MS = 60_000; // 60 s — large bodies can be slow to transfer
50
+ const UPLOAD_MAX_BYTES = 200_000_000; // 200 MB mirrors the backend multipart Plug.Parsers limit
41
51
  /** Map a content_type to its canonical file extension. */
42
52
  function extFromContentType(ct) {
43
53
  const sub = ct.split(";")[0].trim().toLowerCase();
@@ -67,7 +77,89 @@ function extFromUrl(url) {
67
77
  }
68
78
  return "jpg";
69
79
  }
70
- export function registerMediaTools(server) {
80
+ // ---------------------------------------------------------------------------
81
+ // Local-path helpers (exported for smoke-test coverage)
82
+ // ---------------------------------------------------------------------------
83
+ /** Windows drive-letter path pattern, e.g. C:\… or C:/… */
84
+ const WIN_DRIVE_RE = /^[A-Za-z]:[\\/]/;
85
+ /**
86
+ * Return true when `entry` looks like a local file path (not a URL / data URI).
87
+ * Recognised forms: file://, absolute POSIX (/…), home-dir (~/…), Windows drive (C:\…).
88
+ * Exported so the smoke test can assert the pure logic without a transport.
89
+ */
90
+ export function isLocalPath(entry) {
91
+ return (entry.startsWith("file://") ||
92
+ entry.startsWith("/") ||
93
+ entry.startsWith("~/") ||
94
+ WIN_DRIVE_RE.test(entry));
95
+ }
96
+ /**
97
+ * Resolve a local-path entry to an absolute POSIX path.
98
+ * - file://… → fileURLToPath
99
+ * - ~/… → expand homedir
100
+ * - /… → unchanged
101
+ * - C:\… → unchanged (Windows absolute)
102
+ */
103
+ export function resolveLocalPath(entry) {
104
+ if (entry.startsWith("file://"))
105
+ return fileURLToPath(entry);
106
+ if (entry.startsWith("~/"))
107
+ return homedir() + entry.slice(1);
108
+ return entry;
109
+ }
110
+ /** Map a file extension (lowercased, no dot) to its MIME type. */
111
+ const EXT_TO_MIME = {
112
+ jpg: "image/jpeg",
113
+ jpeg: "image/jpeg",
114
+ png: "image/png",
115
+ webp: "image/webp",
116
+ gif: "image/gif",
117
+ avif: "image/avif",
118
+ svg: "image/svg+xml",
119
+ bmp: "image/bmp",
120
+ tiff: "image/tiff",
121
+ tif: "image/tiff",
122
+ };
123
+ /**
124
+ * Sniff the MIME type of a Buffer from its magic bytes.
125
+ * Returns undefined when the signature isn't recognised.
126
+ * Exported for smoke-test coverage.
127
+ */
128
+ export function sniffMime(buf) {
129
+ if (buf.length < 4)
130
+ return undefined;
131
+ // JPEG: FF D8 FF
132
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
133
+ return "image/jpeg";
134
+ // PNG: 89 50 4E 47
135
+ if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
136
+ return "image/png";
137
+ // GIF: 47 49 46 38 (GIF87a / GIF89a)
138
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38)
139
+ return "image/gif";
140
+ // BMP: 42 4D
141
+ if (buf[0] === 0x42 && buf[1] === 0x4d)
142
+ return "image/bmp";
143
+ // WEBP: RIFF????WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
144
+ if (buf.length >= 12 &&
145
+ buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
146
+ buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) {
147
+ return "image/webp";
148
+ }
149
+ return undefined;
150
+ }
151
+ /**
152
+ * Derive the content-type for a local file: sniff magic bytes first, then fall
153
+ * back to the extension. Returns undefined when both fail (unrecognised format).
154
+ * Exported for smoke-test coverage.
155
+ */
156
+ export function localContentType(ext, buf) {
157
+ const sniffed = sniffMime(buf);
158
+ const fromExt = EXT_TO_MIME[ext.toLowerCase()];
159
+ // Prefer sniffed (more reliable); fall back to extension when sniff fails.
160
+ return sniffed ?? fromExt;
161
+ }
162
+ export function registerMediaTools(server, allowLocalFiles = true) {
71
163
  // 13) Search images ---------------------------------------------------------
72
164
  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 (src.large for heroes/banners, src.medium for cards/thumbs), `avg_color` for matching section backgrounds, plus photographer name and attribution URL. 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.", {
73
165
  query: z.string().optional().describe("Single subject query — backward-compat. Prefer `queries` when the page needs 2+ images."),
@@ -130,97 +222,160 @@ export function registerMediaTools(server) {
130
222
  return text({ queries: out });
131
223
  });
132
224
  // 14) Upload images to Webcake -----------------------------------------------
133
- server.tool("upload_images", "Converts external image URLs (typically collected from ingest_html/ingest_url results) or data: URIs into Webcake-hosted URLs (statics.pancake.vn) by downloading each image and re-uploading it to the Webcake backend. Use this whenever the page is built from a reference HTML/URL (BOTH intents — adapt AND clone) or the user supplies their own image URLs: reference images are the user's assets, so re-host and reuse them rather than replacing them with stock photos, and never hotlink third-party hosts that may block hotlinking or disappear. The returned URLs go directly into specials.src — same as search_images results. Processes up to 20 URLs per call in parallel, with an 8 MB per-image cap. No Webcake credentials required (the upload endpoint is public). DEFAULTS to dry_run=true (returns a preview of what would be uploaded, no network calls); set dry_run=false to actually upload. Use search_images instead when you need stock photos.", {
225
+ 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). DEFAULTS to dry_run=true (returns a preview of what would be processed, no network calls); set dry_run=false to actually upload. 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.", {
134
226
  urls: z
135
227
  .array(z.string())
136
228
  .min(1)
137
229
  .max(20)
138
- .describe("External http(s) image URLs or data:image/...;base64,... URIs to upload. 1–20 per call."),
230
+ .describe("Image sources to upload 1–20 per call. Accepted formats:\n" +
231
+ "• http(s) URLs (remote images to download and re-host)\n" +
232
+ "• data:image/...;base64,... URIs (inline image data)\n" +
233
+ "• 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" +
234
+ "Up to 200 MB per image (the backend multipart limit)."),
139
235
  dry_run: z
140
236
  .boolean()
141
237
  .optional()
142
- .describe("Default TRUE — return a preview of the endpoint and URLs that WOULD be processed, without any network activity. Set false to actually download and upload."),
238
+ .describe("Default TRUE — return a preview of the endpoint and entries that WOULD be processed, without any network or filesystem activity (local paths: reports whether the file exists and its size). Set false to actually read/download and upload."),
143
239
  }, { title: "Upload Images to Webcake", readOnlyHint: false, openWorldHint: true }, async ({ urls, dry_run }, extra) => {
144
240
  const isDry = dry_run !== false;
145
241
  const base = resolveApiBase(extra?.requestInfo?.headers);
146
- // Deduplicate input URLs.
242
+ // Stdio transport: extra.requestInfo is undefined (no HTTP request headers).
243
+ // HTTP transport: extra.requestInfo is always present.
244
+ // We derive allowLocalFiles from the parameter passed into registerMediaTools
245
+ // (true for stdio, false for the HTTP serve mode — see registerTools / http.ts).
246
+ const localAllowed = allowLocalFiles;
247
+ // Deduplicate input entries while preserving original strings as keys.
147
248
  const deduped = [...new Set(urls)];
148
249
  if (isDry) {
250
+ // For local paths: stat the file so the model catches typos before the real call.
251
+ const urlsInfo = await Promise.all(deduped.map(async (entry) => {
252
+ if (isLocalPath(entry)) {
253
+ if (!localAllowed) {
254
+ return { entry, local: true, allowed: false, error: "Local file paths are only supported when the MCP server runs locally (stdio). Send a public URL or data: URI instead." };
255
+ }
256
+ try {
257
+ const resolved = resolveLocalPath(entry);
258
+ const st = await fs.stat(resolved);
259
+ return { entry, local: true, allowed: true, exists: true, size_bytes: st.size, exceeds_limit: st.size > UPLOAD_MAX_BYTES, limit_bytes: UPLOAD_MAX_BYTES };
260
+ }
261
+ catch {
262
+ return { entry, local: true, allowed: true, exists: false };
263
+ }
264
+ }
265
+ return { entry, local: false };
266
+ }));
149
267
  return text({
150
268
  ok: true,
151
269
  dry_run: true,
152
270
  endpoint: `${base}/external/upload_file`,
153
- urls_to_upload: deduped,
154
- hint: "Re-call with dry_run:false to actually download and upload these images.",
271
+ urls_to_upload: urlsInfo,
272
+ hint: "Re-call with dry_run:false to actually read/download and upload these images.",
155
273
  });
156
274
  }
157
- // Process each URL in parallel; per-URL failures don't fail the whole call.
158
- const results = await Promise.all(deduped.map(async (originalUrl) => {
275
+ // Process each entry in parallel; per-entry failures don't fail the whole call.
276
+ const results = await Promise.all(deduped.map(async (originalEntry) => {
159
277
  try {
160
- let b64;
278
+ let bytes;
161
279
  let contentType;
162
- if (originalUrl.startsWith("data:")) {
163
- // data:image/<subtype>;base64,<data>
164
- const match = originalUrl.match(/^data:(image\/[^;,]+);base64,(.+)$/s);
280
+ if (isLocalPath(originalEntry)) {
281
+ // --- LOCAL FILE PATH ---
282
+ if (!localAllowed) {
283
+ return [originalEntry, { ok: false, error: "Local file paths are only supported when the MCP server runs locally (stdio). Send a public URL or data: URI instead." }];
284
+ }
285
+ const resolved = resolveLocalPath(originalEntry);
286
+ // Stat first — size check before reading the whole file.
287
+ let stat;
288
+ try {
289
+ stat = await fs.stat(resolved);
290
+ }
291
+ catch (e) {
292
+ const msg = (e?.code === "ENOENT") ? `File not found: ${resolved}` : `Cannot read file: ${e?.message ?? e}`;
293
+ return [originalEntry, { ok: false, error: msg }];
294
+ }
295
+ if (stat.size > UPLOAD_MAX_BYTES) {
296
+ return [originalEntry, { ok: false, error: `Image exceeds the 200 MB backend limit (file size: ${stat.size} bytes)` }];
297
+ }
298
+ // Read file contents.
299
+ let fileBuf;
300
+ try {
301
+ fileBuf = await fs.readFile(resolved);
302
+ }
303
+ catch (e) {
304
+ return [originalEntry, { ok: false, error: `Cannot read file: ${e?.message ?? e}` }];
305
+ }
306
+ // Derive extension from path, sniff MIME from bytes.
307
+ const dotIdx = resolved.lastIndexOf(".");
308
+ const extRaw = dotIdx >= 0 ? resolved.slice(dotIdx + 1).replace(/[?#].*$/, "").toLowerCase() : "";
309
+ const ct = localContentType(extRaw, fileBuf);
310
+ if (!ct) {
311
+ return [originalEntry, { ok: false, error: `Not a recognized image file: ${resolved}` }];
312
+ }
313
+ contentType = ct;
314
+ bytes = fileBuf;
315
+ }
316
+ else if (originalEntry.startsWith("data:")) {
317
+ // --- DATA URI ---
318
+ const match = originalEntry.match(/^data:(image\/[^;,]+);base64,(.+)$/s);
165
319
  if (!match) {
166
- return [originalUrl, { ok: false, error: "Malformed data: URI — expected data:image/<type>;base64,<data>" }];
320
+ return [originalEntry, { ok: false, error: "Malformed data: URI — expected data:image/<type>;base64,<data>" }];
167
321
  }
168
322
  contentType = match[1].toLowerCase();
169
- b64 = match[2];
323
+ bytes = Buffer.from(match[2], "base64");
170
324
  }
171
325
  else {
172
- // Fetch the remote image.
326
+ // --- REMOTE HTTP(S) URL ---
173
327
  let res;
174
328
  try {
175
- res = await fetch(originalUrl, {
176
- signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
329
+ res = await fetch(originalEntry, {
330
+ signal: AbortSignal.timeout(UPLOAD_FETCH_TIMEOUT_MS),
177
331
  headers: {
178
332
  "User-Agent": "Mozilla/5.0 (compatible; webcake-landing-mcp/1.0; +https://webcake.io)",
179
333
  },
180
334
  });
181
335
  }
182
336
  catch (e) {
183
- return [originalUrl, { ok: false, error: `Fetch failed: ${e?.message ?? e}` }];
337
+ return [originalEntry, { ok: false, error: `Fetch failed: ${e?.message ?? e}` }];
184
338
  }
185
339
  if (!res.ok) {
186
- return [originalUrl, { ok: false, error: `Remote returned HTTP ${res.status}` }];
340
+ return [originalEntry, { ok: false, error: `Remote returned HTTP ${res.status}` }];
187
341
  }
188
342
  // Reject oversized images early via Content-Length.
189
343
  const cl = res.headers.get("content-length");
190
344
  if (cl && parseInt(cl, 10) > UPLOAD_MAX_BYTES) {
191
- return [originalUrl, { ok: false, error: `Image exceeds 8 MB limit (Content-Length: ${cl})` }];
345
+ return [originalEntry, { ok: false, error: `Image exceeds the 200 MB backend limit (Content-Length: ${cl})` }];
192
346
  }
193
347
  // Determine content-type; reject non-images (also catches html error pages).
194
348
  const rawCt = res.headers.get("content-type") ?? "";
195
- contentType = rawCt.split(";")[0].trim().toLowerCase() || `image/${extFromUrl(originalUrl)}`;
349
+ contentType = rawCt.split(";")[0].trim().toLowerCase() || `image/${extFromUrl(originalEntry)}`;
196
350
  if (!contentType.startsWith("image/")) {
197
- return [originalUrl, { ok: false, error: `Not an image — content-type: ${contentType || "(empty)"}` }];
351
+ return [originalEntry, { ok: false, error: `Not an image — content-type: ${contentType || "(empty)"}` }];
198
352
  }
199
353
  const buf = await res.arrayBuffer();
200
354
  if (buf.byteLength > UPLOAD_MAX_BYTES) {
201
- return [originalUrl, { ok: false, error: `Image exceeds 8 MB limit (actual: ${buf.byteLength} bytes)` }];
355
+ return [originalEntry, { ok: false, error: `Image exceeds the 200 MB backend limit (actual: ${buf.byteLength} bytes)` }];
202
356
  }
203
- b64 = Buffer.from(buf).toString("base64");
357
+ bytes = Buffer.from(buf);
204
358
  }
205
359
  if (!contentType.startsWith("image/")) {
206
- return [originalUrl, { ok: false, error: `Not an image — content-type: ${contentType}` }];
360
+ return [originalEntry, { ok: false, error: `Not an image — content-type: ${contentType}` }];
207
361
  }
208
362
  const ext = extFromContentType(contentType);
209
- const result = await uploadImageBase64(base, b64, ext, contentType);
363
+ const filename = `upload.${ext}`;
364
+ const result = await uploadImageMultipart(base, bytes, filename, contentType);
210
365
  if (!result.ok) {
211
- return [originalUrl, { ok: false, error: result.error ?? "Upload failed" }];
366
+ return [originalEntry, { ok: false, error: result.error ?? "Upload failed" }];
212
367
  }
213
- return [originalUrl, { ok: true, url: result.url }];
368
+ return [originalEntry, { ok: true, url: result.url }];
214
369
  }
215
370
  catch (e) {
216
- return [originalUrl, { ok: false, error: `Unexpected error: ${e?.message ?? e}` }];
371
+ return [originalEntry, { ok: false, error: `Unexpected error: ${e?.message ?? e}` }];
217
372
  }
218
373
  }));
219
374
  const images = {};
220
375
  let uploaded = 0;
221
376
  let failed = 0;
222
- for (const [url, result] of results) {
223
- images[url] = result;
377
+ for (const [entry, result] of results) {
378
+ images[entry] = result;
224
379
  if (result.ok)
225
380
  uploaded++;
226
381
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.66",
3
+ "version": "1.0.67",
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",