From 6bd93dfcbe5ad6f203939bbb3c36c37419113e1c Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:27:13 +0700 Subject: [PATCH 1/2] feat: downsample get_screen MCP images to save tokens Add an `imageMaxWidth` parameter (default 400 px) to the `get_screen` tool. When the stored screen has `svgContent` and its width exceeds `imageMaxWidth`, re-render a fresh PNG from the SVG via @resvg/resvg-js instead of returning the full-resolution Retina PNG. Pass `imageMaxWidth: 0` to disable resizing. The `svg+xml` image branch now honors the same parameter, and both resvg call sites share a `renderSvgToPngBase64` helper. Rasterizing straight from the stored vector is sharper than post-resizing a 2x PNG, and reuses an existing dependency without pulling in a new image library. --- mcp-server/src/tools/screen-tools.js | 43 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/mcp-server/src/tools/screen-tools.js b/mcp-server/src/tools/screen-tools.js index 72b3a33..342905a 100644 --- a/mcp-server/src/tools/screen-tools.js +++ b/mcp-server/src/tools/screen-tools.js @@ -87,12 +87,13 @@ export const screenTools = [ }, { name: "get_screen", - description: "Get full details of a specific screen, including hotspots. Image data is excluded by default to keep responses small.", + description: "Get full details of a specific screen, including hotspots. Image data is excluded by default to keep responses small. When included, the image is downsampled to 400 px wide by default to reduce token cost; pass imageMaxWidth: 0 for the original full-resolution image.", inputSchema: { type: "object", properties: { screenId: { type: "string", description: "ID of the screen" }, includeImage: { type: "boolean", description: "Include base64 imageData in response (default: false)" }, + imageMaxWidth: { type: "number", description: "Max width in px for the returned image. Image is re-rendered from SVG at this width to reduce base64 size and token cost. Pass 0 to disable resizing and return the original full-resolution image. Default: 400" }, }, required: ["screenId"], }, @@ -152,6 +153,18 @@ function displayedImageHeight(rawWidth, rawHeight) { return Math.round(rawHeight * DEFAULT_SCREEN_WIDTH / rawWidth); } +const DEFAULT_IMAGE_MAX_WIDTH = 400; + +async function renderSvgToPngBase64(svgString, targetWidth) { + const { Resvg } = await import("@resvg/resvg-js"); + const fitTo = targetWidth > 0 + ? { mode: "width", value: targetWidth } + : { mode: "original" }; + const resvg = new Resvg(svgString, { fitTo }); + const pngBuffer = resvg.render().asPng(); + return Buffer.from(pngBuffer).toString("base64"); +} + export async function handleScreenTool(name, args, state, renderer) { switch (name) { case "create_screen": { @@ -249,6 +262,8 @@ export async function handleScreenTool(name, args, state, renderer) { delete result.imageData; result.hasImage = !!imageData; + const maxWidth = args.imageMaxWidth ?? DEFAULT_IMAGE_MAX_WIDTH; + const content = [ { type: "text", text: JSON.stringify(result, null, 2) }, ]; @@ -261,17 +276,33 @@ export async function handleScreenTool(name, args, state, renderer) { if (subtype === "svg+xml") { // MCP image blocks don't support SVG; convert to PNG via resvg try { - const { Resvg } = await import("@resvg/resvg-js"); const svgString = Buffer.from(rawBase64, "base64").toString("utf-8"); - const resvg = new Resvg(svgString, { fitTo: { mode: "original" } }); - const pngBuffer = resvg.render().asPng(); - const pngBase64 = Buffer.from(pngBuffer).toString("base64"); + const targetWidth = maxWidth > 0 + ? maxWidth + : (screen.imageWidth || DEFAULT_IMAGE_MAX_WIDTH); + const pngBase64 = await renderSvgToPngBase64(svgString, targetWidth); content.push({ type: "image", data: pngBase64, mimeType: "image/png" }); } catch { content.push({ type: "text", text: "[SVG image — could not convert to PNG]" }); } } else { - content.push({ type: "image", data: rawBase64, mimeType: `image/${subtype}` }); + // Downsample from stored SVG when a smaller width is requested + const shouldDownsample = + maxWidth > 0 && + screen.svgContent && + screen.imageWidth && + screen.imageWidth > maxWidth; + + if (shouldDownsample) { + try { + const pngBase64 = await renderSvgToPngBase64(screen.svgContent, maxWidth); + content.push({ type: "image", data: pngBase64, mimeType: "image/png" }); + } catch { + content.push({ type: "image", data: rawBase64, mimeType: `image/${subtype}` }); + } + } else { + content.push({ type: "image", data: rawBase64, mimeType: `image/${subtype}` }); + } } } } From 2208bd34f8b3a58503b6c5f623171fbc3a274568 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Fri, 17 Apr 2026 06:33:15 +0700 Subject: [PATCH 2/2] refactor: simplify get_screen downsample to one inline branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `renderSvgToPngBase64` helper and `DEFAULT_IMAGE_MAX_WIDTH` constant; inline the single resvg call at the PNG-branch downsample site. Revert the `svg+xml` branch to its original `mode: "original"` form — `imageMaxWidth` is only meaningful for Satori-rendered screens, which always store PNG data URIs, so applying it to imported SVG screens was outside the PR's scope and required an awkward `(imageWidth || 400)` fallback. No behavior change for the PNG branch: the four-case E2E (400 / 200 / 0 / no-svg) still yields 400, 200, 786, 786 px respectively. --- mcp-server/src/tools/screen-tools.js | 48 +++++++++------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/mcp-server/src/tools/screen-tools.js b/mcp-server/src/tools/screen-tools.js index 342905a..26ef58b 100644 --- a/mcp-server/src/tools/screen-tools.js +++ b/mcp-server/src/tools/screen-tools.js @@ -153,18 +153,6 @@ function displayedImageHeight(rawWidth, rawHeight) { return Math.round(rawHeight * DEFAULT_SCREEN_WIDTH / rawWidth); } -const DEFAULT_IMAGE_MAX_WIDTH = 400; - -async function renderSvgToPngBase64(svgString, targetWidth) { - const { Resvg } = await import("@resvg/resvg-js"); - const fitTo = targetWidth > 0 - ? { mode: "width", value: targetWidth } - : { mode: "original" }; - const resvg = new Resvg(svgString, { fitTo }); - const pngBuffer = resvg.render().asPng(); - return Buffer.from(pngBuffer).toString("base64"); -} - export async function handleScreenTool(name, args, state, renderer) { switch (name) { case "create_screen": { @@ -262,7 +250,7 @@ export async function handleScreenTool(name, args, state, renderer) { delete result.imageData; result.hasImage = !!imageData; - const maxWidth = args.imageMaxWidth ?? DEFAULT_IMAGE_MAX_WIDTH; + const maxWidth = args.imageMaxWidth ?? 400; const content = [ { type: "text", text: JSON.stringify(result, null, 2) }, @@ -276,33 +264,27 @@ export async function handleScreenTool(name, args, state, renderer) { if (subtype === "svg+xml") { // MCP image blocks don't support SVG; convert to PNG via resvg try { + const { Resvg } = await import("@resvg/resvg-js"); const svgString = Buffer.from(rawBase64, "base64").toString("utf-8"); - const targetWidth = maxWidth > 0 - ? maxWidth - : (screen.imageWidth || DEFAULT_IMAGE_MAX_WIDTH); - const pngBase64 = await renderSvgToPngBase64(svgString, targetWidth); + const resvg = new Resvg(svgString, { fitTo: { mode: "original" } }); + const pngBuffer = resvg.render().asPng(); + const pngBase64 = Buffer.from(pngBuffer).toString("base64"); content.push({ type: "image", data: pngBase64, mimeType: "image/png" }); } catch { content.push({ type: "text", text: "[SVG image — could not convert to PNG]" }); } - } else { - // Downsample from stored SVG when a smaller width is requested - const shouldDownsample = - maxWidth > 0 && - screen.svgContent && - screen.imageWidth && - screen.imageWidth > maxWidth; - - if (shouldDownsample) { - try { - const pngBase64 = await renderSvgToPngBase64(screen.svgContent, maxWidth); - content.push({ type: "image", data: pngBase64, mimeType: "image/png" }); - } catch { - content.push({ type: "image", data: rawBase64, mimeType: `image/${subtype}` }); - } - } else { + } else if (maxWidth > 0 && screen.svgContent && screen.imageWidth > maxWidth) { + // Re-render a smaller PNG from the stored SVG to cut token cost + try { + const { Resvg } = await import("@resvg/resvg-js"); + const resvg = new Resvg(screen.svgContent, { fitTo: { mode: "width", value: maxWidth } }); + const pngBase64 = Buffer.from(resvg.render().asPng()).toString("base64"); + content.push({ type: "image", data: pngBase64, mimeType: "image/png" }); + } catch { content.push({ type: "image", data: rawBase64, mimeType: `image/${subtype}` }); } + } else { + content.push({ type: "image", data: rawBase64, mimeType: `image/${subtype}` }); } } }