webcake-landing-mcp 1.0.81 → 1.0.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "v": "1.0.82",
4
+ "d": "16/06/2026",
5
+ "type": "Added",
6
+ "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…",
7
+ "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…"
8
+ },
2
9
  {
3
10
  "v": "1.0.81",
4
11
  "d": "16/06/2026",
@@ -33,12 +40,5 @@
33
40
  "type": "Added",
34
41
  "en": "The remote serve transport now implements a spec-conformant OAuth 2.1 Authorization Server (Authorization Code + PKCE S256, Dynamic Client…",
35
42
  "vi": "Transport serve từ xa nay triển khai một OAuth 2.1 Authorization Server chuẩn spec (Authorization Code + PKCE S256, Dynamic Client Registration,…"
36
- },
37
- {
38
- "v": "1.0.76",
39
- "d": "15/06/2026",
40
- "type": "Added",
41
- "en": "validate_page now warns when specials.custom_css sets layout or structural CSS properties (position, top, left, right, bottom, inset, width, height,…",
42
- "vi": "validate_page nay cảnh báo khi specials.custom_css đặt các thuộc tính CSS layout hoặc cấu trúc (position, top, left, right, bottom, inset, width,…"
43
43
  }
44
44
  ]
package/dist/http.js CHANGED
@@ -21,7 +21,7 @@ import { ICON_SVG, ICON_MIME } from "./branding.js";
21
21
  import { guideHtml, ogImageSvg, normalizeLang } from "./web-guide.js";
22
22
  import { privacyHtml, termsHtml } from "./legal.js";
23
23
  import { searchPexels, resolvePexelsKey } from "./persistence/pexels-client.js";
