diff --git a/mcp-server/README.md b/mcp-server/README.md
index 30d131a..cd0c6e3 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -1,6 +1,6 @@
# Drawd MCP Server
-Standalone MCP server for AI agent integration with Drawd flows.
+Standalone [Model Context Protocol](https://modelcontextprotocol.io) server for AI agent integration with Drawd flows.
## Tools
@@ -20,6 +20,7 @@ Standalone MCP server for AI agent integration with Drawd flows.
| `update_screen` | Update screen properties |
| `update_screen_image` | Re-render a screen's HTML to update its image |
| `delete_screen` | Delete a screen and its connections |
+| `create_screen_with_hotspots` | Transactional: create a screen + hotspots + connections in one call (see below) |
### Hotspots & Connections
@@ -174,3 +175,44 @@ Empty `warnings: []` means the HTML passes all checks.
| `no-br-tag` | ` ` is not supported; use margin/padding |
| `text-needs-nowrap` | Text leaves need `white-space: nowrap` to prevent unexpected wrapping |
| `unsupported-css-property` | CSS property is outside Satori's supported allowlist |
+
+### `create_screen_with_hotspots`
+
+Create a screen with hotspots and connections in a single transactional call. If any sub-step fails, all changes are rolled back — the `.drawd` file is either fully updated or unchanged.
+
+**Placeholders:**
+
+| Placeholder | Resolves to |
+|-------------|-------------|
+| `@self` | The just-created screen |
+| `@caller` | The screen specified by `callerScreenId` (required when used) |
+
+**Example:**
+
+```json
+{
+ "screen": {
+ "name": "Paywall",
+ "html": "
...
",
+ "device": "iphone",
+ "x": 1240,
+ "y": 4450
+ },
+ "hotspots": [
+ { "label": "Dismiss", "x": 85, "y": 4, "w": 10, "h": 6, "action": "navigate", "target": "@caller" },
+ { "label": "Purchase", "x": 8, "y": 82, "w": 84, "h": 8, "action": "custom", "customDescription": "Trigger in-app purchase flow" }
+ ],
+ "connections": [
+ { "fromHotspot": "Purchase", "to": "screen-id-of-success-page", "action": "navigate", "data_flow": [{ "name": "productId", "type": "String" }] }
+ ],
+ "callerScreenId": "screen-id-that-opened-paywall",
+ "includeThumbnail": true
+}
+```
+
+**Validation rules:**
+
+- Hotspot labels must be unique within the call.
+- `callerScreenId` must be provided (and reference an existing screen) when any placeholder uses `@caller`.
+- `connections[].fromHotspot` must match a label in the `hotspots` array.
+- Duplicate connections (same from-screen + to-screen + hotspot) auto-created by a hotspot's `target` are silently skipped.
diff --git a/mcp-server/src/renderer/__tests__/inline-images.test.js b/mcp-server/src/renderer/__tests__/inline-images.test.js
index aad3e6e..07d90bb 100644
--- a/mcp-server/src/renderer/__tests__/inline-images.test.js
+++ b/mcp-server/src/renderer/__tests__/inline-images.test.js
@@ -25,11 +25,15 @@ function mockFetchBinary(byteValue, contentType) {
describe("inlineRemoteImages — passthrough", () => {
it("returns input unchanged when no tags exist", async () => {
const html = "
no images here
";
- expect(await inlineRemoteImages(html)).toBe(html);
+ const result = await inlineRemoteImages(html);
+ expect(result.html).toBe(html);
+ expect(result.warnings).toEqual([]);
});
it("returns input unchanged for non-string input", async () => {
- expect(await inlineRemoteImages(undefined)).toBeUndefined();
+ const result = await inlineRemoteImages(undefined);
+ expect(result.html).toBeUndefined();
+ expect(result.warnings).toEqual([]);
});
});
@@ -38,22 +42,26 @@ describe("inlineRemoteImages — allowlist enforcement", () => {
_imageInlineInternals.imageBytesCache.clearMemory();
});
- it("replaces disallowed-host with transparent PNG", async () => {
+ it("replaces disallowed-host with transparent PNG and emits a warning", async () => {
const cache = makeFreshCache();
global.fetch = vi.fn();
const html = '';
- const out = await inlineRemoteImages(html, cache);
- expect(out).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ const result = await inlineRemoteImages(html, cache);
+ expect(result.html).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ expect(result.warnings).toHaveLength(1);
+ expect(result.warnings[0]).toContain("evil.example.com/track.gif");
+ expect(result.warnings[0]).toContain("allowlist");
expect(global.fetch).not.toHaveBeenCalled();
});
- it("replaces non-http(s) URLs with transparent PNG", async () => {
+ it("replaces non-allowlisted hosts with transparent PNG and emits a warning", async () => {
const cache = makeFreshCache();
global.fetch = vi.fn();
- // Note: javascript: would not match the regex (no http/https). Test ftp.
const html = '';
- const out = await inlineRemoteImages(html, cache);
- expect(out).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ const result = await inlineRemoteImages(html, cache);
+ expect(result.html).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ expect(result.warnings).toHaveLength(1);
+ expect(result.warnings[0]).toContain("allowlist");
expect(global.fetch).not.toHaveBeenCalled();
});
});
@@ -63,14 +71,15 @@ describe("inlineRemoteImages — happy path", () => {
_imageInlineInternals.imageBytesCache.clearMemory();
});
- it("downloads and inlines an allowlisted picsum URL", async () => {
+ it("downloads and inlines an allowlisted picsum URL with no warnings", async () => {
const cache = makeFreshCache();
mockFetchBinary(0x42, "image/jpeg");
const url = "https://picsum.photos/seed/test/100/100";
const html = ``;
- const out = await inlineRemoteImages(html, cache);
- expect(out).toContain("data:image/jpeg;base64,");
- expect(out).not.toContain(url);
+ const result = await inlineRemoteImages(html, cache);
+ expect(result.html).toContain("data:image/jpeg;base64,");
+ expect(result.html).not.toContain(url);
+ expect(result.warnings).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
@@ -79,21 +88,25 @@ describe("inlineRemoteImages — happy path", () => {
mockFetchBinary(0x42, "image/jpeg");
const url = "https://images.unsplash.com/photo-abc";
const html = `
`;
- await inlineRemoteImages(html, cache);
+ const result = await inlineRemoteImages(html, cache);
+ expect(result.warnings).toEqual([]);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
- it("falls back to transparent PNG when fetch fails", async () => {
+ it("falls back to transparent PNG and emits a warning when fetch fails", async () => {
const cache = makeFreshCache();
global.fetch = vi.fn(async () => {
throw new Error("network down");
});
const html = '';
- const out = await inlineRemoteImages(html, cache);
- expect(out).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ const result = await inlineRemoteImages(html, cache);
+ expect(result.html).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ expect(result.warnings).toHaveLength(1);
+ expect(result.warnings[0]).toContain("network down");
+ expect(result.warnings[0]).toContain("photo-bad");
});
- it("handles a mix of allowed and disallowed URLs", async () => {
+ it("handles a mix of allowed and disallowed URLs with correct warnings", async () => {
const cache = makeFreshCache();
let call = 0;
global.fetch = vi.fn(async () => {
@@ -110,9 +123,13 @@ describe("inlineRemoteImages — happy path", () => {
'' +
'' +
'';
- const out = await inlineRemoteImages(html, cache);
- expect(out).toContain("data:image/png;base64,");
- expect(out).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ const result = await inlineRemoteImages(html, cache);
+ expect(result.html).toContain("data:image/png;base64,");
+ expect(result.html).toContain(_imageInlineInternals.TRANSPARENT_PNG_DATA_URI);
+ // Only the disallowed URL should produce a warning.
+ expect(result.warnings).toHaveLength(1);
+ expect(result.warnings[0]).toContain("attacker.example/bad");
+ expect(result.warnings[0]).toContain("allowlist");
// Only the allowed URL should have triggered a network call.
expect(global.fetch).toHaveBeenCalledTimes(1);
});
diff --git a/mcp-server/src/renderer/satori-renderer.js b/mcp-server/src/renderer/satori-renderer.js
index 8a8b82d..448feeb 100644
--- a/mcp-server/src/renderer/satori-renderer.js
+++ b/mcp-server/src/renderer/satori-renderer.js
@@ -109,15 +109,16 @@ function swapSrc(html, url, replacement) {
*
* @param {string} html
* @param {Cache} [imageCache] Override the module-level cache (mostly for tests).
- * @returns {Promise}
+ * @returns {Promise<{html: string, warnings: string[]}>}
*/
export async function inlineRemoteImages(html, imageCache = _imageBytesCache) {
- if (typeof html !== "string" || !html.includes(" m[1]))];
+ const warnings = [];
// Resolve each unique URL to a data URI. Cap concurrency at 4.
const results = new Map();
@@ -125,7 +126,9 @@ export async function inlineRemoteImages(html, imageCache = _imageBytesCache) {
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
while (queue.length > 0) {
const url = queue.shift();
- results.set(url, await resolveImageToDataUri(url, imageCache));
+ const { dataUri, warning } = await resolveImageToDataUri(url, imageCache);
+ results.set(url, dataUri);
+ if (warning) warnings.push(warning);
}
});
await Promise.all(workers);
@@ -134,7 +137,7 @@ export async function inlineRemoteImages(html, imageCache = _imageBytesCache) {
for (const [url, dataUri] of results) {
out = swapSrc(out, url, dataUri);
}
- return out;
+ return { html: out, warnings };
}
async function resolveImageToDataUri(url, imageCache) {
@@ -142,7 +145,7 @@ async function resolveImageToDataUri(url, imageCache) {
process.stderr.write(
`[drawd-mcp] Rejecting outside allowlist: ${safeHostFor(url)}\n`,
);
- return TRANSPARENT_PNG_DATA_URI;
+ return { dataUri: TRANSPARENT_PNG_DATA_URI, warning: `${url} rejected by remote-image allowlist, replaced with transparent fallback` };
}
try {
const cached = await imageCache.getOrFetch(url, async () => {
@@ -154,16 +157,18 @@ async function resolveImageToDataUri(url, imageCache) {
lenBuf.writeUInt32BE(ctBuf.length, 0);
return Buffer.concat([lenBuf, ctBuf, bytes]);
});
- if (!Buffer.isBuffer(cached) || cached.length < 4) return TRANSPARENT_PNG_DATA_URI;
+ if (!Buffer.isBuffer(cached) || cached.length < 4) {
+ return { dataUri: TRANSPARENT_PNG_DATA_URI, warning: `${url} returned empty response, replaced with transparent fallback` };
+ }
const ctLen = cached.readUInt32BE(0);
const contentType = cached.slice(4, 4 + ctLen).toString("utf8");
const bytes = cached.slice(4 + ctLen);
- return `data:${contentType};base64,${bytes.toString("base64")}`;
+ return { dataUri: `data:${contentType};base64,${bytes.toString("base64")}` };
} catch (err) {
process.stderr.write(
`[drawd-mcp] Failed to inline ${safeHostFor(url)}: ${err.message}\n`,
);
- return TRANSPARENT_PNG_DATA_URI;
+ return { dataUri: TRANSPARENT_PNG_DATA_URI, warning: `${url} fetch failed (${err.message}), replaced with transparent fallback` };
}
}
@@ -243,7 +248,7 @@ export class SatoriRenderer {
// before any other preprocessing. Satori cannot fetch URLs itself, so
// unresolved elements would otherwise render as broken/missing.
// Hostname allowlist is enforced inside inlineRemoteImages (SSRF guard).
- const inlinedHtml = await inlineRemoteImages(expandedHtml);
+ const { html: inlinedHtml, warnings: imageWarnings } = await inlineRemoteImages(expandedHtml);
// satori-html does not decode HTML entities. Agents frequently write
// numeric entities (●, ●) and safe named entities (•,
@@ -318,6 +323,7 @@ export class SatoriRenderer {
chrome: chromeRenderError ? [] : expandedChrome,
chromeStyle,
safeArea: chromeRenderError ? { top: 0, bottom: 0, left: 0, right: 0 } : composed.safeArea,
+ warnings: imageWarnings,
...(chromeRenderError ? { chromeRenderError } : {}),
};
}
diff --git a/mcp-server/src/server.js b/mcp-server/src/server.js
index f3d3849..381f1b6 100644
--- a/mcp-server/src/server.js
+++ b/mcp-server/src/server.js
@@ -15,6 +15,7 @@ import { commentTools, handleCommentTool } from "./tools/comment-tools.js";
import { generationTools, handleGenerationTool } from "./tools/generation-tools.js";
import { selectionTools, handleSelectionTool } from "./tools/selection-tools.js";
import { assetTools, handleAssetTool } from "./tools/asset-tools.js";
+import { bundleTools, handleBundleTool } from "./tools/bundle-tools.js";
import { validationTools, handleValidationTool } from "./tools/validation-tools.js";
import { composeTools, handleComposeTool } from "./tools/compose-tools.js";
import { layoutTools, handleLayoutTool } from "./tools/layout-tools.js";
@@ -31,6 +32,7 @@ const COMMENT_TOOL_NAMES = new Set(commentTools.map((t) => t.name));
const GENERATION_TOOL_NAMES = new Set(generationTools.map((t) => t.name));
const SELECTION_TOOL_NAMES = new Set(selectionTools.map((t) => t.name));
const ASSET_TOOL_NAMES = new Set(assetTools.map((t) => t.name));
+const BUNDLE_TOOL_NAMES = new Set(bundleTools.map((t) => t.name));
const VALIDATION_TOOL_NAMES = new Set(validationTools.map((t) => t.name));
const COMPOSE_TOOL_NAMES = new Set(composeTools.map((t) => t.name));
const LAYOUT_TOOL_NAMES = new Set(layoutTools.map((t) => t.name));
@@ -68,6 +70,7 @@ const ALL_TOOLS = [
...withFilePath(commentTools),
...withFilePath(generationTools),
...withFilePath(selectionTools),
+ ...withFilePath(bundleTools),
...withFilePath(layoutTools),
...withFilePath(designTokenTools),
// Asset tools (icons, stock photos) are stateless — no flow context required.
@@ -120,6 +123,8 @@ export function createServer(state, renderer, bridge) {
result = handleGenerationTool(name, args, state);
} else if (SELECTION_TOOL_NAMES.has(name)) {
result = handleSelectionTool(name, args, state, bridge);
+ } else if (BUNDLE_TOOL_NAMES.has(name)) {
+ result = await handleBundleTool(name, args, state, renderer);
} else if (DESIGN_TOKEN_TOOL_NAMES.has(name)) {
result = handleDesignTokenTool(name, args, state);
} else if (ASSET_TOOL_NAMES.has(name)) {
diff --git a/mcp-server/src/tools/__tests__/bundle-tools.test.js b/mcp-server/src/tools/__tests__/bundle-tools.test.js
new file mode 100644
index 0000000..a93055d
--- /dev/null
+++ b/mcp-server/src/tools/__tests__/bundle-tools.test.js
@@ -0,0 +1,478 @@
+// @vitest-environment node
+//
+// Tests for create_screen_with_hotspots — the transactional bundle tool (#9.9).
+//
+// Uses a mock renderer to avoid the Satori startup cost. The real FlowState
+// is used (with _autoSave mocked) so we exercise the actual mutation paths.
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { FlowState } from "../../state.js";
+import { handleBundleTool } from "../bundle-tools.js";
+
+// ── Minimal valid PNG buffer (just enough for readPngDimensions) ──────────
+function makePng(width = 786, height = 1704) {
+ const buf = Buffer.alloc(29);
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
+ buf.writeUInt32BE(13, 8);
+ Buffer.from("IHDR").copy(buf, 12);
+ buf.writeUInt32BE(width, 16);
+ buf.writeUInt32BE(height, 20);
+ buf.writeUInt8(8, 24); // bit depth
+ buf.writeUInt8(6, 25); // color type (RGBA)
+ return buf;
+}
+
+// ── Mock renderer ────────────────────────────────────────────────────────
+function mockRenderer(width = 786, height = 1704) {
+ const png = makePng(width, height);
+ return {
+ render: vi.fn().mockResolvedValue({
+ pngBuffer: png,
+ width,
+ height,
+ svgString: "",
+ device: "iphone",
+ chrome: ["status-bar-ios"],
+ chromeStyle: "light",
+ safeArea: { top: 59, bottom: 34, left: 0, right: 0 },
+ }),
+ toDataUri: vi.fn().mockReturnValue(
+ `data:image/png;base64,${Buffer.from(png).toString("base64")}`,
+ ),
+ };
+}
+
+// ── State helper ─────────────────────────────────────────────────────────
+function makeState() {
+ const state = new FlowState();
+ state._autoSave = vi.fn();
+ return state;
+}
+
+const SIMPLE_HTML = '