webcake-landing-mcp 1.0.80 → 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.
- package/dist/changelog.json +14 -14
- package/dist/http.js +20 -3
- package/dist/mcp/response.js +12 -0
- package/dist/persistence/screenshot-client.js +46 -0
- package/dist/persistence/screenshot-playwright.js +98 -15
- package/dist/tools/media.js +25 -4
- package/package.json +1 -1
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
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
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.81",
|
|
11
|
+
"d": "16/06/2026",
|
|
12
|
+
"type": "Changed",
|
|
13
|
+
"en": "The Playwright screenshot engine that backs render_preview's GET /api/render/screenshot route now defaults to JPEG output instead of PNG, reducing…",
|
|
14
|
+
"vi": "Engine chụp màn hình Playwright phục vụ route GET /api/render/screenshot của render_preview nay mặc định xuất ảnh JPEG thay vì PNG, giúp giảm kích…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.80",
|
|
4
18
|
"d": "16/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "The remote serve transport now implements a spec-conformant OAuth 2.1 Authorization Server (Authorization Code + PKCE S256, Dynamic Client…",
|
|
28
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,…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.76",
|
|
32
|
-
"d": "15/06/2026",
|
|
33
|
-
"type": "Added",
|
|
34
|
-
"en": "validate_page now warns when specials.custom_css sets layout or structural CSS properties (position, top, left, right, bottom, inset, width, height,…",
|
|
35
|
-
"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,…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.75",
|
|
39
|
-
"d": "15/06/2026",
|
|
40
|
-
"type": "Added",
|
|
41
|
-
"en": "New get_icon_svg tool resolves Material Symbols (ms:<name>) and Font Awesome (fa:<name>) icon-font references to real inline SVG markup via the…",
|
|
42
|
-
"vi": "Tool mới get_icon_svg resolve tên icon-font Material Symbols (ms:<name>) và Font Awesome (fa:<name>) thành SVG inline thực sự qua Iconify API công…"
|
|
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,13 +350,30 @@ 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.
|
|
356
373
|
return sendErr(r.reason === "not_installed" ? 503 : 502, { ok: false, error: r.error });
|
|
357
374
|
}
|
|
358
|
-
res.writeHead(200, { "content-type":
|
|
359
|
-
return res.end(r.
|
|
375
|
+
res.writeHead(200, { "content-type": r.mimeType, "cache-control": "no-store", ...cors });
|
|
376
|
+
return res.end(r.data);
|
|
360
377
|
}
|
|
361
378
|
export async function startHttpServer(port) {
|
|
362
379
|
// mcp-session-id -> live transport (each bound to its own McpServer instance).
|
package/dist/mcp/response.js
CHANGED
|
@@ -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
|
|
@@ -63,8 +63,34 @@ async function getBrowser() {
|
|
|
63
63
|
return null;
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
/**
|
|
67
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Output tuning (env-controlled, so the VPS operator picks the size/quality
|
|
68
|
+
* tradeoff). Default JPEG — a full-page landing screenshot is ~5–10× smaller as
|
|
69
|
+
* JPEG than PNG with no loss that matters for a layout/colour comparison, which
|
|
70
|
+
* shrinks the base64 the model receives. `scale` (deviceScaleFactor) renders at a
|
|
71
|
+
* lower pixel density to cut dimensions too; 1 = crisp, 0.5 = quarter the pixels.
|
|
72
|
+
*/
|
|
73
|
+
function outputOpts() {
|
|
74
|
+
const fmt = (process.env.RENDER_SCREENSHOT_FORMAT ?? "jpeg").toLowerCase();
|
|
75
|
+
const type = fmt === "png" ? "png" : "jpeg";
|
|
76
|
+
const q = parseInt(process.env.RENDER_SCREENSHOT_QUALITY ?? "", 10);
|
|
77
|
+
const quality = type === "jpeg" ? (Number.isFinite(q) && q >= 1 && q <= 100 ? q : 72) : undefined;
|
|
78
|
+
const s = parseFloat(process.env.RENDER_SCREENSHOT_SCALE ?? "");
|
|
79
|
+
const scale = Number.isFinite(s) && s > 0 && s <= 2 ? s : 1;
|
|
80
|
+
return { type, quality, scale };
|
|
81
|
+
}
|
|
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) {
|
|
68
94
|
const pw = await loadPlaywright();
|
|
69
95
|
if (!pw) {
|
|
70
96
|
return {
|
|
@@ -73,13 +99,10 @@ export async function captureWithPlaywright(url, opts = {}) {
|
|
|
73
99
|
error: "playwright is not installed on this host (run: npm i playwright && npx playwright install chromium)",
|
|
74
100
|
};
|
|
75
101
|
}
|
|
76
|
-
// One shot, with a single self-heal retry: if the cached browser died (crash,
|
|
77
|
-
// or a system Chrome that closed), drop it and relaunch once.
|
|
78
102
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
79
103
|
let browser = await getBrowser();
|
|
80
104
|
if (!browser)
|
|
81
105
|
return { ok: false, error: "failed to launch headless chromium (check CHROME_BIN / playwright install)" };
|
|
82
|
-
// If the cached handle is already disconnected, force a relaunch.
|
|
83
106
|
if (typeof browser.isConnected === "function" && !browser.isConnected()) {
|
|
84
107
|
browserPromise = null;
|
|
85
108
|
browser = await getBrowser();
|
|
@@ -88,19 +111,12 @@ export async function captureWithPlaywright(url, opts = {}) {
|
|
|
88
111
|
}
|
|
89
112
|
let ctx;
|
|
90
113
|
try {
|
|
91
|
-
ctx = await browser.newContext({
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
95
|
-
const page = await ctx.newPage();
|
|
96
|
-
await page.goto(url, { waitUntil: "load", timeout: 30_000 });
|
|
97
|
-
await page.waitForTimeout(1200); // let webfonts/lazy images settle
|
|
98
|
-
const png = await page.screenshot({ fullPage: opts.fullPage !== false, type: "png" });
|
|
99
|
-
return { ok: true, png: Buffer.from(png) };
|
|
114
|
+
ctx = await browser.newContext({ viewport: { width, height: 900 }, deviceScaleFactor: scale });
|
|
115
|
+
const value = await fn(ctx);
|
|
116
|
+
return { ok: true, value };
|
|
100
117
|
}
|
|
101
118
|
catch (e) {
|
|
102
119
|
const msg = String(e?.message ?? e);
|
|
103
|
-
// Browser-died errors → reset and retry once; other errors → fail now.
|
|
104
120
|
const dead = /closed|disconnected|crash|Target page, context or browser/i.test(msg);
|
|
105
121
|
if (dead && attempt === 0) {
|
|
106
122
|
browserPromise = null;
|
|
@@ -115,6 +131,73 @@ export async function captureWithPlaywright(url, opts = {}) {
|
|
|
115
131
|
}
|
|
116
132
|
return { ok: false, error: "playwright capture failed after retry" };
|
|
117
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
|
+
}
|
|
118
201
|
// ---------------------------------------------------------------------------
|
|
119
202
|
// SSRF guard — this route fetches an arbitrary `url`, so block private/loopback
|
|
120
203
|
// targets (someone could otherwise screenshot internal services). Pure + exported
|
package/dist/tools/media.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|