24
- import { captureWithPlaywright, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
24
+ import { captureWithPlaywright, captureTilesWithPlaywright, isAllowedScreenshotUrl } from "./persistence/screenshot-playwright.js";
25
25
  import { resolveEnv, ENVIRONMENTS, stripTrailingSlash } from "./persistence/config.js";
26
26
  import { buildConnectUrl } from "./auth/login.js";
27
27
  import { registerClient, startAuthorize, completeAuthorize, exchangeToken, resolveAccessToken, revokeToken, authServerMetadata, protectedResourceMetadata, } from "./auth/oauth-server.js";
@@ -350,6 +350,23 @@ async function handleRenderScreenshot(req, res) {
350
350
  return sendErr(400, { ok: false, error: allow.error });
351
351
  const fullPage = sp.get("full_page") !== "false";
352
352
  const width = sp.get("width") ? Number(sp.get("width")) : undefined;
353
+ // Tiles mode: split a tall page into horizontal bands, returned as a JSON array
354
+ // of base64 images (top→bottom) so the model reads each slice at a readable size.
355
+ if (sp.get("tiles") === "1" || sp.get("tiles") === "true") {
356
+ const bandHeight = sp.get("band_height") ? Number(sp.get("band_height")) : undefined;
357
+ const t = await captureTilesWithPlaywright(target, { width, bandHeight });
358
+ if (!t.ok)
359
+ return sendErr(t.reason === "not_installed" ? 503 : 502, { ok: false, error: t.error });
360
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store", ...cors });
361
+ return res.end(JSON.stringify({
362
+ ok: true,
363
+ mimeType: t.mimeType,
364
+ page_height: t.pageHeight,
365
+ width: t.width,
366
+ truncated: t.truncated,
367
+ tiles: t.tiles.map((b) => ({ y: b.y, height: b.height, data: b.data.toString("base64") })),
368
+ }));
369
+ }
353
370
  const r = await captureWithPlaywright(target, { fullPage, width });
354
371
  if (!r.ok) {
355
372
  // 503 when the engine is absent (caller should fall back / skip), 502 otherwise.
@@ -18,6 +18,18 @@ export function image(dataBase64, mimeType = "image/png", note) {
18
18
  }
19
19
  return { content };
20
20
  }
21
+ /**
22
+ * Like `image()` but returns SEVERAL image blocks (e.g. a tall page tiled into
23
+ * top→bottom bands), optionally followed by a text note. The model sees each band
24
+ * at a readable size instead of one over-squished image.
25
+ */
26
+ export function images(items, note) {
27
+ const content = items.map((it) => ({ type: "image", data: it.dataBase64, mimeType: it.mimeType ?? "image/png" }));
28
+ if (note !== undefined) {
29
+ content.push({ type: "text", text: typeof note === "string" ? note : JSON.stringify(note, null, 2) });
30
+ }
31
+ return { content };
32
+ }
21
33
  /**
22
34
  * Directive shipped alongside every non-empty validation-warnings list.
23
35
  * Warnings are design defects the customer WILL see (text overlapping the
@@ -132,6 +132,52 @@ export async function captureViaProxy(base, targetUrl, opts, nonce = 0) {
132
132
  return { ok: false, status: 0, via: "proxy", error: `network error calling screenshot proxy ${base}: ${e?.message ?? e}` };
133
133
  }
134
134
  }
135
+ /**
136
+ * Capture a tall page as horizontal BANDS via the self-hosted Playwright route
137
+ * (`?tiles=1`) so the model reads each slice at a readable size. Microlink can't
138
+ * tile, so this REQUIRES a proxy host (RENDER_SCREENSHOT_BASE); without one it
139
+ * returns not_supported and the caller should fall back to a single full-page shot.
140
+ * Never throws.
141
+ */
142
+ export async function captureScreenshotTiles(targetUrl, opts = {}, resolved = {}, nonce = 0) {
143
+ if (!resolved.proxyBase) {
144
+ return { ok: false, status: 0, not_supported: true, error: "tiling needs a Playwright host (RENDER_SCREENSHOT_BASE); Microlink can't tile" };
145
+ }
146
+ const q = new URLSearchParams();
147
+ q.set("url", targetUrl + (targetUrl.includes("?") ? "&" : "?") + "_=" + nonce);
148
+ q.set("tiles", "1");
149
+ q.set("full_page", "true");
150
+ if (opts.width && Number.isFinite(opts.width))
151
+ q.set("width", String(Math.round(opts.width)));
152
+ if (opts.bandHeight && Number.isFinite(opts.bandHeight))
153
+ q.set("band_height", String(Math.round(opts.bandHeight)));
154
+ const url = `${resolved.proxyBase}${SCREENSHOT_PROXY_PATH}?${q.toString()}`;
155
+ try {
156
+ const res = await fetch(url, { method: "GET", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(SCREENSHOT_TIMEOUT_MS) });
157
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
158
+ if (res.ok && ct.includes("application/json")) {
159
+ const j = await res.json();
160
+ return {
161
+ ok: true,
162
+ status: res.status,
163
+ mimeType: j.mimeType,
164
+ pageHeight: j.page_height,
165
+ width: j.width,
166
+ truncated: j.truncated === true,
167
+ tiles: (j.tiles ?? []).map((t) => ({ y: t.y, height: t.height, dataBase64: t.data })),
168
+ via: "proxy",
169
+ };
170
+ }
171
+ const body = (await res.text()).slice(0, 300);
172
+ return { ok: false, status: res.status, quota_exhausted: res.status === 429, error: `tiles proxy returned HTTP ${res.status}${body ? `: ${body}` : ""}` };
173
+ }
174
+ catch (e) {
175
+ if (e?.name === "TimeoutError" || e?.name === "AbortError") {
176
+ return { ok: false, status: 0, error: `tiles request timed out after ${SCREENSHOT_TIMEOUT_MS}ms` };
177
+ }
178
+ return { ok: false, status: 0, error: `network error calling tiles proxy ${resolved.proxyBase}: ${e?.message ?? e}` };
179
+ }
180
+ }
135
181
  /**
136
182
  * Capture a screenshot with AUTOMATIC FALLOVER between the two engines:
137
183
  * - Microlink direct (free, zero-config) and
@@ -79,8 +79,18 @@ function outputOpts() {
79
79
  const scale = Number.isFinite(s) && s > 0 && s <= 2 ? s : 1;
80
80
  return { type, quality, scale };
81
81
  }
82
- /** Screenshot a URL with Playwright. Never throws. */
83
- export async function captureWithPlaywright(url, opts = {}) {
82
+ /** Default band height (CSS px) for tiling a tall page; env-tunable. */
83
+ function resolveBandHeight() {
84
+ const b = parseInt(process.env.RENDER_SCREENSHOT_BAND_HEIGHT ?? "", 10);
85
+ return Number.isFinite(b) && b >= 200 ? b : 1400;
86
+ }
87
+ /**
88
+ * Run `fn` against a fresh browser context (viewport width + deviceScaleFactor),
89
+ * with a single self-heal retry if the cached browser died (crash, or a system
90
+ * Chrome that closed). Always closes the context. Never throws — the shared
91
+ * lifecycle for both single-shot and tiled capture.
92
+ */
93
+ async function withContext(width, scale, fn) {
84
94
  const pw = await loadPlaywright();
85
95
  if (!pw) {
86
96
  return {
@@ -89,13 +99,10 @@ export async function captureWithPlaywright(url, opts = {}) {
89
99
  error: "playwright is not installed on this host (run: npm i playwright && npx playwright install chromium)",
90
100
  };
91
101
  }
92
- // One shot, with a single self-heal retry: if the cached browser died (crash,
93
- // or a system Chrome that closed), drop it and relaunch once.
94
102
  for (let attempt = 0; attempt < 2; attempt++) {
95
103
  let browser = await getBrowser();
96
104
  if (!browser)
97
105
  return { ok: false, error: "failed to launch headless chromium (check CHROME_BIN / playwright install)" };
98
- // If the cached handle is already disconnected, force a relaunch.
99
106
  if (typeof browser.isConnected === "function" && !browser.isConnected()) {
100
107
  browserPromise = null;
101
108
  browser = await getBrowser();
@@ -104,24 +111,12 @@ export async function captureWithPlaywright(url, opts = {}) {
104
111
  }
105
112
  let ctx;
106
113
  try {
107
- const out = outputOpts();
108
- ctx = await browser.newContext({
109
- viewport: { width: opts.width && Number.isFinite(opts.width) ? Math.round(opts.width) : 1280, height: 900 },
110
- deviceScaleFactor: out.scale,
111
- });
112
- const page = await ctx.newPage();
113
- await page.goto(url, { waitUntil: "load", timeout: 30_000 });
114
- await page.waitForTimeout(1200); // let webfonts/lazy images settle
115
- const shot = await page.screenshot({
116
- fullPage: opts.fullPage !== false,
117
- type: out.type,
118
- ...(out.type === "jpeg" ? { quality: out.quality } : {}),
119
- });
120
- return { ok: true, data: Buffer.from(shot), mimeType: out.type === "png" ? "image/png" : "image/jpeg" };
114
+ ctx = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: scale });
115
+ const value = await fn(ctx);
116
+ return { ok: true, value };
121
117
  }
122
118
  catch (e) {
123
119
  const msg = String(e?.message ?? e);
124
- // Browser-died errors → reset and retry once; other errors → fail now.
125
120
  const dead = /closed|disconnected|crash|Target page, context or browser/i.test(msg);
126
121
  if (dead && attempt === 0) {
127
122
  browserPromise = null;
@@ -136,6 +131,73 @@ export async function captureWithPlaywright(url, opts = {}) {
136
131
  }
137
132
  return { ok: false, error: "playwright capture failed after retry" };
138
133
  }
134
+ /** Load a page in `ctx`, wait for it to settle, return the page handle. */
135
+ async function openSettledPage(ctx, url) {
136
+ const page = await ctx.newPage();
137
+ await page.goto(url, { waitUntil: "load", timeout: 30_000 });
138
+ await page.waitForTimeout(1200); // let webfonts/lazy images settle
139
+ return page;
140
+ }
141
+ /** Screenshot a URL with Playwright (one image — full page or viewport). Never throws. */
142
+ export async function captureWithPlaywright(url, opts = {}) {
143
+ const out = outputOpts();
144
+ const width = opts.width && Number.isFinite(opts.width) ? Math.round(opts.width) : 1280;
145
+ const r = await withContext(width, out.scale, async (ctx) => {
146
+ const page = await openSettledPage(ctx, url);
147
+ return Buffer.from(await page.screenshot({
148
+ fullPage: opts.fullPage !== false,
149
+ type: out.type,
150
+ ...(out.type === "jpeg" ? { quality: out.quality } : {}),
151
+ }));
152
+ });
153
+ if (!r.ok)
154
+ return r;
155
+ return { ok: true, data: r.value, mimeType: out.type === "png" ? "image/png" : "image/jpeg" };
156
+ }
157
+ /**
158
+ * Capture a tall page as a STACK of horizontal bands (top→bottom) via Playwright
159
+ * `clip` — so the model sees each slice at a readable aspect ratio instead of one
160
+ * giant image squished (and blurred) to the vision input's long-edge cap. No image
161
+ * library needed: each band is its own screenshot. Bands are capped at `maxTiles`
162
+ * (truncated:true when the page is taller than that).
163
+ */
164
+ export async function captureTilesWithPlaywright(url, opts = {}) {
165
+ const out = outputOpts();
166
+ const width = opts.width && Number.isFinite(opts.width) ? Math.round(opts.width) : 1280;
167
+ const bandH = opts.bandHeight && opts.bandHeight >= 200 ? Math.round(opts.bandHeight) : resolveBandHeight();
168
+ const maxTiles = opts.maxTiles && opts.maxTiles > 0 ? Math.round(opts.maxTiles) : 8;
169
+ const r = await withContext(width, out.scale, async (ctx) => {
170
+ const page = await openSettledPage(ctx, url);
171
+ // String form so tsc doesn't type-check `document` against the Node libs.
172
+ const pageHeight = Math.ceil(Number(await page.evaluate("Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)")) || 0);
173
+ const total = Math.max(1, Math.ceil(pageHeight / bandH));
174
+ const n = Math.min(total, maxTiles);
175
+ // `clip` is measured against the VIEWPORT, so grow the viewport to cover the
176
+ // bands we'll capture before clipping (Chromium caps a single shot at ~16384px).
177
+ const captureHeight = Math.min(pageHeight, n * bandH, 16000);
178
+ await page.setViewportSize({ width, height: Math.max(captureHeight, 1) });
179
+ await page.waitForTimeout(300); // reflow after the resize
180
+ const tiles = [];
181
+ for (let i = 0; i < n; i++) {
182
+ const y = i * bandH;
183
+ let h = Math.min(bandH, pageHeight - y);
184
+ if (y + h > captureHeight)
185
+ h = captureHeight - y; // never clip past the viewport
186
+ if (h <= 0)
187
+ break;
188
+ const shot = await page.screenshot({
189
+ clip: { x: 0, y, width, height: h },
190
+ type: out.type,
191
+ ...(out.type === "jpeg" ? { quality: out.quality } : {}),
192
+ });
193
+ tiles.push({ y, height: h, data: Buffer.from(shot) });
194
+ }
195
+ return { pageHeight, width, tiles, truncated: total > maxTiles };
196
+ });
197
+ if (!r.ok)
198
+ return r;
199
+ return { ok: true, mimeType: out.type === "png" ? "image/png" : "image/jpeg", ...r.value };
200
+ }
139
201
  // ---------------------------------------------------------------------------
140
202
  // SSRF guard — this route fetches an arbitrary `url`, so block private/loopback
141
203
  // targets (someone could otherwise screenshot internal services). Pure + exported
@@ -26,9 +26,9 @@ import { z } from "zod";
26
26
  import { promises as fs } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { fileURLToPath } from "node:url";
29
- import { text, image } from "../mcp/response.js";
29
+ import { text, image, images } from "../mcp/response.js";
30
30
  import { searchPexels, searchImagesViaProxy, resolvePexelsKey, resolvePexelsProxyBase, pexelsKeyFromHeaders, } from "../persistence/pexels-client.js";
31
- import { captureScreenshot, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
31
+ import { captureScreenshot, captureScreenshotTiles, resolveScreenshotProxyBase, screenshotProxyBaseFromHeaders, resolveMicrolinkKey, microlinkKeyFromHeaders, } from "../persistence/screenshot-client.js";
32
32
  import { uploadImageMultipart } from "../persistence/webcake-client.js";
33
33
  import { resolveIconSvg } from "../persistence/icon-client.js";
34
34
  import { configFromHeaders, ENVIRONMENTS, stripTrailingSlash } from "../persistence/config.js";
@@ -269,12 +269,13 @@ export function registerMediaTools(server, allowLocalFiles = true) {
269
269
  });
270
270
  });
271
271
  // 13c) Render a page/URL to a screenshot the model can SEE --------------------
272
- server.tool("render_preview", "Renders a PUBLIC URL to a PNG and returns it as an image so the model can SEE the result and compare it visually to the reference — the fidelity-check step of the clone loop (build → see → patch_page → re-check). Pass `page_id` to shoot a created page's preview (/preview/<id>) or `url` for any public page (e.g. the reference you're cloning). full_page defaults to true (whole scrollable page). AGENT-FIRST: if YOU already have a screenshot/browser capability (a shell + headless browser, or a screenshot tool), screenshot the preview URL YOURSELF instead — it's fresh and unlimited; use this tool only when you cannot. ENGINE: zero-config via Microlink's free tier (rate-limited ~50/day PER IP, so heavy looping can hit HTTP 429 — then this returns ok:false and you should SKIP the visual check that round, not fail); a host can set RENDER_SCREENSHOT_BASE (or the x-render-screenshot-base header) to a keyed proxy, or MICROLINK_API_KEY / x-microlink-key for a higher quota. NOTE: a no-domain preview only renders for ~10 minutes after the last publish — call this promptly after create_page/publish_page, and re-publish before re-checking a stale page.", {
272
+ server.tool("render_preview", "Renders a PUBLIC URL to a PNG and returns it as an image so the model can SEE the result and compare it visually to the reference — the fidelity-check step of the clone loop (build → see → patch_page → re-check). Pass `page_id` to shoot a created page's preview (/preview/<id>) or `url` for any public page (e.g. the reference you're cloning). full_page defaults to true (whole scrollable page). AGENT-FIRST: if YOU already have a screenshot/browser capability (a shell + headless browser, or a screenshot tool), screenshot the preview URL YOURSELF instead — it's fresh and unlimited; use this tool only when you cannot. ENGINE: zero-config via Microlink's free tier (rate-limited ~50/day PER IP, so heavy looping can hit HTTP 429 — then this returns ok:false and you should SKIP the visual check that round, not fail); a host can set RENDER_SCREENSHOT_BASE (or the x-render-screenshot-base header) to a keyed proxy, or MICROLINK_API_KEY / x-microlink-key for a higher quota. NOTE: a no-domain preview only renders for ~10 minutes after the last publish — call this promptly after create_page/publish_page, and re-publish before re-checking a stale page. TALL PAGES: pass tiles:true to get the page as a STACK of top→bottom band images (each readable at full detail) instead of one full-page image squished small — needs a self-hosted Playwright host (RENDER_SCREENSHOT_BASE); falls back to a single image otherwise.", {
273
273
  page_id: z.string().optional().describe("A created page's id — screenshots its /preview/<id> URL (built from the preview base). Provide page_id OR url."),
274
274
  url: z.string().optional().describe("Any public http(s) URL to screenshot (e.g. the reference page being cloned). Wins over page_id."),
275
275
  full_page: z.boolean().optional().describe("Capture the whole scrollable page (default true) vs just the viewport."),
276
276
  width: z.number().int().min(320).max(2560).optional().describe("Viewport width in px (default 1280; use ~960/1200 to match the page canvas, ~420 for mobile)."),
277
- }, { title: "Render Preview Screenshot", readOnlyHint: true, openWorldHint: true }, async ({ page_id, url, full_page, width }, extra) => {
277
+ tiles: z.boolean().optional().describe("Tall pages: return the page as MULTIPLE top→bottom band images (each readable in detail) instead of one squished full-page image. Requires a Playwright host (RENDER_SCREENSHOT_BASE); without one it falls back to a single image."),
278
+ }, { title: "Render Preview Screenshot", readOnlyHint: true, openWorldHint: true }, async ({ page_id, url, full_page, width, tiles }, extra) => {
278
279
  const headers = extra?.requestInfo?.headers;
279
280
  let target = url?.trim();
280
281
  if (!target && page_id) {
@@ -287,6 +288,26 @@ export function registerMediaTools(server, allowLocalFiles = true) {
287
288
  proxyBase: resolveScreenshotProxyBase(screenshotProxyBaseFromHeaders(headers)),
288
289
  microlinkKey: resolveMicrolinkKey(microlinkKeyFromHeaders(headers)),
289
290
  };
291
+ // Tiles mode (tall pages): a stack of readable top→bottom bands. Needs a
292
+ // Playwright host; if none is configured, fall through to a single image.
293
+ if (tiles === true) {
294
+ const t = await captureScreenshotTiles(target, { width }, resolved, Date.now());
295
+ if (t.ok && t.tiles && t.tiles.length) {
296
+ return images(t.tiles.map((b) => ({ dataBase64: b.dataBase64, mimeType: t.mimeType })), `Rendered ${target} as ${t.tiles.length} band(s) top→bottom (page ${t.pageHeight}px @ ${t.width}px wide${t.truncated ? ", TRUNCATED — page taller than the band cap" : ""}). Read the bands in order as one page. Compare each to the reference: section order, colors, spacing, image placement, text. For each mismatch, patch_page the element by id, re-publish, then re-check.`);
297
+ }
298
+ if (!t.not_supported) {
299
+ // A real failure (quota/network) — report it; don't silently single-shot.
300
+ return text({
301
+ ok: false,
302
+ url: target,
303
+ status: t.status,
304
+ quota_exhausted: t.quota_exhausted ?? false,
305
+ error: t.error,
306
+ hint: "Tiled screenshot failed. Retry without tiles for a single image, or skip the visual check this round.",
307
+ });
308
+ }
309
+ // not_supported → fall through to a single full-page image below.
310
+ }
290
311
  const r = await captureScreenshot(target, { fullPage: full_page !== false, width }, resolved, Date.now());
291
312
  if (!r.ok) {
292
313
  return text({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webcake-landing-mcp",
3
- "version": "1.0.81",
3
+ "version": "1.0.82",
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",